@elia97/officina 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,8 @@ Una rotta renderizzata a richiesta non emette HTML. Con l'adapter Vercel, `check
50
50
 
51
51
  Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzione o su un'anteprima: al posto del server del progetto visita quell'indirizzo, ed è l'unico modo di avere numeri di produzione. `--local` serve i file statici della build, quindi su un sito renderizzato a richiesta si ferma e lo dice, invece di misurare dei 404. Un'anteprima Vercel con la Deployment Protection risponde con la pagina di accesso, e il check non la può misurare: serve la produzione o un'anteprima non protetta. La porta del server locale segue `LH_PORT` sia con `--listen` sia con `--port`.
52
52
 
53
+ Lo smoke legge gli header di sicurezza sulla pagina a cui arriva la radice. Se `/` è un redirect sulla stessa origine — il 302 di lingua che Astro emette con `i18n.routing.redirectToDefaultLocale`, per esempio — li cerca sulla destinazione, perché il redirect non porta la CSP delle pagine renderizzate, e il nome del controllo dice dove li ha letti: `header content-security-policy su /it`. Un redirect verso un'altra origine, o senza `location`, è un fallimento. Anche l'attesa che l'alias di produzione punti al deployment nuovo conta un 3xx come una risposta.
54
+
53
55
  Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
54
56
 
55
57
  ```ts
@@ -66,7 +68,7 @@ export default defineConfig({
66
68
  icons: { background: SITE.themeColor.light },
67
69
  bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
68
70
  smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
69
- features: { analytics: 'required', roadmap: false },
71
+ features: { analytics: 'required', roadmap: false, botId: 'required' },
70
72
  routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
71
73
  })
72
74
  ```
@@ -79,14 +81,16 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
79
81
  | `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
80
82
  | `@elia97/officina/icons` | `ICON_SPECS` |
81
83
 
82
- `siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi non ha BotID la ricompone senza `checkBotIdChallenge`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`.
84
+ `siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`.
83
85
 
84
- `features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
86
+ `features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Per BotID è `features.botId`: con `false` lo smoke salta la challenge e lo scrive nell'uscita, anche dentro una lista di `smoke.checks` che la contiene. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
85
87
 
86
- `features.generators` ha un valore in più delle altre due, perché non tutti i siti in produzione nascono dallo scaffold: `'required'` è il comportamento di sempre; `false` spegne i generatori del pacchetto, e `doctor` smette di chiedere i punti di aggancio e gli ancoraggi invece di pretendere file che nessun codice di quel sito userebbe; `'project'` fa lo stesso ma lascia vivi i generatori che il progetto scrive in `officina.generators.mjs`, che a quel punto `doctor` pretende. Con `false` o `'project'`, `officina gen section|page|component|collection` si ferma dicendolo, mentre gli script `gen` e `gen:*` restano attesi in `package.json`: meglio un comando che spiega di uno script che manca. `gen icons` non c'entra con i generatori di codice e non cambia.
88
+ `features.generators` ha un valore in più delle altre, perché non tutti i siti in produzione nascono dallo scaffold: `'required'` è il comportamento di sempre; `false` spegne i generatori del pacchetto, e `doctor` smette di chiedere i punti di aggancio e gli ancoraggi invece di pretendere file che nessun codice di quel sito userebbe; `'project'` fa lo stesso ma lascia vivi i generatori che il progetto scrive in `officina.generators.mjs`, che a quel punto `doctor` pretende. Con `false` o `'project'`, `officina gen section|page|component|collection` si ferma dicendolo, mentre gli script `gen` e `gen:*` restano attesi in `package.json`: meglio un comando che spiega di uno script che manca. `gen icons` non c'entra con i generatori di codice e non cambia.
87
89
 
88
90
  `placeholders` dice a `check placeholders` cosa guardare: `sources` sono i file da scandire, `contactEnvKeys` le variabili che `--env` pretende. Una lista dichiarata **sostituisce** quella del pacchetto, dizionari compresi: senza dichiarazione i sorgenti sono `src/lib/site.ts`, `src/lib/company.ts` e i dizionari di `src/i18n/strings`, quando la cartella esiste. Serve ai progetti che non nascono dallo scaffold: se partita IVA, recapiti e destinatario dei form stanno nel contenuto invece che in un modulo, i sorgenti del template non sono i suoi, e crearli per far passare il gate terrebbe gli stessi dati in due sedi. Un sorgente elencato che non esiste è un ritrovamento con il suo percorso, non un errore che ferma il comando.
89
91
 
92
+ Nel deploy `check placeholders` gira due volte sui file di Vercel. Con `--env .vercel/.env.production.local`, dopo `vercel pull`, distingue una chiave che su Vercel non esiste, una vuota e una che porta un segnaposto del template, tutte e tre errori, da una variabile **Sensitive**: `vercel pull` non ne scarica il valore e scrive al suo posto `[SENSITIVE]`, quindi il check lo dice con un avviso, che non ferma il deploy, invece di darla per verificata. Il segnaposto però diventa un problema se la build legge la variabile, per esempio una variabile di `astro:env` con `access: 'public'`, che Astro incorpora nel codice: con `--output .vercel/output`, dopo `vercel build`, il check cerca `[SENSITIVE]` nei file della build e ferma il deploy nominandoli. Una variabile che la build legge va tenuta Encrypted su Vercel, o letta a runtime.
93
+
90
94
  Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`. Due file `officina.config.*` insieme sono un errore, come un file senza default export; il resto lo valida `loadConfig` a runtime, nominando il percorso della voce sbagliata — `bundle.cssMaxGzip: atteso un numero, ricevuto una stringa` — e una voce sconosciuta, perché `sitUrl` al posto di `siteUrl` non deve passare in silenzio.
