@elia97/officina 0.11.0 → 0.13.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.
Files changed (45) hide show
  1. package/README.md +92 -12
  2. package/dist/bin.js +4 -1
  3. package/dist/checks/bundle.js +13 -2
  4. package/dist/checks/doctor.js +13 -10
  5. package/dist/checks/migrate.d.ts +2 -0
  6. package/dist/checks/migrate.js +44 -0
  7. package/dist/checks/placeholders.js +15 -6
  8. package/dist/checks/secrets.d.ts +2 -0
  9. package/dist/checks/secrets.js +46 -0
  10. package/dist/checks/smoke.js +20 -4
  11. package/dist/index.d.ts +1 -0
  12. package/dist/lib/alignment.d.ts +1 -0
  13. package/dist/lib/alignment.js +4 -2
  14. package/dist/lib/bundle-css.d.ts +7 -0
  15. package/dist/lib/bundle-css.js +19 -0
  16. package/dist/lib/check-placeholders.d.ts +3 -1
  17. package/dist/lib/check-placeholders.js +9 -3
  18. package/dist/lib/check-routes.js +5 -3
  19. package/dist/lib/cli.d.ts +1 -0
  20. package/dist/lib/cli.js +1 -0
  21. package/dist/lib/config-gaps.js +1 -6
  22. package/dist/lib/config.d.ts +26 -1
  23. package/dist/lib/config.js +28 -2
  24. package/dist/lib/database-auth.d.ts +5 -0
  25. package/dist/lib/database-auth.js +81 -0
  26. package/dist/lib/env-schema.d.ts +8 -0
  27. package/dist/lib/env-schema.js +37 -0
  28. package/dist/lib/migrate.d.ts +11 -0
  29. package/dist/lib/migrate.js +29 -0
  30. package/dist/lib/routes.js +5 -3
  31. package/dist/lib/secrets.d.ts +10 -0
  32. package/dist/lib/secrets.js +53 -0
  33. package/dist/lib/smoke-on-demand.d.ts +8 -0
  34. package/dist/lib/smoke-on-demand.js +65 -0
  35. package/dist/lib/smoke-production.d.ts +4 -1
  36. package/dist/lib/smoke-production.js +2 -0
  37. package/dist/lib/validate.d.ts +2 -0
  38. package/dist/lib/validate.js +9 -1
  39. package/dist/lib/vercel-build.js +0 -1
  40. package/dist/lib/workflows.d.ts +14 -0
  41. package/dist/lib/workflows.js +44 -3
  42. package/dist/smoke.d.ts +1 -0
  43. package/dist/smoke.js +1 -0
  44. package/package.json +1 -1
  45. package/presets/biome.base.json +2 -1
