diff --git a/.gitignore b/.gitignore index eb0b7e1..627bdb4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,10 @@ coverage/ playwright-report/ test-results/ .env* -config/site.production.json +config/site.production.build.json +dist-cloudflare/ +.cloudflare-stage-*/ +.wrangler/ release-evidence/ *.release.tar *.release.tar.partial-* diff --git a/config/site.production.json b/config/site.production.json new file mode 100644 index 0000000..3d042aa --- /dev/null +++ b/config/site.production.json @@ -0,0 +1,5 @@ +{ + "environment": "production", + "canonicalUrl": "https://lucapislaru.ro/", + "buildId": "production-template" +} diff --git a/docs/deployment/cloudflare-setup.md b/docs/deployment/cloudflare-setup.md new file mode 100644 index 0000000..c979ea1 --- /dev/null +++ b/docs/deployment/cloudflare-setup.md @@ -0,0 +1,144 @@ +# Publish the portfolio with Cloudflare and ROTLD + +The primary address is **https://lucapislaru.ro/**. The accented domain **lucapîslaru.ro** redirects to it. Its ASCII/Punycode form is **xn--lucapslaru-z8a.ro**; these are two spellings of the same accented domain, not a third domain. + +Use Cloudflare Workers Static Assets on the free plan. Keep both registrations and renewals at ROTLD. There is no application server, database, or request-time SSR to provision. + +## 1. Export locally + +Use Node.js 22.22.1 or a newer compatible Node 22 release. From the Portfolio project directory, run: + +```sh +npm ci +npm run build:production +npm run verify:cloudflare +``` + +`npm ci` is only needed when setting up a fresh checkout or when dependencies change. It requires registry access. The current machine already has the dependencies. + +The finished upload directory is **dist-cloudflare/**. The export command: + +- checks the public content/asset references and prepared asset hashes; +- type-checks application source and Vite configuration using `tsconfig.production.json`; +- builds the client and build-time SSR code, then pre-renders the complete document and hydration snapshot; +- reads `config/site.production.json` and creates an ignored `config/site.production.build.json` with a fresh production build ID; +- verifies the exact artifact inventory, bytes, public content, metadata, and transfer budgets; +- scans the export for test hooks, fault/tier URL switches, source-map references, known private evidence fields, recognizable credential patterns, and local source paths; +- copies only the verified HTML, robots, sitemap, and referenced assets into the upload directory, and adds a real Cloudflare `_headers` file. + +Production exports reject `PORTFOLIO_TEST_*`, `VITE_*` environment overrides and a non-production `NODE_ENV`. The export-specific Vite mode does not load local `.env` files or copy a `public/` folder. Benchmark commands continue to use their existing separate build mode. + +Only `dist-cloudflare/` is configured for upload. Do not upload the repository, `dist-client/`, `dist-ssr/`, or the preview output. The generated `_headers` file is Cloudflare configuration, not a page served to visitors. It supplies security headers and one revalidation cache policy, avoiding stale media at stable filenames. Unknown URLs return 404; there is no catch-all homepage fallback. + +The production config is public and committed; it contains no credentials. Its `production-template` build ID is replaced for each export. The preview config remains `noindex` and uses its existing localhost canonical URL. + +## 2. Add both domains to Cloudflare + +In [Cloudflare](https://dash.cloudflare.com/), add each domain as a separate website/domain and select the **Free** plan: + +1. `lucapislaru.ro` +2. `xn--lucapslaru-z8a.ro` (Cloudflare may display `lucapîslaru.ro`) + +Review the imported DNS records. Preserve any existing MX and email-related TXT records if you use domain email. Cloudflare will show an assigned pair of nameservers for each domain. Copy the exact pair for that domain; the pairs can differ. This is a DNS change, not a registrar transfer. + +## 3. Set the nameservers at ROTLD + +Open [ROTLD](https://www.rotld.ro/) and use **Domenii .ro → Administrare Domenii → On-Line**. Log in for the first domain and open **Nameservers**. Replace the old nameservers with the exact pair Cloudflare assigned to that domain, save, and complete any confirmation ROTLD requests. Repeat for the accented domain. + +If DNSSEC is already enabled, remove/disable the old DNSSEC delegation at ROTLD before changing nameservers. Once Cloudflare reports the domain Active, enable DNSSEC in Cloudflare and publish its new DS details at ROTLD. Do not reuse the previous provider's DS record. [Cloudflare nameserver/DNSSEC instructions](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/), [ROTLD administration guidance](https://www.rotld.ro/faq-general-questions/). + +Wait until **both** domains show **Active** in Cloudflare before proceeding. No hosting IP address is needed for the main site. + +## 4. Deploy the main site + +Log in to Cloudflare's deployment tool from the project directory: + +```sh +npx --yes wrangler@4.129.0 login +``` + +The browser asks you to authorize Wrangler. Credentials stay in Wrangler's local login storage, outside the site export. Then publish: + +```sh +npm run deploy:cloudflare +``` + +This command **publishes publicly**. Wrangler first runs the entire production export again, so a failed build stops deployment. It creates/updates the Worker **luca-pislaru-portfolio**, uploads only `dist-cloudflare/`, and attaches **lucapislaru.ro** through the custom-domain entry in `wrangler.jsonc`. + +Cloudflare manages the main site's DNS record and HTTPS certificate. If it reports a conflicting old website A/AAAA/CNAME record at the root, inspect that exact record and remove the obsolete website record before retrying; preserve email and other unrelated records. Do not create a placeholder A record for the main site's root. + +The `workers.dev` address and version preview URLs are disabled in the configuration. This keeps alternate deployment URLs from exposing/indexing another copy of the site. Keep those settings disabled in the dashboard too. [Workers custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/). + +To validate the deployment configuration without uploading anything: + +```sh +npx --yes wrangler@4.129.0 deploy --config wrangler.jsonc --dry-run +``` + +## 5. Redirect the accented domain + +Select the **xn--lucapslaru-z8a.ro** domain in Cloudflare. Under **DNS → Records**, create these redirect-only records, with the orange cloud/proxy enabled: + +| Type | Name | Address | Proxy | +| --- | --- | --- | --- | +| A | @ | 192.0.2.1 | Proxied | +| A | www | 192.0.2.1 | Proxied | + +`192.0.2.1` is Cloudflare's documented placeholder for redirect-only hostnames; it is not your server's address. [Cloudflare domain redirect instructions](https://developers.cloudflare.com/fundamentals/manage-domains/redirect-domain/). + +Open **Rules → Overview → Create rule → Redirect Rule** (also called Single Redirect). Name it `Accented domain to primary`. Use this custom filter expression: + +```text +(http.host eq "xn--lucapslaru-z8a.ro" or http.host eq "www.xn--lucapslaru-z8a.ro") +``` + +For the destination, select **Dynamic** and enter: + +```text +concat("https://lucapislaru.ro", http.request.uri.path) +``` + +Choose **308 Permanent Redirect**, enable **Preserve query string**, and deploy the rule. It handles both HTTP and HTTPS without adding another copy of the website. Wait for the accented domain's Cloudflare edge certificate to become Active before checking HTTPS redirects. + +## 6. Redirect www and HTTP on the main domain + +Switch to the **lucapislaru.ro** domain. Add a proxied `A` record named `www` pointing to `192.0.2.1`. Leave the root record managed by Workers. + +Create a second Redirect Rule named `Primary HTTPS canonical address` with this filter: + +```text +(http.host eq "www.lucapislaru.ro" or (http.host eq "lucapislaru.ro" and not ssl)) +``` + +Use the same dynamic destination expression, **308** status, and **Preserve query string** setting as above. This sends HTTP and www requests directly to the preferred HTTPS address. The HTTPS root must not match this rule, or it would loop. A separate HTTP-upgrade rule is not needed. + +## 7. Check the live site and indexing + +After HTTPS is active, verify: + +| Address | Expected result | +| --- | --- | +| https://lucapislaru.ro/ | Portfolio loads with HTTP 200 | +| http://lucapislaru.ro/ | One 308 to https://lucapislaru.ro/ | +| https://www.lucapislaru.ro/ | One 308 to the primary domain | +| http://xn--lucapslaru-z8a.ro/ | One 308 to the primary domain | +| https://xn--lucapslaru-z8a.ro/ | One 308 to the primary domain | +| https://www.xn--lucapslaru-z8a.ro/ | One 308 to the primary domain | +| https://lucapislaru.ro/robots.txt | Allow crawling; sitemap names the main domain | +| https://lucapislaru.ro/sitemap.xml | Contains https://lucapislaru.ro/ | +| https://lucapislaru.ro/not-a-real-page | HTTP 404 | +| https://lucapislaru.ro/.env | HTTP 404 | + +Check an accented-domain URL with a path and query, too: `/not-a-real-page?check=1` should redirect to the identical path/query on the primary domain, which then correctly returns 404. Disable JavaScript in your browser and reload the homepage: the actual portfolio text and contact disclosure should remain usable. Re-enable JavaScript and check the animated experience. + +View the homepage source and confirm `index, follow`, a canonical URL of `https://lucapislaru.ro/`, and social image/structured-data URLs on that same origin. Do not place a Cloudflare Access login or a blanket bot challenge in front of the public domain. + +In [Google Search Console](https://search.google.com/search-console/), add a **Domain property** for `lucapislaru.ro`, verify it by adding Google's TXT record in Cloudflare DNS, and submit `https://lucapislaru.ro/sitemap.xml`. Inspect the homepage URL and request indexing. The site becomes eligible for indexing; Google decides whether and when to index it. [Google sitemap guidance](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap). + +## Later updates and verification scope + +For future updates, run `npm run deploy:cloudflare` from the intended checkout. It rebuilds and validates before publishing. Avoid building a preview and production export concurrently because they share intermediate `dist*` directories. + +This is a static Cloudflare deployment workflow. The existing Nginx signed-release/activation scripts are separate and are not executed by this command. The production type-check intentionally excludes test files; the general `npm run typecheck` and full test suite remain separate quality checks. The old `smoke:production` command assumes the Nginx signed-manifest header and a literal pre-rendered `mailto:` link, so it is not the Cloudflare acceptance command. + +Export checks establish the exact public file boundary and detect known prohibited surfaces. They do not certify private source history, all possible secrets, or the semantic privacy of every screenshot. Existing public portfolio descriptions, approved screenshots, contact details and social links remain public. The encoded email is recoverable by visitors, as required for the contact feature. No credentials belong in public content or frontend source. diff --git a/package.json b/package.json index 4dbec27..ba6edab 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "doc": "docs" }, "scripts": { + "build:production": "node scripts/export-cloudflare.mjs", + "verify:cloudflare": "node scripts/export-cloudflare.mjs --verify", + "deploy:cloudflare": "npx --yes wrangler@4.129.0 deploy --config wrangler.jsonc", "dev": "vite --host 127.0.0.1", "typecheck": "vue-tsc --noEmit", "test:unit": "vitest run", diff --git a/scripts/export-cloudflare.mjs b/scripts/export-cloudflare.mjs new file mode 100644 index 0000000..86d47c7 --- /dev/null +++ b/scripts/export-cloudflare.mjs @@ -0,0 +1,142 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { parseSiteConfig } from '../src/content/schemas.ts'; +import { serializeStaticHostHeaders } from '../src/security/security-policy.ts'; +import { parseBuildAuthority } from './build-contract.mjs'; +import { prerender } from './prerender.mjs'; +import { verifyArtifacts } from './verify-artifacts.mjs'; + +const scriptPath = fileURLToPath(import.meta.url); +const root = resolve(dirname(scriptPath), '..'); +const configPath = 'config/site.production.build.json'; +const output = resolve(root, 'dist-cloudflare'); +const rootFiles = ['index.html', 'robots.txt', 'sitemap.xml']; +const prohibited = /__portfolio(?:_|JourneyInstrumentation)|PORTFOLIO_TEST_(?:BUILD|TOKEN)|drive-trace\.v1|sourceMappingURL|-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----|\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b|\bAKIA[A-Z0-9]{16}\b|\/home\/luca\//iu; + +export const assertProductionEnvironment = (environment) => { + if (Object.entries(environment).some(([key, value]) => + value && (/^(?:PORTFOLIO_TEST_|VITE_)/u.test(key) || (key === 'NODE_ENV' && value !== 'production')))) { + throw new Error('Production export rejects test flags, VITE_* overrides and non-production NODE_ENV.'); + } +}; + +export const assertPublicFiles = (files, expectedPaths) => { + const expected = [...new Set(expectedPaths)].sort(); + if (JSON.stringify([...files.keys()].sort()) !== JSON.stringify(expected)) { + throw new Error('Cloudflare export inventory differs from the verified public closure.'); + } + if (files.size > 20_000) throw new Error('Cloudflare free static file limit exceeded.'); + for (const [path, bytes] of files) { + if (bytes.length > 25 * 1024 * 1024) throw new Error(`Cloudflare asset exceeds 25 MiB: ${path}`); + if (prohibited.test(bytes.toString('utf8'))) throw new Error(`Prohibited production surface in ${path}.`); + } +}; + +// Revalidate all URLs: approved media have stable, unhashed names. One rule avoids +// Cloudflare combining contradictory Cache-Control values from matching rules. +export const renderCloudflareHeaders = (site, hash) => serializeStaticHostHeaders(site, hash); + +const regularBytes = async (path) => { + const stats = await lstat(path); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) throw new Error('Export input must be a regular, non-linked file.'); + return readFile(path); +}; + +const expectedExport = async () => { + await verifyArtifacts(configPath); + const site = parseSiteConfig(JSON.parse(await regularBytes(resolve(root, configPath)))); + if (site.environment !== 'production') throw new Error('Cloudflare export requires production inputs.'); + const authority = parseBuildAuthority(JSON.parse(await regularBytes(resolve(root, 'dist-ssr/artifact-authority.json')))); + const files = new Map(); + for (const path of [...rootFiles, ...authority.artifactContract.files.map((file) => file.path)]) { + files.set(path, await regularBytes(resolve(root, 'dist', path))); + } + const html = files.get('index.html').toString('utf8'); + const jsonLd = html.match(/