91
95
 
92
96
  `sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. Un ingresso che manca — il favicon, `.lighthouserc.json`, `src/pages`, la build in `dist/client` — è sempre una riga che nomina il file e dice a cosa serve, mai uno stack trace. `@biomejs/biome` invece è una peer dependency con l'intervallo che il preset regge, e `doctor` confronta la versione del progetto con quello.
@@ -116,7 +120,7 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
116
120
  |---|---|---|
117
121
  | `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
118
122
  | `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
119
- | `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
123
+ | `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, controllo della build, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
120
124
  | `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
121
125
 
122
126
  ```yaml
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import process from 'node:process';
4
4
  import { parseArgs } from 'node:util';
5
- import { contactEnvKeys, envFindings, placeholderSources, sourceFindings } from '../lib/check-placeholders.js';
5
+ import { contactEnvKeys, envFindings, outputFindings, placeholderSources, sourceFindings, } from '../lib/check-placeholders.js';
6
6
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
7
7
  import { loadConfig } from '../lib/config.js';
8
8
  const MISSING_SOURCE = 'sorgente assente: `placeholders.sources` in officina.config.ts dice quali file guardare';
@@ -16,30 +16,47 @@ function sourceScan(paths) {
16
16
  function envScan(path, keys) {
17
17
  if (!existsSync(path))
18
18
  return [{ path, message: "file dell'ambiente assente: lo scrive vercel pull", severity: 'error' }];
19
- return envFindings(readFileSync(path, 'utf8'), keys).map((hit) => ({ path, ...hit, severity: 'error' }));
19
+ return envFindings(readFileSync(path, 'utf8'), keys).map((hit) => ({ path, ...hit }));
20
20
  }
21
- function scan(env, config) {
21
+ function outputScan(dir) {
22
+ if (!existsSync(dir))
23
+ return [{ path: dir, message: 'cartella della build assente: la scrive vercel build', severity: 'error' }];
24
+ return outputFindings(dir);
25
+ }
26
+ function scan(env, output, config) {
27
+ if (output !== undefined) {
28
+ return {
29
+ scope: `build in ${output}`,
30
+ findings: outputScan(output),
31
+ clean: 'Nessuna variabile Sensitive entrata nella build.',
32
+ advice: 'Una variabile che la build legge non può essere Sensitive su Vercel: portala a Encrypted, o leggila a runtime',
33
+ };
34
+ }
22
35
  if (env !== undefined) {
23
36
  return {
24
37
  scope: `ambiente in ${env}`,
25
38
  findings: envScan(env, contactEnvKeys(config)),
26
- fix: 'docs/guides/deploy-ops.md § Checklist per il go-live',
39
+ clean: 'Nessun segnaposto del template.',
40
+ advice: 'Da sostituire prima del deploy: docs/guides/deploy-ops.md § Checklist per il go-live',
27
41
  };
28
42
  }
29
43
  const paths = placeholderSources(config);
30
- return { scope: `${paths.length} sorgenti`, findings: sourceScan(paths), fix: 'README.md § Cosa tocca il rebranding' };
44
+ return {
45
+ scope: `${paths.length} sorgenti`,
46
+ findings: sourceScan(paths),
47
+ clean: 'Nessun segnaposto del template.',
48
+ advice: 'Da sostituire prima del deploy: README.md § Cosa tocca il rebranding',
49
+ };
31
50
  }
32
51
  export async function main(args = process.argv.slice(2)) {
33
- const { env } = parseArgs({ args, options: { env: { type: 'string' } } }).values;
34
- const { scope, findings, fix } = scan(env, await loadConfig(process.cwd()));
52
+ const { env, output } = parseArgs({ args, options: { env: { type: 'string' }, output: { type: 'string' } } }).values;
53
+ const { scope, findings, clean, advice } = scan(env, output, await loadConfig(process.cwd()));
35
54
  console.log(`\ncheck:placeholders — ${scope}\n`);
36
55
  printFindings(findings, cliOptions([]).format);
37
- if (findings.length === 0) {
38
- console.log(' Nessun segnaposto del template.\n');
39
- }
40
- else {
41
- console.log(`\n Da sostituire prima del deploy: ${fix}.\n`);
42
- }
56
+ if (findings.length === 0)
57
+ console.log(` ${clean}\n`);
58
+ else if (findings.some(({ severity }) => severity === 'error'))
59
+ console.log(`\n ${advice}.\n`);
43
60
  return exitCode(findings, false);
44
61
  }
45
62
  if (import.meta.main)
@@ -2,7 +2,7 @@
2
2
  // L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
3
3
  import process from 'node:process';
4
4
  import { missingInput } from '../lib/cli.js';
5
- import { loadConfig } from '../lib/config.js';
5
+ import { isRequired, loadConfig } from '../lib/config.js';
6
6
  import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
7
7
  import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
8
8
  const PAGES_DIR = 'src/pages';
@@ -23,7 +23,8 @@ function printFailures(failures) {
23
23
  console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
24
24
  }
25
25
  export async function main(args = []) {
26
- const { siteUrl, smoke = {}, routes = {} } = await loadConfig(process.cwd());
26
+ const config = await loadConfig(process.cwd());
27
+ const { siteUrl, smoke = {}, routes = {} } = config;
27
28
  const [url] = args;
28
29
  if (siteUrl === undefined) {
29
30
  console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
@@ -40,6 +41,7 @@ export async function main(args = []) {
40
41
  baseUrl,
41
42
  siteUrl,
42
43
  securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
44
+ botIdRequired: isRequired(config, 'botId'),
43
45
  };
44
46
  if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
45
47
  return 1;
@@ -1,8 +1,9 @@
1
1
  import { type Finding, type Hit } from './cli.ts';
2
2
  import type { OfficinaConfig } from './config.ts';
3
- type EnvHit = Omit<Finding, 'path' | 'severity'>;
3
+ type EnvHit = Omit<Finding, 'path'>;
4
4
  export declare function placeholderSources(config?: OfficinaConfig): string[];
5
5
  export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
6
6
  export declare function sourceFindings(source: string): Hit[];
7
7
  export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
8
+ export declare const outputFindings: (dir: string) => Finding[];
8
9
  export {};
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
4
  import { readLines } from './cli.js';
@@ -11,6 +11,8 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
11
11
  const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
12
12
  const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
13
13
  const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
14
+ // `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@59.22.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
15
+ const SENSITIVE = '[SENSITIVE]';
14
16
  const dictionaries = () => existsSync(DICTIONARIES)