@@ -1,3 +1,7 @@
1
+ const CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
2
+ const MEDIA_PRELUDE = /(?<=@media\b)[^{;]*/gi;
3
+ const RANGE_COMPARISON = /[<>=]/;
4
+ const STYLE_ELEMENT = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
1
5
  const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
2
6
  export const routeCount = (count) => (count === 1 ? '1 rotta' : `${count} rotte`);
3
7
  function overBudget({ stylesheets, gzip, routes }, maxGzip) {
@@ -31,3 +35,18 @@ export function measureCss(routes, sheets, maxGzip, assets) {
31
35
  failures.push(...measured.filter((group) => group.gzip > maxGzip).map((group) => overBudget(group, maxGzip)));
32
36
  return { groups: measured, failures };
33
37
  }
38
+ export function rangeMediaQueries(css) {
39
+ const preludes = [...css.replace(CSS_COMMENT, ' ').matchAll(MEDIA_PRELUDE)].map(([prelude]) => prelude.trim());
40
+ return [...new Set(preludes.filter((prelude) => RANGE_COMPARISON.test(prelude)))];
41
+ }
42
+ export const inlineStyles = (html) => [...html.matchAll(STYLE_ELEMENT)].map(([, css]) => css).join('\n');
43
+ export function rangeQueryFailures(sources) {
44
+ return sources.flatMap(({ file, css }) => {
45
+ const queries = rangeMediaQueries(css);
46
+ if (queries.length === 0)
47
+ return [];
48
+ return [
49
+ `${file}: media query in sintassi di intervallo, che Safari legge solo dalla 16.4 — ${queries.join(', ')}; si abbassano con \`vite.build.cssTarget\``,
50
+ ];
51
+ });
52
+ }
@@ -1,8 +1,10 @@
1
+ import type { Manifest } from './alignment.ts';
1
2
  import { type Finding, type Hit } from './cli.ts';
2
- import type { OfficinaConfig } from './config.ts';
3
+ import { type OfficinaConfig } from './config.ts';
3
4
  type EnvHit = Omit<Finding, 'path'>;
4
5
  export declare function placeholderSources(config?: OfficinaConfig): string[];
5
6
  export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
7
+ export declare function requiredEnvKeys(config: OfficinaConfig, manifest: Manifest): string[];
6
8
  export declare function sourceFindings(source: string): Hit[];
7
9
  export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
8
10
  export declare const outputFindings: (dir: string) => Finding[];
@@ -2,6 +2,7 @@ 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';
5
+ import { DEPENDENCY_FEATURES, featuresOn } from './config.js';
5
6
  const DICTIONARIES = 'src/i18n/strings';
6
7
  const DEFAULT_SOURCES = ['src/lib/site.ts', 'src/lib/company.ts'];
7
8
  const CONTACT_ENV_KEYS = ['CONTACT_FROM_EMAIL', 'CONTACT_FROM_NAME', 'CONTACT_TO_EMAIL'];
@@ -11,7 +12,7 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
11
12
  const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
12
13
  const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
13
14
  const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
14
- // `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
15
+ // `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una Secret.
15
16
  const SENSITIVE = '[SENSITIVE]';
16
17
  const WITHOUT_VALUE = 'il deploy andrebbe online senza il suo valore';
17
18
  const dictionaries = () => existsSync(DICTIONARIES)
@@ -27,6 +28,11 @@ export function placeholderSources(config = {}) {
27
28
  return [...DEFAULT_SOURCES, ...dictionaries()];
28
29
  }
29
30
  export const contactEnvKeys = (config = {}) => config.placeholders?.contactEnvKeys ?? CONTACT_ENV_KEYS;
31
+ export function requiredEnvKeys(config, manifest) {
32
+ const on = featuresOn(config, manifest);
33
+ const secrets = on.map((name) => DEPENDENCY_FEATURES[name].secret);
34
+ return [...new Set([...contactEnvKeys(config), ...secrets])];
35
+ }
30
36
  function literalFindings(value) {
31
37
  const messages = [];
32
38
  const token = value.match(TOKEN);
@@ -64,7 +70,7 @@ export function envFindings(content, keys) {
64
70
  { line: declaration.n, severity, message: `${key} ${message}` },
65
71
  ];
66
72
  if (value === SENSITIVE)
67
- return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
73
+ return hit('warning', 'è Secret su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
68
74
  if (value === '')
69
75
  return hit('error', `è vuota: ${WITHOUT_VALUE}`);
70
76
  if (TOKEN.test(value) || DOMAIN.test(value))
@@ -84,5 +90,5 @@ export const outputFindings = (dir) => filesUnder(dir)
84
90
  .map((path) => ({
85
91
  path,
86
92
  severity: 'error',
87
- message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Sensitive: la build l'ha incorporato al posto del valore`,
93
+ message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Secret: la build l'ha incorporato al posto del valore`,
88
94
  }));
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { posix } from 'node:path';
3
+ import { DRIZZLE_CONFIG } from './database-auth.js';
3
4
  import { blankFences, blankSpans, linkDestinations } from './markdown.js';
4
5
  const SCANNED = /\.md$/;
5
6
  const ROADMAP = 'docs/ROADMAP.md';
@@ -10,14 +11,14 @@ const IGNORED = [
10
11
  /\.pdf$/,
11
12
  /^docs\/sources\//,
12
13
  ];
13
- const PATH_REFERENCE = /`((?:docs|src|scripts|test|e2e|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
14
+ const PATH_REFERENCE = /`((?:docs|src|scripts|test|e2e|public|drizzle|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
14
15
  const NAME_REFERENCE = /`([A-Za-z0-9._-]+)`/g;
15
16
  // La virgola non delimita: i titoli la contengono.
16
17
  const SECTION_REFERENCE = /`([A-Za-z0-9._/-]+\.md)`\s*§\s*([^.·|)§]{1,80})/g;
17
18
  const LINK_TEXT = /\[[^[\]]*\]\(/g;
18
19
  const ROOT_DOCUMENT = /^[A-Z][A-Z0-9_-]*\.md$/;
19
20
  // I file tracciati alla radice di template/vetrina (git ls-files), Markdown esclusi.
20
- const ROOT_CONFIG = new Set([
21
+ const VETRINA_ROOT = [
21
22
  '.editorconfig',
22
23
  '.env.example',
23
24
  '.fallowrc.jsonc',
@@ -39,7 +40,8 @@ const ROOT_CONFIG = new Set([
39
40
  'tsconfig.json',
40
41
  'vercel.json',
41
42
  'vitest.config.ts',
42
- ]);
43
+ ];
44
+ const ROOT_CONFIG = new Set([...VETRINA_ROOT, DRIZZLE_CONFIG]);
43
45
  const CHANGELOG = /(?:^|\/)CHANGELOG\.md$/;
44
46
  const SITE_CONTENT = /^src\/content\//;
45
47
  const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i;
package/dist/lib/cli.d.ts CHANGED
@@ -19,6 +19,7 @@ export type CliOptions = ReturnType<typeof cliOptions>;
19
19
  export declare const isGenerated: (path: string) => boolean;
20
20
  export declare function missingInput(path: string, purpose: string): boolean;
21
21
  export declare function scopedPaths(options: CliOptions, isScanned: (path: string) => boolean): string[];
22
+ export declare const maskCommand: (value: string) => string;
22
23
  export declare function printFindings(findings: Finding[], format: string): void;
23
24
  export declare function exitCode(findings: Finding[], strict: boolean): number;
24
25
  export declare const readLines: (text: string) => {
package/dist/lib/cli.js CHANGED
@@ -38,6 +38,7 @@ export function scopedPaths(options, isScanned) {
38
38
  }
39
39
  // I workflow command di GitHub vogliono %, CR e LF codificati nel messaggio.
40
40
  const escapeForGitHub = (text) => text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
41
+ export const maskCommand = (value) => `::add-mask::${escapeForGitHub(value)}`;
41
42
  export function printFindings(findings, format) {
42
43
  for (const finding of findings) {
43
44
  const location = finding.line === undefined ? '' : `:${finding.line}`;
@@ -8,13 +8,8 @@ function siteUrlGap(siteUrl) {
8
8
  return `\`siteUrl\` non è un URL: \`${siteUrl}\``;
9
9
  return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
10
10
  }
11
- // TODO: dalla 0.12.0 `links` è un errore di doctor: toglila da NEW_FEATURES.
12
- const NEW_FEATURES = new Set(['links']);
13
11
  // Il silenzio vale `'required'`: un controllo si spegne dichiarandolo, non dimenticandolo.
14
- const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => ({
15
- ...asGap(`\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`),
16
- ...(NEW_FEATURES.has(name) ? { severity: 'warning' } : {}),
17
- }));
12
+ const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => asGap(`\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`));
18
13
  const representativeGaps = (patterns) => patterns.map((label) => `\`routes.representatives\` non ha un percorso per \`${label}\`: smoke e Lighthouse non guardano quella pagina`);
19
14
  const disabledGaps = (labels) => labels.map((label) => `\`routes.disabled\` nomina \`${label}\`, che non è una pagina dinamica prerenderizzata di \`src/pages\`: non spegne niente`);
20
15
  export function configGaps(loaded, { missingRepresentatives = [], unknownDisabled = [] } = {}) {
@@ -1,3 +1,4 @@
1
+ import type { Manifest } from './alignment.ts';
1
2
  import type { Budget } from './bundle-budget.ts';
2
3
  import type { Representatives, VerifiedRoute } from './routes.ts';
3
4
  import type { SmokeCheck } from './smoke-production.ts';
@@ -22,18 +23,27 @@ export interface OfficinaConfig {
22
23
  analytics?: {
23
24
  linkTracking?: string;
24
25
  };
25
- /** Quali controlli il progetto pretende. Una voce non dichiarata vale `'required'`. */
26
+ /**
27
+ * Quali controlli il progetto pretende. Una voce non dichiarata vale `'required'`, tranne `database`
28
+ * e `auth`, che senza dichiarazione seguono le dipendenze di `package.json`.
29
+ */
26
30
  features?: {
27
31
  analytics?: FeatureSetting;
28
32
  roadmap?: FeatureSetting;
29
33
  generators?: GeneratorsSetting;
30
34
  botId?: FeatureSetting;
31
35
  links?: FeatureSetting;
36
+ database?: FeatureSetting;
37
+ auth?: FeatureSetting;
32
38
  };
33
39
  placeholders?: {
34
40
  sources?: readonly string[];
35
41
  contactEnvKeys?: readonly string[];
36
42
  };
43
+ /** La variabile con cui girano le migrazioni; senza dichiarazione, `DATABASE_URL_UNPOOLED`. */
44
+ database?: {
45
+ migrationUrlKey?: string;
46
+ };
37
47
  routes?: {
38
48
  /** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
39
49
  representatives?: Representatives;
@@ -46,6 +56,21 @@ export type FeatureName = (typeof FEATURES)[number];
46
56
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
47
57
  export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
48
58
  export declare const generatorsSetting: (config: OfficinaConfig) => GeneratorsSetting;
59
+ export declare const DEPENDENCY_FEATURES: {
60
+ readonly database: {
61
+ readonly dependency: 'drizzle-orm';
62
+ readonly secret: 'DATABASE_URL';
63
+ };
64
+ readonly auth: {
65
+ readonly dependency: 'better-auth';
66
+ readonly secret: 'BETTER_AUTH_SECRET';
67
+ };
68
+ };
69
+ export type DependencyFeature = keyof typeof DEPENDENCY_FEATURES;
70
+ export declare function isOn(config: OfficinaConfig, manifest: Manifest, name: DependencyFeature): boolean;
71
+ export declare const featuresOn: (config: OfficinaConfig, manifest: Manifest) => DependencyFeature[];
72
+ export declare const featuresOffByChoice: (config: OfficinaConfig, manifest: Manifest) => DependencyFeature[];
73
+ export declare const migrationUrlKey: (config: OfficinaConfig) => string;
49
74
  export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
50
75
  export declare const findConfigFile: (root: string) => string | undefined;
51
76
  export declare function loadConfig(root: string): Promise<OfficinaConfig>;
@@ -9,11 +9,28 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
12
+ import { aFunction, aNumber, anArrayOf, anEnvName, aRecordOf, aShape, aString, aStringOrNull, isRecord, oneOf, } from './validate.js';
13
13
  export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId', 'links'];
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';
17
+ export const DEPENDENCY_FEATURES = {
18
+ database: { dependency: 'drizzle-orm', secret: 'DATABASE_URL' },
19
+ auth: { dependency: 'better-auth', secret: 'BETTER_AUTH_SECRET' },
20
+ };
21
+ const DEPENDENCY_FEATURE_NAMES = Object.keys(DEPENDENCY_FEATURES);
22
+ const declares = (dependencies, name) => isRecord(dependencies) && name in dependencies;
23
+ const dependsOn = ({ dependencies, devDependencies }, name) => {
24
+ const { dependency } = DEPENDENCY_FEATURES[name];
25
+ return declares(dependencies, dependency) || declares(devDependencies, dependency);
26
+ };
27
+ export function isOn(config, manifest, name) {
28
+ const declared = config.features?.[name];
29
+ return declared === undefined ? dependsOn(manifest, name) : declared === 'required';
30
+ }
31
+ export const featuresOn = (config, manifest) => DEPENDENCY_FEATURE_NAMES.filter((name) => isOn(config, manifest, name));
32
+ export const featuresOffByChoice = (config, manifest) => DEPENDENCY_FEATURE_NAMES.filter((name) => config.features?.[name] === false && dependsOn(manifest, name));
33
+ export const migrationUrlKey = (config) => config.database?.migrationUrlKey ?? 'DATABASE_URL_UNPOOLED';
17
34
  const budget = aShape({ label: aString, matches: aFunction, maxGzip: aNumber });
18
35
  const verifiedRoute = aShape({ path: aString, type: aString });
19
36
  const feature = oneOf("'required' oppure false", ['required', false]);
@@ -28,8 +45,17 @@ const SHAPE = aShape({
28
45
  checks: anArrayOf(aFunction),
29
46
  }),
30
47
  analytics: aShape({ linkTracking: aString }),
31
- features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature, links: feature }),
48
+ features: aShape({
49
+ analytics: feature,
50
+ roadmap: feature,
51
+ generators,
52
+ botId: feature,
53
+ links: feature,
54
+ database: feature,
55
+ auth: feature,
56
+ }),
32
57
  placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
58
+ database: aShape({ migrationUrlKey: anEnvName }),
33
59
  routes: aShape({ representatives: aRecordOf(aString), disabled: anArrayOf(aString) }),
34
60
  });
35
61
  const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
@@ -0,0 +1,5 @@
1
+ import type { Manifest, ProjectFiles } from './alignment.ts';
2
+ import { type DependencyFeature } from './config.ts';
3
+ import type { ContractGap } from './contract.ts';
4
+ export declare const DRIZZLE_CONFIG = "drizzle.config.ts";
5
+ export declare function databaseAuthGaps(files: ProjectFiles, manifest: Manifest, on: readonly DependencyFeature[]): ContractGap[];
@@ -0,0 +1,81 @@
1
+ import { DEPENDENCY_FEATURES } from './config.js';
2
+ import { envSchema } from './env-schema.js';
3
+ import { actionInputGaps } from './workflows.js';
4
+ const MANIFEST = 'package.json';
5
+ export const DRIZZLE_CONFIG = 'drizzle.config.ts';
6
+ const ASTRO_CONFIG = 'astro.config.mjs';
7
+ const SCRIPTS = [
8
+ { name: 'db:generate', reason: 'le migrazioni si generano dallo schema con quel nome' },
9
+ { name: 'db:migrate', reason: 'le migrazioni si applicano con quel nome, in CI e al deploy' },
10
+ ];
11
+ const CI_INPUTS = {
12
+ workflow: '.github/workflows/ci.yml',
13
+ action: 'ci',
14
+ inputs: [
15
+ { name: 'migrate', reason: 'il branch di test non si migra prima dei test' },
16
+ {
17
+ name: 'database-url',
18
+ reason: 'i test di integrazione si saltano',
19
+ notFrom: [
20
+ { secret: 'PRODUCTION_DATABASE_URL', reason: 'la CI migrerebbe la produzione con le migrazioni delle PR' },
21
+ {
22
+ secret: 'TEST_DATABASE_URL',
23
+ reason: 'le corse parallele migrerebbero lo stesso branch, invece di un branch Neon per corsa',
24
+ },
25
+ ],
26
+ },
27
+ ],
28
+ };
29
+ const DEPLOY_INPUTS = {
30
+ workflow: '.github/workflows/deploy.yml',
31
+ action: 'deploy',
32
+ inputs: [
33
+ { name: 'migrate', reason: 'la produzione parte con lo schema di prima' },
34
+ {
35
+ name: 'database-url',
36
+ reason: "le migrazioni del deploy non hanno l'indirizzo di produzione",
37
+ notFrom: [
38
+ {
39
+ secret: 'TEST_DATABASE_URL',
40
+ reason: 'le migrazioni girerebbero sul branch di test, e la produzione partirebbe con lo schema di prima',
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ };
46
+ function databaseGaps(files, { scripts = {} }) {
47
+ const gaps = SCRIPTS.filter(({ name }) => scripts[name] === undefined).map(({ name, reason }) => ({
48
+ path: MANIFEST,
49
+ message: `script \`${name}\` assente: ${reason}`,
50
+ }));
51
+ if (files.read(DRIZZLE_CONFIG) === undefined) {
52
+ gaps.push({ path: DRIZZLE_CONFIG, message: 'manca: drizzle-kit legge da lì la sua configurazione' });
53
+ }
54
+ return [...gaps, ...actionInputGaps(files, CI_INPUTS), ...actionInputGaps(files, DEPLOY_INPUTS)];
55
+ }
56
+ function secretGap(key, field) {
57
+ if (field === undefined) {
58
+ return `\`${key}\` non è in \`env.schema\`: va dichiarata con \`context: 'server'\` e \`access: 'secret'\``;
59
+ }
60
+ if (field.context === 'server' && field.access === 'secret')
61
+ return undefined;
62
+ return `\`${key}\` in \`env.schema\` non è \`context: 'server'\` con \`access: 'secret'\`: una variabile pubblica Astro la scrive nel codice della build`;
63
+ }
64
+ function secretGaps(source, keys) {
65
+ if (keys.length === 0)
66
+ return [];
67
+ if (source === undefined)
68
+ return [{ path: ASTRO_CONFIG, message: 'manca: lo schema delle variabili, `env.schema`, sta lì' }];
69
+ const fields = new Map(envSchema(source).map((field) => [field.key, field]));
70
+ return keys.flatMap((key) => {
71
+ const message = secretGap(key, fields.get(key));
72
+ return message === undefined ? [] : [{ path: ASTRO_CONFIG, message }];
73
+ });
74
+ }
75
+ // TODO: dalla 0.14.0 i requisiti del database e dell'autenticazione sono errori di doctor: togli `severity`.
76
+ const asWarning = (gap) => ({ ...gap, severity: 'warning' });
77
+ export function databaseAuthGaps(files, manifest, on) {
78
+ const database = on.includes('database') ? databaseGaps(files, manifest) : [];
79
+ const secrets = secretGaps(files.read(ASTRO_CONFIG), on.map((name) => DEPENDENCY_FEATURES[name].secret));
80
+ return [...database, ...secrets].map(asWarning);
81
+ }
@@ -0,0 +1,8 @@
1
+ export interface EnvField {
2
+ key: string;
3
+ type: string;
4
+ context: string | undefined;
5
+ access: string | undefined;
6
+ constrained: boolean;
7
+ }
8
+ export declare function envSchema(source: string): EnvField[];
@@ -0,0 +1,37 @@
1
+ import { Node, Project, SyntaxKind } from 'ts-morph';
2
+ const ENV_FIELD = /^envField\.(\w+)$/;
3
+ // Le validazioni che la guida di Astro elenca per envField.string, oltre al tipo.
4
+ const CONSTRAINTS = ['max', 'min', 'length', 'url', 'includes', 'startsWith', 'endsWith'];
5
+ const constrained = (options) => Node.isObjectLiteralExpression(options) && CONSTRAINTS.some((name) => options.getProperty(name) !== undefined);
6
+ const keyOf = (property) => {
7
+ const name = property.getNameNode();
8
+ return Node.isStringLiteral(name) ? name.getLiteralValue() : name.getText();
9
+ };
10
+ function literal(options, name) {
11
+ if (!Node.isObjectLiteralExpression(options))
12
+ return undefined;
13
+ const property = options.getProperty(name);
14
+ const value = Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
15
+ return Node.isStringLiteral(value) ? value.getLiteralValue() : undefined;
16
+ }
17
+ // Il sorgente si legge invece di importarlo: astro.config.mjs importa moduli senza estensione, che
18
+ // risolve Vite e che Node rifiuta con ERR_MODULE_NOT_FOUND.
19
+ export function envSchema(source) {
20
+ const file = new Project({ useInMemoryFileSystem: true }).createSourceFile('astro.config.mjs', source);
21
+ return file.getDescendantsOfKind(SyntaxKind.CallExpression).flatMap((call) => {
22
+ const property = call.getParent();
23
+ const type = ENV_FIELD.exec(call.getExpression().getText())?.[1];
24
+ if (type === undefined || !Node.isPropertyAssignment(property))
25
+ return [];
26
+ const [options] = call.getArguments();
27
+ return [
28
+ {
29
+ key: keyOf(property),
30
+ type,
31
+ context: literal(options, 'context'),
32
+ access: literal(options, 'access'),
33
+ constrained: constrained(options),
34
+ },
35
+ ];
36
+ });
37
+ }
@@ -0,0 +1,11 @@
1
+ type Env = Readonly<Record<string, string | undefined>>;
2
+ export type MigrationEnv = {
3
+ env: Record<string, string | undefined>;
4
+ url: string;
5
+ } | {
6
+ error: string;
7
+ };
8
+ export type MigrationTarget = 'test' | 'production';
9
+ export declare function migrationEnv(env: Env, key: string, target: MigrationTarget): MigrationEnv;
10
+ export declare function maskedValues(url: string): string[];
11
+ export {};
@@ -0,0 +1,29 @@
1
+ const SOURCES = {
2
+ test: {
3
+ variable: 'TEST_DATABASE_URL',
4
+ origin: "è l'indirizzo del branch di test, che `actions/ci` riceve con `database-url`",
5
+ },
6
+ production: {
7
+ variable: 'PRODUCTION_DATABASE_URL',
8
+ origin: "è l'indirizzo di produzione, che `actions/deploy` riceve con `database-url` dal segreto `PRODUCTION_DATABASE_URL` dell'environment `production`, dichiarato dal job di deploy",
9
+ },
10
+ };
11
+ export function migrationEnv(env, key, target) {
12
+ const { variable, origin } = SOURCES[target];
13
+ const { [variable]: url, ...rest } = env;
14
+ if (!url)
15
+ return { error: `\`${variable}\` è vuota: ${origin}` };
16
+ return { env: { ...rest, [key]: url }, url };
17
+ }
18
+ const decoded = (text) => {
19
+ try {
20
+ return decodeURIComponent(text);
21
+ }
22
+ catch {
23
+ return text;
24
+ }
25
+ };
26
+ export function maskedValues(url) {
27
+ const password = URL.parse(url)?.password ?? '';
28
+ return [...new Set(password === '' ? [url] : [url, password, decoded(password)])];
29
+ }
@@ -33,10 +33,12 @@ export function pageRouteOf(file, pagesDir) {
33
33
  .replace(/\.astro$/, '')
34
34
  .replace(/\/index$/, '') || '/');
35
35
  }
36
+ const PARAMETER = /(\[[^\]]*\])/;
36
37
  function segmentPattern(segment) {
37
- if (segment.startsWith('['))
38
- return '[^/]+';
39
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ return segment
39
+ .split(PARAMETER)
40
+ .map((part) => (part.startsWith('[') ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
41
+ .join('');
40
42
  }
41
43
  const TRAILING_REST_SEGMENT = /\/\[\.\.\.[^/]*\]$/;
42
44
  // Anche un segmento rest finale fa match col vuoto: `paginate()` emette la prima pagina come
@@ -0,0 +1,10 @@
1
+ import type { Finding } from './cli.ts';
2
+ import type { EnvField } from './env-schema.ts';
3
+ export declare function secretFields(fields: readonly EnvField[]): EnvField[];
4
+ export declare const canary: (key: string) => string;
5
+ export declare const canaryLines: (secrets: readonly EnvField[]) => string[];
6
+ export interface SecretScan {
7
+ findings: Finding[];
8
+ verified: string[];
9
+ }
10
+ export declare function scanSecrets(keys: readonly string[], env: Readonly<Record<string, string | undefined>>, files: readonly string[], read: (path: string) => Buffer): SecretScan;
@@ -0,0 +1,53 @@
1
+ const SCHEMA = 'astro.config.mjs';
2
+ const MIN_SEARCHABLE = 8;
3
+ export function secretFields(fields) {
4
+ const secrets = fields.filter(({ context, access }) => context === 'server' && access === 'secret');
5
+ return secrets.filter(({ key }, index) => secrets.findIndex((field) => field.key === key) === index);
6
+ }
7
+ export const canary = (key) => `officina-${key}-canary`;
8
+ // Astro valida ogni variabile sul tipo e sulle validazioni del suo envField: un canary di testo non
9
+ // è un `number`, un `boolean` né un `enum`, e non rispetta `url`, `startsWith` e simili.
10
+ export const canaryLines = (secrets) => secrets.filter(({ type, constrained }) => type === 'string' && !constrained).map(({ key }) => `${key}=${canary(key)}`);
11
+ // Le due forme in cui il runtime di Astro scrive un valore: escapeHTML nel markup, stringifyForScript
12
+ // negli script (astro/dist/runtime/server/escape.js).
13
+ const written = (value) => [
14
+ value,
15
+ value
16
+ .replaceAll('&', '&amp;')
17
+ .replaceAll('<', '&lt;')
18
+ .replaceAll('>', '&gt;')
19
+ .replaceAll("'", '&#39;')
20
+ .replaceAll('"', '&quot;'),
21
+ JSON.stringify(value).slice(1, -1).replaceAll('<', '\\u003c'),
22
+ ];
23
+ function target(key, raw) {
24
+ const value = raw?.trim() ?? '';
25
+ const own = value.length >= MIN_SEARCHABLE && value !== canary(key) ? written(value) : [];
26
+ return { key, value, needles: [...new Set([canary(key), ...own])] };
27
+ }
28
+ const searchable = ({ value }) => value.length >= MIN_SEARCHABLE;
29
+ const leaksIn = (path, content, targets) => targets
30
+ .filter(({ needles }) => needles.some((needle) => content.includes(needle)))
31
+ .map(({ key }) => ({ path, key, viaCanary: content.includes(canary(key)) }));
32
+ const leakFinding = ({ path, key, viaCanary }) => ({
33
+ path,
34
+ severity: 'error',
35
+ message: `contiene ${viaCanary ? 'il canary' : 'il valore'} di \`${key}\`, una chiave server e secret di env.schema`,
36
+ });
37
+ const unverifiable = ({ key, value }) => ({
38
+ path: SCHEMA,
39
+ severity: 'warning',
40
+ message: value === ''
41
+ ? `\`${key}\` non si verifica: nell'ambiente non ci sono né il suo valore né il canary`
42
+ : `\`${key}\` non si verifica: il suo valore ha meno di ${MIN_SEARCHABLE} caratteri, e cercarlo darebbe falsi positivi`,
43
+ });
44
+ export function scanSecrets(keys, env, files, read) {
45
+ const targets = keys.map((key) => target(key, env[key]));
46
+ const leaks = files.flatMap((path) => leaksIn(path, read(path), targets));
47
+ const leaked = new Set(leaks.map(({ key }) => key));
48
+ const clean = targets.filter(({ key }) => !leaked.has(key));
49
+ return {
50
+ findings: [...leaks.map(leakFinding), ...clean.filter((t) => !searchable(t)).map(unverifiable)],
51
+ verified: clean.filter(searchable).map(({ key }) => key),
52
+ };
53
+ }
@@ -0,0 +1,8 @@
1
+ import { type Expectations, type Representatives } from './routes.ts';
2
+ import type { CheckResult, SmokeContext } from './smoke-production.ts';
3
+ export type OnDemandPage = {
4
+ label: string;
5
+ path?: string;
6
+ };
7
+ export declare function onDemandPages(expected: Expectations, pagesDir: string, representatives?: Representatives): OnDemandPage[];
8
+ export declare function checkOnDemandPages(context: SmokeContext): Promise<CheckResult[]>;
@@ -0,0 +1,65 @@
1
+ import { pageRouteOf } from './routes.js';
2
+ export function onDemandPages(expected, pagesDir, representatives = {}) {
3
+ return expected.ssr
4
+ .map((file) => pageRouteOf(file, pagesDir))
5
+ .sort()
6
+ .map((label) => {
7
+ const path = label.includes('[') ? representatives[label] : label;
8
+ return path === undefined ? { label } : { label, path };
9
+ });
10
+ }
11
+ const META = /<meta\b[^>]*>/gi;
12
+ const HTTP_EQUIV = /(?<![-\w])http-equiv\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
13
+ const CONTENT = /(?<![-\w])content\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
14
+ const SCRIPT_SRC = /(?:^|;)\s*script-src(?:\s|;|$)/i;
15
+ const attributeValue = (tag, attribute) => {
16
+ const match = attribute.exec(tag);
17
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? '';
18
+ };
19
+ const hasCspMeta = (html) => [...html.matchAll(META)].some(([tag]) => attributeValue(tag, HTTP_EQUIV).toLowerCase() === 'content-security-policy' &&
20
+ SCRIPT_SRC.test(attributeValue(tag, CONTENT)));
21
+ // TODO: dalla 0.14.0 un'anomalia di una pagina resa a richiesta fa fallire lo smoke: qui `warn` diventa `fail`.
22
+ const warn = (check, detail) => ({ check, status: 'warn', detail });
23
+ function redirect(check, response, url) {
24
+ const location = response.headers.get('location');
25
+ if (location === null)
26
+ return warn(check, `risponde ${response.status} senza location`);
27
+ const target = new URL(location, url);
28
+ const { origin } = new URL(url);
29
+ if (target.origin !== origin)
30
+ return warn(check, `rimanda a ${target.href}, fuori da ${origin}`);
31
+ return { check, status: 'pass' };
32
+ }
33
+ async function visit({ get, baseUrl }, path) {
34
+ const check = `GET ${path} (a richiesta)`;
35
+ const url = `${baseUrl}${path}`;
36
+ try {
37
+ const response = await get(url);
38
+ if (response.status >= 300 && response.status < 400)
39
+ return redirect(check, response, url);
40
+ if (response.status !== 200) {
41
+ return warn(check, `atteso 200 o un redirect sulla stessa origine, ricevuto ${response.status}`);
42
+ }
43
+ const contentType = response.headers.get('content-type') ?? '';
44
+ if (!contentType.includes('text/html'))
45
+ return warn(check, `atteso un content-type text/html, ricevuto "${contentType}"`);
46
+ if (!hasCspMeta((await response.text?.()) ?? ''))
47
+ return warn(check, 'manca il meta Content-Security-Policy con script-src');
48
+ return { check, status: 'pass' };
49
+ }
50
+ catch (error) {
51
+ return warn(check, error instanceof Error ? error.message : String(error));
52
+ }
53
+ }
54
+ export async function checkOnDemandPages(context) {
55
+ const results = [];
56
+ for (const { label, path } of context.onDemand ?? []) {
57
+ if (path !== undefined)
58
+ results.push(await visit(context, path));
59
+ else {
60
+ const detail = 'pagina dinamica senza rappresentante in `routes.representatives`';
61
+ results.push({ check: `pagina ${label} (a richiesta)`, status: 'skip', detail });
62
+ }
63
+ }
64
+ return results;
65
+ }
@@ -1,10 +1,12 @@
1
1
  import type { VerifiedRoute } from './routes.ts';
2
+ import { type OnDemandPage } from './smoke-on-demand.ts';
2
3
  export interface SmokeResponse {
3
4
  ok: boolean;
4
5
  status: number;
5
6
  headers: {
6
7
  get: (name: string) => string | null;
7
8
  };
9
+ text?: () => Promise<string>;
8
10
  }
9
11
  export type Fetcher = (url: string) => Promise<SmokeResponse>;
10
12
  export interface SmokeContext {
@@ -13,11 +15,12 @@ export interface SmokeContext {
13
15
  siteUrl: string;
14
16
  securityHeaders: Record<string, string | null>;
15
17
  botIdRequired: boolean;
18
+ onDemand?: readonly OnDemandPage[];
16
19
  }
17
20
  export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
18
21
  export interface CheckResult {
19
22
  check: string;
20
- status: 'pass' | 'fail' | 'skip';
23
+ status: 'pass' | 'fail' | 'skip' | 'warn';
21
24
  detail?: string;
22
25
  }
23
26
  export declare const SECURITY_HEADERS: Record<string, string | null>;
@@ -1,3 +1,4 @@
1
+ import { checkOnDemandPages } from './smoke-on-demand.js';
1
2
  const pass = (check) => ({ check, status: 'pass' });
2
3
  const fail = (check, detail) => ({ check, status: 'fail', detail });
3
4
  const skip = (check, detail) => ({ check, status: 'skip', detail });
@@ -144,6 +145,7 @@ export async function checkTrailingSlash({ get, baseUrl }, pages) {
144
145
  }
145
146
  export const DEFAULT_CHECKS = [
146
147
  checkPages,
148
+ checkOnDemandPages,
147
149
  checkSecurityHeaders,
148
150
  checkBotIdChallenge,
149
151
  checkCanonicalHost,