15
17
  ? readdirSync(DICTIONARIES)
16
18
  .filter((name) => name.endsWith('.ts'))
@@ -51,13 +53,35 @@ export function envFindings(content, keys) {
51
53
  const env = parseEnv(content);
52
54
  const lines = readLines(content);
53
55
  return keys.flatMap((key) => {
54
- const value = env[key]?.trim() ?? '';
55
- const declaration = lines.find(({ text }) => new RegExp(`^\\s*${key}\\s*=`).test(text));
56
- const at = declaration ? { line: declaration.n } : {};
56
+ const value = env[key]?.trim();
57
+ const declaration = lines.find(({ text }) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(text));
58
+ if (value === undefined || declaration === undefined) {
59
+ const message = `${key} non c'è fra le variabili di produzione su Vercel: vale il default di astro.config.mjs`;
60
+ return [{ severity: 'error', message }];
61
+ }
62
+ const hit = (severity, message) => [
63
+ { line: declaration.n, severity, message: `${key} ${message}` },
64
+ ];
65
+ if (value === SENSITIVE)
66
+ return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
57
67
  if (value === '')
58
- return [{ ...at, message: `${key} non è impostata: vale il default di astro.config.mjs` }];
68
+ return hit('error', 'è vuota: vale il default di astro.config.mjs');
59
69
  if (TOKEN.test(value) || DOMAIN.test(value))
60
- return [{ ...at, message: `${key} porta un segnaposto del template` }];
70
+ return hit('error', 'porta un segnaposto del template');
61
71
  return [];
62
72
  });
63
73
  }
74
+ const filesUnder = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
75
+ const path = join(dir, entry.name);
76
+ if (entry.isDirectory())
77
+ return filesUnder(path);
78
+ return entry.isFile() ? [path] : [];
79
+ });
80
+ export const outputFindings = (dir) => filesUnder(dir)
81
+ .filter((path) => readFileSync(path).includes(SENSITIVE))
82
+ .sort()
83
+ .map((path) => ({
84
+ path,
85
+ severity: 'error',
86
+ message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Sensitive: la build l'ha incorporato al posto del valore`,
87
+ }));
@@ -27,6 +27,7 @@ export interface OfficinaConfig {
27
27
  analytics?: FeatureSetting;
28
28
  roadmap?: FeatureSetting;
29
29
  generators?: GeneratorsSetting;
30
+ botId?: FeatureSetting;
30
31
  };
31
32
  placeholders?: {
32
33
  sources?: readonly string[];
@@ -37,7 +38,7 @@ export interface OfficinaConfig {
37
38
  representatives?: Representatives;
38
39
  };
39
40
  }
40
- export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators'];
41
+ export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId'];
41
42
  export type FeatureName = (typeof FEATURES)[number];
42
43
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
43
44
  export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
13
- export const FEATURES = ['analytics', 'roadmap', 'generators'];
13
+ export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId'];
14
14
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
15
15
  export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
16
16
  export const generatorsSetting = (config) => config.features?.generators ?? 'required';
@@ -28,7 +28,7 @@ const SHAPE = aShape({
28
28
  checks: anArrayOf(aFunction),
29
29
  }),
30
30
  analytics: aShape({ linkTracking: aString }),
31
- features: aShape({ analytics: feature, roadmap: feature, generators }),
31
+ features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature }),
32
32
  placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
33
33
  routes: aShape({ representatives: aRecordOf(aString) }),
34
34
  });
@@ -12,6 +12,7 @@ export interface SmokeContext {
12
12
  baseUrl: string;
13
13
  siteUrl: string;
14
14
  securityHeaders: Record<string, string | null>;
15
+ botIdRequired: boolean;
15
16
  }
16
17
  export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
17
18
  export interface CheckResult {
@@ -26,7 +27,7 @@ export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d
26
27
  export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
27
28
  export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
28
29
  export declare function checkSecurityHeaders({ get, baseUrl, securityHeaders }: SmokeContext): Promise<CheckResult[]>;
29
- export declare function checkBotIdChallenge({ get, baseUrl }: SmokeContext): Promise<CheckResult[]>;
30
+ export declare function checkBotIdChallenge({ get, baseUrl, botIdRequired }: SmokeContext): Promise<CheckResult[]>;
30
31
  /** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
31
32
  export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
32
33
  export declare function checkTrailingSlash({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
@@ -13,11 +13,14 @@ export const SECURITY_HEADERS = {
13
13
  };
14
14
  /** Deve corrispondere al `source` del rewrite in vercel.json. */
15
15
  export const BOTID_CHALLENGE = '/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js';
16
+ const isRedirect = (status) => status >= 300 && status < 400;
17
+ const MAX_REDIRECTS = 5;
16
18
  /** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
17
19
  export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
18
20
  for (let attempt = 1; attempt <= attempts; attempt++) {
19
21
  try {
20
- if ((await get(baseUrl)).ok)
22
+ const response = await get(baseUrl);
23
+ if (response.ok || isRedirect(response.status))
21
24
  return;
22
25
  }
23
26
  catch { }
@@ -25,6 +28,25 @@ export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
25
28
  await sleep(attempt * 2000);
26
29
  }
27
30
  }
31
+ /** La radice può essere un redirect, come il 302 di lingua di Astro, che non porta la CSP delle pagine renderizzate. */
32
+ async function landingPage(get, baseUrl) {
33
+ const { origin } = new URL(baseUrl);
34
+ let url = baseUrl;
35
+ for (let hops = 0;; hops++) {
36
+ const response = await get(url);
37
+ if (!isRedirect(response.status))
38
+ return { url, response };
39
+ if (hops === MAX_REDIRECTS)
40
+ throw new Error(`più di ${MAX_REDIRECTS} redirect a partire da ${baseUrl}`);
41
+ const location = response.headers.get('location');
42
+ if (location === null)
43
+ throw new Error(`${url} risponde ${response.status} senza location`);
44
+ const next = new URL(location, url);
45
+ if (next.origin !== origin)
46
+ throw new Error(`${url} rimanda a ${next.href}, fuori da ${origin}`);
47
+ url = next.href;
48
+ }
49
+ }
28
50
  export async function checkPages({ get, baseUrl }, pages) {
29
51
  const results = [];
30
52
  for (const { path, type } of pages) {
@@ -46,15 +68,17 @@ export async function checkPages({ get, baseUrl }, pages) {
46
68
  return results;
47
69
  }
48
70
  export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
49
- let response;
71
+ let landing;
50
72
  try {
51
- response = await get(baseUrl);
73
+ landing = await landingPage(get, baseUrl);
52
74
  }
53
75
  catch (error) {
54
76
  return [fail('header di sicurezza', messageOf(error))];
55
77
  }
78
+ const { url, response } = landing;
79
+ const where = url === baseUrl ? '' : ` su ${new URL(url).pathname}`;
56
80
  const results = Object.entries(securityHeaders).map(([header, expected]) => {
57
- const check = `header ${header}`;
81
+ const check = `header ${header}${where}`;
58
82
  const value = response.headers.get(header);
59
83
  if (value === null)
60
84
  return fail(check, 'assente');
@@ -65,11 +89,13 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
65
89
  // Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
66
90
  const check = 'nessun x-robots-tag sull’host di produzione';
67
91
  const robots = response.headers.get('x-robots-tag');
68
- results.push(robots === null ? pass(check) : fail(check, `presente su ${baseUrl}: "${robots}"`));
92
+ results.push(robots === null ? pass(check) : fail(check, `presente su ${url}: "${robots}"`));
69
93
  return results;
70
94
  }
71
- export async function checkBotIdChallenge({ get, baseUrl }) {
95
+ export async function checkBotIdChallenge({ get, baseUrl, botIdRequired }) {
72
96
  const check = 'challenge di BotID servita dalla stessa origine';
97
+ if (!botIdRequired)
98
+ return [skip(check, 'spento da `features.botId: false`')];
73
99
  try {
74
100
  const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
75
101
  if (response.status !== 200)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità, generatori e verifiche di build e di produzione.",
5
5
  "type": "module",
6
6
  "license": "MIT",