@starci/hfs 4.0.10 → 4.1.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/CHANGELOG.md +1 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +221 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// api app into a temp directory through the injected `emit` (packages/hfs/emit) and compares hashes; an emit that cannot run is a finding.
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
-
import os from 'node:os';
|
|
12
11
|
import path from 'node:path';
|
|
13
12
|
import { found, readText } from './read.mjs';
|
|
14
13
|
import { safeRemove } from '../../api/fs/safe-remove.mjs';
|
|
14
|
+
import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
|
|
15
15
|
|
|
16
16
|
export const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
|
|
17
17
|
const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
|
|
@@ -19,7 +19,7 @@ const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
|
|
|
19
19
|
|
|
20
20
|
/** sha256 of a file's text with line endings folded, or null when it cannot be read. */
|
|
21
21
|
function contractHash(file) {
|
|
22
|
-
try { return createHash('sha256').update(fs.readFileSync(file, 'utf8').
|
|
22
|
+
try { return createHash('sha256').update(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n')).digest('hex'); } catch { return null; }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** The findings of R23 for the be side at `repoRoot` (a side of another profile has none). */
|
|
@@ -42,6 +42,36 @@ export function contractFindings({ repoRoot, files, repo, resolver }) {
|
|
|
42
42
|
/** The first lines of an error, enough to act on. */
|
|
43
43
|
const errorText = (error) => String(error?.message ?? error).split(/\r?\n/).filter(Boolean).slice(0, 4).join(' | ');
|
|
44
44
|
|
|
45
|
+
function emitFailure(app, artifacts, error, apps, findings) {
|
|
46
|
+
for (const { artifact } of artifacts) apps.push({ app: app.name, artifact, status: 'emit-failed' });
|
|
47
|
+
const snapshot = `contracts/${app.name}/${SNAPSHOT_OF.graphql}`;
|
|
48
|
+
findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `contracts/${app.name} cannot be verified: \`starci app emit\` failed for ${app.name}: ${errorText(error)}`, { app: app.name, drift: 'emit-failed' }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function artifactFindings({ repoRoot, tracked, scratch, app, artifact, what, emitted, apps, findings }) {
|
|
52
|
+
const snapshot = `contracts/${app.name}/${artifact}`;
|
|
53
|
+
const committed = tracked.has(snapshot) ? contractHash(path.join(repoRoot, snapshot)) : null;
|
|
54
|
+
if (!emitted.written.includes(snapshot)) {
|
|
55
|
+
if (committed === null) {
|
|
56
|
+
if (artifact === SNAPSHOT_OF.graphql) apps.push({ app: app.name, artifact, status: 'none' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
apps.push({ app: app.name, artifact, status: 'left-behind' });
|
|
60
|
+
findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} is committed but ${app.name} ${what}; delete it`, { app: app.name, drift: 'left-behind' }));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const fresh = contractHash(path.join(scratch, snapshot));
|
|
64
|
+
if (committed === null) {
|
|
65
|
+
// the uncommitted snapshot itself is reported by contractFindings above; here it only counts as not judged
|
|
66
|
+
apps.push({ app: app.name, artifact, status: 'not-committed' });
|
|
67
|
+
} else if (committed === fresh) {
|
|
68
|
+
apps.push({ app: app.name, artifact, status: 'fresh' });
|
|
69
|
+
} else {
|
|
70
|
+
apps.push({ app: app.name, artifact, status: 'stale' });
|
|
71
|
+
findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} (${committed.slice(0, 12)}) differs from what ${app.name} emits now (${String(fresh).slice(0, 12)}); run \`npm run contract:emit\` and commit the result`, { app: app.name, drift: 'stale' }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
/**
|
|
46
76
|
* R23, full pass: each committed `contracts/<app>/schema.graphql` and `contracts/<app>/openapi.json` equals what `emit` writes for
|
|
47
77
|
* the app now. `emit({ repoRoot, declaration, outDir })` is `emitContracts` of packages/hfs/emit and answers `{ written, skipped }`;
|
|
@@ -56,7 +86,7 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
|
|
|
56
86
|
const apps = [];
|
|
57
87
|
if (repo.profile !== 'be') return { findings, apps };
|
|
58
88
|
const tracked = new Set(files);
|
|
59
|
-
const scratch =
|
|
89
|
+
const scratch = makeTempDir('hfs-contracts-');
|
|
60
90
|
try {
|
|
61
91
|
for (const app of repo.apps.filter((a) => a.kind === 'api')) {
|
|
62
92
|
const artifacts = [{ artifact: SNAPSHOT_OF.graphql, what: 'serves no GraphQL any more' }, { artifact: SNAPSHOT_OF.json, what: 'declares no operations any more' }];
|
|
@@ -64,33 +94,11 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
|
|
|
64
94
|
try {
|
|
65
95
|
emitted = emit({ repoRoot, declaration: { apps: [app] }, outDir: scratch });
|
|
66
96
|
} catch (error) {
|
|
67
|
-
|
|
68
|
-
const snapshot = `contracts/${app.name}/${SNAPSHOT_OF.graphql}`;
|
|
69
|
-
findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `contracts/${app.name} cannot be verified: \`starci app emit\` failed for ${app.name}: ${errorText(error)}`, { app: app.name, drift: 'emit-failed' }));
|
|
97
|
+
emitFailure(app, artifacts, error, apps, findings);
|
|
70
98
|
continue;
|
|
71
99
|
}
|
|
72
100
|
for (const { artifact, what } of artifacts) {
|
|
73
|
-
|
|
74
|
-
const committed = tracked.has(snapshot) ? contractHash(path.join(repoRoot, snapshot)) : null;
|
|
75
|
-
if (!emitted.written.includes(snapshot)) {
|
|
76
|
-
if (committed === null) {
|
|
77
|
-
if (artifact === SNAPSHOT_OF.graphql) apps.push({ app: app.name, artifact, status: 'none' });
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
apps.push({ app: app.name, artifact, status: 'left-behind' });
|
|
81
|
-
findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} is committed but ${app.name} ${what}; delete it`, { app: app.name, drift: 'left-behind' }));
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const fresh = contractHash(path.join(scratch, snapshot));
|
|
85
|
-
if (committed === null) {
|
|
86
|
-
// the uncommitted snapshot itself is reported by contractFindings above; here it only counts as not judged
|
|
87
|
-
apps.push({ app: app.name, artifact, status: 'not-committed' });
|
|
88
|
-
} else if (committed === fresh) {
|
|
89
|
-
apps.push({ app: app.name, artifact, status: 'fresh' });
|
|
90
|
-
} else {
|
|
91
|
-
apps.push({ app: app.name, artifact, status: 'stale' });
|
|
92
|
-
findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} (${committed.slice(0, 12)}) differs from what ${app.name} emits now (${String(fresh).slice(0, 12)}); run \`npm run contract:emit\` and commit the result`, { app: app.name, drift: 'stale' }));
|
|
93
|
-
}
|
|
101
|
+
artifactFindings({ repoRoot, tracked, scratch, app, artifact, what, emitted, apps, findings });
|
|
94
102
|
}
|
|
95
103
|
}
|
|
96
104
|
} finally {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { sameText } from '../../lib/same-text.mjs';
|
|
4
4
|
import { found, readText } from './read.mjs';
|
|
5
5
|
import { DB_CONFIG_POLICY, DB_TYPES_DRIFT, TYPES_FILE } from './database-constants.mjs';
|
|
6
|
-
const ENV_REF = /^env\([A-Za-z_]
|
|
6
|
+
const ENV_REF = /^env\([A-Za-z_]\w*\)$/;
|
|
7
7
|
const PUBLIC_KEYS = new Set(['anon_key', 'publishable_key', 'public_key']);
|
|
8
8
|
|
|
9
9
|
|
|
@@ -28,17 +28,69 @@ const isCredentialKey = (key) => {
|
|
|
28
28
|
|| (leaf.endsWith('_key') && !PUBLIC_KEYS.has(leaf));
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
const escapeRegexPart = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
32
|
+
|
|
31
33
|
const tomlLine = (text, key) => {
|
|
32
|
-
const expression = new RegExp(
|
|
34
|
+
const expression = new RegExp(String.raw`^\s*["']?${escapeRegexPart(key)}["']?\s*=`, 'm');
|
|
33
35
|
const match = expression.exec(text);
|
|
34
36
|
return match ? text.slice(0, match.index).split('\n').length : undefined;
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
function credentialConfigFindings(file, text, toml) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
for (const [at, key, value] of tomlEntries(toml)) {
|
|
42
|
+
if (!isCredentialKey(key)) continue;
|
|
43
|
+
const line = tomlLine(text, key);
|
|
44
|
+
if (typeof value !== 'string' || !ENV_REF.test(value)) {
|
|
45
|
+
const lineNote = line ? `:${line}` : '';
|
|
46
|
+
findings.push(found(DB_CONFIG_POLICY, file, `${file}${lineNote} ${at} holds a literal credential; a secret in config.toml is written env(NAME), never a value`, { ...(line ? { line } : {}), key: at }));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return findings;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A finding for an auth setting of config.toml that differs from what hfs.json supabase.<field> declares; `shown` is the setting as read.
|
|
53
|
+
const settingFinding = (file, key, field, shown, declaredValue, note = '') => found(DB_CONFIG_POLICY, file, `${file} ${key} is ${shown}; hfs.json supabase.${field} declares ${declaredValue}${note}`, { key, declared: declaredValue });
|
|
54
|
+
|
|
55
|
+
const shownSetting = (value) => JSON.stringify(value ?? 'absent');
|
|
56
|
+
|
|
57
|
+
function signupFindings(file, auth, declared) {
|
|
58
|
+
const findings = [];
|
|
59
|
+
if (declared.enableSignup === undefined) return findings;
|
|
60
|
+
if (auth.enable_signup !== declared.enableSignup) findings.push(settingFinding(file, 'auth.enable_signup', 'enableSignup', shownSetting(auth.enable_signup), declared.enableSignup, ' - the invite posture is a checked fact, not a doc claim'));
|
|
61
|
+
if (auth.email?.enable_signup !== undefined && auth.email.enable_signup !== declared.enableSignup) {
|
|
62
|
+
findings.push(settingFinding(file, 'auth.email.enable_signup', 'enableSignup', JSON.stringify(auth.email.enable_signup), declared.enableSignup));
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function redirectUrlFindings(file, auth, declared) {
|
|
68
|
+
const findings = [];
|
|
69
|
+
if (declared.redirectUrls === undefined) return findings;
|
|
70
|
+
const configured = Array.isArray(auth.additional_redirect_urls) ? auth.additional_redirect_urls : [];
|
|
71
|
+
for (const url of configured) {
|
|
72
|
+
if (!declared.redirectUrls.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls holds ${url}, which hfs.json supabase.redirectUrls does not declare; the config states nothing the app did not declare`, { key: 'auth.additional_redirect_urls', url }));
|
|
73
|
+
}
|
|
74
|
+
for (const url of declared.redirectUrls) {
|
|
75
|
+
if (!configured.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls omits ${url}, which hfs.json supabase.redirectUrls declares; the config and declaration must carry the same redirect set`, { key: 'auth.additional_redirect_urls', url }));
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function declarationConfigFindings(file, auth, declared) {
|
|
81
|
+
const findings = [];
|
|
82
|
+
if (declared.jwtExpiry !== undefined && auth.jwt_expiry !== declared.jwtExpiry) findings.push(settingFinding(file, 'auth.jwt_expiry', 'jwtExpiry', shownSetting(auth.jwt_expiry), declared.jwtExpiry));
|
|
83
|
+
findings.push(...signupFindings(file, auth, declared));
|
|
84
|
+
if (declared.siteUrl !== undefined && auth.site_url !== declared.siteUrl) findings.push(settingFinding(file, 'auth.site_url', 'siteUrl', shownSetting(auth.site_url), declared.siteUrl));
|
|
85
|
+
findings.push(...redirectUrlFindings(file, auth, declared));
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
37
89
|
/** The parsed TOML of `text`, or null when it does not parse. */
|
|
38
90
|
export async function parseToml(text) {
|
|
39
91
|
try {
|
|
40
92
|
const module = await import('smol-toml');
|
|
41
|
-
return
|
|
93
|
+
return module.parse(text);
|
|
42
94
|
} catch {
|
|
43
95
|
return null;
|
|
44
96
|
}
|
|
@@ -46,14 +98,7 @@ export async function parseToml(text) {
|
|
|
46
98
|
|
|
47
99
|
/** L08 policy findings in one already-parsed supabase/config.toml. */
|
|
48
100
|
export function configFindings({ file, text, toml, supabase }) {
|
|
49
|
-
const findings =
|
|
50
|
-
for (const [at, key, value] of tomlEntries(toml)) {
|
|
51
|
-
if (!isCredentialKey(key)) continue;
|
|
52
|
-
const line = tomlLine(text, key);
|
|
53
|
-
if (typeof value !== 'string' || !ENV_REF.test(value)) {
|
|
54
|
-
findings.push(found(DB_CONFIG_POLICY, file, `${file}${line ? `:${line}` : ''} ${at} holds a literal credential; a secret in config.toml is written env(NAME), never a value`, { ...(line ? { line } : {}), key: at }));
|
|
55
|
-
}
|
|
56
|
-
}
|
|
101
|
+
const findings = credentialConfigFindings(file, text, toml);
|
|
57
102
|
const auth = toml?.auth ?? {};
|
|
58
103
|
if (auth.jwt_expiry !== undefined && (typeof auth.jwt_expiry !== 'number' || auth.jwt_expiry > 3600)) {
|
|
59
104
|
findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.jwt_expiry is ${JSON.stringify(auth.jwt_expiry)}; the access token lives at most 3600 seconds`, { key: 'auth.jwt_expiry' }));
|
|
@@ -63,27 +108,7 @@ export function configFindings({ file, text, toml, supabase }) {
|
|
|
63
108
|
enableSignup: supabase.enableSignup, jwtExpiry: supabase.jwtExpiry, siteUrl: supabase.siteUrl,
|
|
64
109
|
redirectUrls: Array.isArray(supabase.redirectUrls) ? supabase.redirectUrls : undefined,
|
|
65
110
|
};
|
|
66
|
-
|
|
67
|
-
findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.jwt_expiry is ${JSON.stringify(auth.jwt_expiry ?? 'absent')}; hfs.json supabase.jwtExpiry declares ${declared.jwtExpiry}`, { key: 'auth.jwt_expiry', declared: declared.jwtExpiry }));
|
|
68
|
-
}
|
|
69
|
-
if (declared.enableSignup !== undefined) {
|
|
70
|
-
if (auth.enable_signup !== declared.enableSignup) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.enable_signup is ${JSON.stringify(auth.enable_signup ?? 'absent')}; hfs.json supabase.enableSignup declares ${declared.enableSignup} - the invite posture is a checked fact, not a doc claim`, { key: 'auth.enable_signup', declared: declared.enableSignup }));
|
|
71
|
-
if (auth.email?.enable_signup !== undefined && auth.email.enable_signup !== declared.enableSignup) {
|
|
72
|
-
findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.email.enable_signup is ${JSON.stringify(auth.email.enable_signup)}; hfs.json supabase.enableSignup declares ${declared.enableSignup}`, { key: 'auth.email.enable_signup', declared: declared.enableSignup }));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (declared.siteUrl !== undefined && auth.site_url !== declared.siteUrl) {
|
|
76
|
-
findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.site_url is ${JSON.stringify(auth.site_url ?? 'absent')}; hfs.json supabase.siteUrl declares ${declared.siteUrl}`, { key: 'auth.site_url', declared: declared.siteUrl }));
|
|
77
|
-
}
|
|
78
|
-
if (declared.redirectUrls !== undefined) {
|
|
79
|
-
const configured = Array.isArray(auth.additional_redirect_urls) ? auth.additional_redirect_urls : [];
|
|
80
|
-
for (const url of configured) {
|
|
81
|
-
if (!declared.redirectUrls.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls holds ${url}, which hfs.json supabase.redirectUrls does not declare; the config states nothing the app did not declare`, { key: 'auth.additional_redirect_urls', url }));
|
|
82
|
-
}
|
|
83
|
-
for (const url of declared.redirectUrls) {
|
|
84
|
-
if (!configured.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls omits ${url}, which hfs.json supabase.redirectUrls declares; the config and declaration must carry the same redirect set`, { key: 'auth.additional_redirect_urls', url }));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
111
|
+
findings.push(...declarationConfigFindings(file, auth, declared));
|
|
87
112
|
return findings;
|
|
88
113
|
}
|
|
89
114
|
|
|
@@ -5,6 +5,7 @@ import { log } from '../../api/git/log.mjs';
|
|
|
5
5
|
import { mergeBase } from '../../api/git/merge-base.mjs';
|
|
6
6
|
import { show } from '../../api/git/show.mjs';
|
|
7
7
|
import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
8
|
+
import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
|
|
8
9
|
import { sameText } from '../../lib/same-text.mjs';
|
|
9
10
|
import { found, readText } from './read.mjs';
|
|
10
11
|
import { DB_MIGRATION_SHAPE, MIGRATIONS_DIR } from './database-constants.mjs';
|
|
@@ -24,7 +25,9 @@ export function defaultGit(args, { cwd }) {
|
|
|
24
25
|
const [verb, ...rest] = args;
|
|
25
26
|
const env = withoutGitLocalEnv(process.env);
|
|
26
27
|
if (verb === 'merge-base') { const sha = mergeBase(cwd, rest[0], rest[1]); return { ok: sha !== null, stdout: sha ?? '' }; }
|
|
27
|
-
|
|
28
|
+
let call = show;
|
|
29
|
+
if (verb === 'ls-tree') call = lsTree;
|
|
30
|
+
else if (verb === 'log') call = log;
|
|
28
31
|
const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
|
|
29
32
|
return { ok: !result.error && result.status === 0, stdout: result.stdout };
|
|
30
33
|
}
|
|
@@ -33,11 +36,13 @@ const runGit = (git, repoRoot, args) => Promise.resolve(git(args, { cwd: repoRoo
|
|
|
33
36
|
.then((result) => ({ ok: result?.ok === true, stdout: String(result?.stdout ?? '') }));
|
|
34
37
|
|
|
35
38
|
async function baseShaOf(git, repoRoot, base) {
|
|
36
|
-
|
|
39
|
+
let found = null;
|
|
40
|
+
await findInOrder(base ? [base] : ['origin/main', 'main'], async (ref) => {
|
|
37
41
|
const result = await runGit(git, repoRoot, ['merge-base', 'HEAD', ref]);
|
|
38
|
-
if (result.ok && result.stdout.trim())
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
if (result.ok && result.stdout.trim()) found = { ref, sha: result.stdout.trim() };
|
|
43
|
+
return found !== null;
|
|
44
|
+
});
|
|
45
|
+
return found;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
async function baseMigrationNames(git, repoRoot, sha) {
|
|
@@ -48,7 +53,8 @@ async function baseMigrationNames(git, repoRoot, sha) {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
async function baseFileText(git, repoRoot, sha, file) {
|
|
51
|
-
|
|
56
|
+
// `./` reads the path from the app root, which is a folder of a larger repository when the app is nested in one (an example of the runtime).
|
|
57
|
+
const result = await runGit(git, repoRoot, ['show', `${sha}:./${file}`]);
|
|
52
58
|
return result.ok ? result.stdout : null;
|
|
53
59
|
}
|
|
54
60
|
|
|
@@ -59,25 +65,18 @@ async function migrationTimeLimit(git, repoRoot, file, now) {
|
|
|
59
65
|
return { at: now(), tracked: false };
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
/** L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. */
|
|
64
|
-
export async function migrationShapeFindings({ repoRoot, migrations, git, base, now }) {
|
|
68
|
+
async function localMigrationFindings(local, repoRoot, git, now) {
|
|
65
69
|
const findings = [];
|
|
66
|
-
const prefix = `${MIGRATIONS_DIR}/`;
|
|
67
|
-
const local = migrations.map((file) => {
|
|
68
|
-
const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
69
|
-
return { file, name, match: MIGRATION_NAME.exec(name) };
|
|
70
|
-
});
|
|
71
70
|
const stamps = new Map();
|
|
72
|
-
|
|
71
|
+
await eachInOrder(local, async (entry) => {
|
|
73
72
|
if (!entry.match) {
|
|
74
73
|
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (supabase migration new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
|
|
75
|
-
|
|
74
|
+
return;
|
|
76
75
|
}
|
|
77
76
|
const stamp = migrationStamp(entry.match[1]);
|
|
78
77
|
if (stamp === null) {
|
|
79
78
|
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time; the stamp is the real creation time`, { stamp: entry.match[1] }));
|
|
80
|
-
|
|
79
|
+
return;
|
|
81
80
|
}
|
|
82
81
|
const limit = await migrationTimeLimit(git, repoRoot, entry.file, now);
|
|
83
82
|
if (stamp > limit.at) {
|
|
@@ -86,26 +85,48 @@ export async function migrationShapeFindings({ repoRoot, migrations, git, base,
|
|
|
86
85
|
}
|
|
87
86
|
if (stamps.has(stamp)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} shares stamp ${entry.match[1]} with ${stamps.get(stamp)}; stamps are strictly increasing`, { stamp: entry.match[1], other: stamps.get(stamp) }));
|
|
88
87
|
stamps.set(stamp, entry.file);
|
|
88
|
+
});
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax) {
|
|
93
|
+
if (!entry.match) return [];
|
|
94
|
+
const stamp = migrationStamp(entry.match[1]);
|
|
95
|
+
if (onBase.has(entry.name)) {
|
|
96
|
+
const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
|
|
97
|
+
const current = readText(repoRoot, entry.file);
|
|
98
|
+
if (!sameText(current, committed)) return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref })];
|
|
99
|
+
} else if (baseMax !== null && stamp !== null && stamp <= baseMax) {
|
|
100
|
+
return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration on ${baseSha.ref} (latest ${String(new Date(baseMax).toISOString())}); a new migration sorts after the base ones`, { stamp: entry.match[1], base: baseSha.ref })];
|
|
89
101
|
}
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function removedMigrationFindings(local, baseNames, baseSha) {
|
|
106
|
+
const findings = [];
|
|
107
|
+
for (const name of baseNames) {
|
|
108
|
+
if (!local.some((entry) => entry.name === name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. */
|
|
114
|
+
export async function migrationShapeFindings({ repoRoot, migrations, git, base, now }) {
|
|
115
|
+
const prefix = `${MIGRATIONS_DIR}/`;
|
|
116
|
+
const local = migrations.map((file) => {
|
|
117
|
+
const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
118
|
+
return { file, name, match: MIGRATION_NAME.exec(name) };
|
|
119
|
+
});
|
|
120
|
+
const findings = await localMigrationFindings(local, repoRoot, git, now);
|
|
90
121
|
const baseSha = await baseShaOf(git, repoRoot, base);
|
|
91
122
|
if (!baseSha) return findings;
|
|
92
123
|
const baseNames = await baseMigrationNames(git, repoRoot, baseSha.sha);
|
|
93
124
|
const onBase = new Set(baseNames);
|
|
94
125
|
const baseStamps = baseNames.map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
|
|
95
126
|
const baseMax = baseStamps.length ? Math.max(...baseStamps) : null;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
|
|
101
|
-
const current = readText(repoRoot, entry.file);
|
|
102
|
-
if (!sameText(current, committed)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref }));
|
|
103
|
-
} else if (baseMax !== null && stamp !== null && stamp <= baseMax) {
|
|
104
|
-
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration on ${baseSha.ref} (latest ${String(new Date(baseMax).toISOString())}); a new migration sorts after the base ones`, { stamp: entry.match[1], base: baseSha.ref }));
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
for (const name of baseNames) {
|
|
108
|
-
if (!local.some((entry) => entry.name === name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
|
|
109
|
-
}
|
|
127
|
+
await eachInOrder(local, async (entry) => {
|
|
128
|
+
findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax));
|
|
129
|
+
});
|
|
130
|
+
findings.push(...removedMigrationFindings(local, baseNames, baseSha));
|
|
110
131
|
return findings;
|
|
111
132
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// PL/pgSQL inspection for DB_DYNAMIC_DDL and DB_DEFINER_SAFE. Function and DO bodies are parsed by libpg-query;
|
|
2
2
|
// dynamic query expressions are then parsed as PostgreSQL expressions instead of matched as raw migration text.
|
|
3
3
|
import { found } from './read.mjs';
|
|
4
|
+
import { eachInOrder } from '../../lib/in-order.mjs';
|
|
4
5
|
import { parsePlpgsqlBody, parseSql } from '../sql/pg-parse.mjs';
|
|
5
6
|
import { DB_DEFINER_SAFE, DB_DYNAMIC_DDL } from './database-constants.mjs';
|
|
6
7
|
|
|
@@ -12,36 +13,36 @@ const nameOf = (list, defaultSchema) => {
|
|
|
12
13
|
|
|
13
14
|
const functionOption = (node, key) => (node.options ?? []).filter((option) => option?.DefElem?.defname === key).map((option) => option.DefElem);
|
|
14
15
|
const securityDefiner = (node) => functionOption(node, 'security').some((option) => option.arg?.Boolean?.boolval === true || option.arg?.Integer?.ival === 1);
|
|
15
|
-
const functionLanguage = (node) => functionOption(node, 'language').map((option) => option.arg?.String?.sval).
|
|
16
|
+
const functionLanguage = (node) => functionOption(node, 'language').map((option) => option.arg?.String?.sval).findLast(Boolean) ?? 'sql';
|
|
16
17
|
const functionBody = (node) => functionOption(node, 'as').flatMap((option) => (option.arg?.List?.items ?? []).map((item) => item?.String?.sval ?? '')).join('') || null;
|
|
17
18
|
const functionReturnKind = (node) => {
|
|
18
|
-
const name = node.returnType?.names?.map(sval).
|
|
19
|
+
const name = node.returnType?.names?.map(sval).findLast(Boolean);
|
|
19
20
|
if (name === 'void') return 'void';
|
|
20
21
|
if (name === 'trigger' || name === 'event_trigger') return 'trigger';
|
|
21
22
|
return node.returnType?.setof ? 'setof' : 'scalar';
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
/** The statement kinds that run a dynamic query, with the field holding its query expression. */
|
|
26
|
+
const DYNAMIC_QUERY_FIELDS = new Map([['PLpgSQL_stmt_dynexecute', 'query'], ['PLpgSQL_stmt_dynfors', 'query'], ['PLpgSQL_stmt_open', 'dynquery']]);
|
|
27
|
+
|
|
28
|
+
function collectDynamicQueries(value, out) {
|
|
29
|
+
if (!value || typeof value !== 'object') return;
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
for (const item of value) collectDynamicQueries(item, out);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const [key, child] of Object.entries(value)) {
|
|
35
|
+
const field = DYNAMIC_QUERY_FIELDS.get(key);
|
|
36
|
+
const text = field === undefined ? undefined : child?.[field]?.PLpgSQL_expr?.query;
|
|
37
|
+
if (text !== undefined) out.push({ text, lineno: child.lineno });
|
|
38
|
+
collectDynamicQueries(child, out);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
/** Every dynamic query expression in one parsed PL/pgSQL function. */
|
|
25
43
|
function dynamicQueries(root) {
|
|
26
44
|
const out = [];
|
|
27
|
-
|
|
28
|
-
if (!value || typeof value !== 'object') return;
|
|
29
|
-
if (Array.isArray(value)) {
|
|
30
|
-
for (const item of value) visit(item);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
for (const [key, child] of Object.entries(value)) {
|
|
34
|
-
if (key === 'PLpgSQL_stmt_dynexecute' || key === 'PLpgSQL_stmt_dynfors') {
|
|
35
|
-
const text = child?.query?.PLpgSQL_expr?.query;
|
|
36
|
-
if (text !== undefined) out.push({ text, lineno: child.lineno });
|
|
37
|
-
} else if (key === 'PLpgSQL_stmt_open') {
|
|
38
|
-
const text = child?.dynquery?.PLpgSQL_expr?.query;
|
|
39
|
-
if (text !== undefined) out.push({ text, lineno: child.lineno });
|
|
40
|
-
}
|
|
41
|
-
visit(child);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
visit(root);
|
|
45
|
+
collectDynamicQueries(root, out);
|
|
45
46
|
return out;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -80,6 +81,33 @@ function formatSpecifiers(template) {
|
|
|
80
81
|
return template.slice(cursor).includes('%') ? null : specs;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/** The text one format() conversion renders to; an argument that is no string literal marks the render incomplete. */
|
|
85
|
+
function substituteSpecifier(spec, args, state) {
|
|
86
|
+
if (spec.type === '%') return '%';
|
|
87
|
+
const argumentIndex = spec.position ?? state.sequential;
|
|
88
|
+
if (spec.position === null) state.sequential += 1;
|
|
89
|
+
if (spec.type === 'I') return 'hfs_identifier';
|
|
90
|
+
if (spec.type === 'L') return "'hfs_literal'";
|
|
91
|
+
const value = stringValue(args[argumentIndex + 1]);
|
|
92
|
+
if (value !== undefined) return value;
|
|
93
|
+
state.complete = false;
|
|
94
|
+
return 'hfs_dynamic';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderFormat(args) {
|
|
98
|
+
const template = stringValue(args[0]);
|
|
99
|
+
if (template === undefined) return { text: 'hfs_dynamic', complete: false };
|
|
100
|
+
const specs = formatSpecifiers(template);
|
|
101
|
+
if (specs === null) return { text: template, complete: false };
|
|
102
|
+
const state = { sequential: 0, specIndex: 0, complete: true };
|
|
103
|
+
const text = template.replace(FORMAT_SPECIFIER, () => {
|
|
104
|
+
const spec = specs[state.specIndex];
|
|
105
|
+
state.specIndex += 1;
|
|
106
|
+
return substituteSpecifier(spec, args, state);
|
|
107
|
+
});
|
|
108
|
+
return { text, complete: state.complete };
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
/** Render enough of a dynamic expression to let the PostgreSQL parser identify its generated statement kind. */
|
|
84
112
|
function renderExpression(node) {
|
|
85
113
|
const literal = stringValue(node);
|
|
@@ -90,31 +118,7 @@ function renderExpression(node) {
|
|
|
90
118
|
const right = renderExpression(node.A_Expr.rexpr);
|
|
91
119
|
return { text: `${left.text}${right.text}`, complete: left.complete && right.complete };
|
|
92
120
|
}
|
|
93
|
-
if (formatName(node))
|
|
94
|
-
const args = node.FuncCall.args ?? [];
|
|
95
|
-
const template = stringValue(args[0]);
|
|
96
|
-
if (template === undefined) return { text: 'hfs_dynamic', complete: false };
|
|
97
|
-
const specs = formatSpecifiers(template);
|
|
98
|
-
if (specs === null) return { text: template, complete: false };
|
|
99
|
-
let sequential = 0;
|
|
100
|
-
let specIndex = 0;
|
|
101
|
-
let complete = true;
|
|
102
|
-
const text = template.replace(FORMAT_SPECIFIER, () => {
|
|
103
|
-
const spec = specs[specIndex];
|
|
104
|
-
specIndex += 1;
|
|
105
|
-
if (spec.type === '%') return '%';
|
|
106
|
-
const argumentIndex = spec.position ?? sequential;
|
|
107
|
-
if (spec.position === null) sequential += 1;
|
|
108
|
-
const argument = args[argumentIndex + 1];
|
|
109
|
-
if (spec.type === 'I') return 'hfs_identifier';
|
|
110
|
-
if (spec.type === 'L') return "'hfs_literal'";
|
|
111
|
-
const value = stringValue(argument);
|
|
112
|
-
if (value !== undefined) return value;
|
|
113
|
-
complete = false;
|
|
114
|
-
return 'hfs_dynamic';
|
|
115
|
-
});
|
|
116
|
-
return { text, complete };
|
|
117
|
-
}
|
|
121
|
+
if (formatName(node)) return renderFormat(node.FuncCall.args ?? []);
|
|
118
122
|
return { text: 'hfs_dynamic', complete: false };
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -142,15 +146,16 @@ async function dynamicQueryAnalysis(text) {
|
|
|
142
146
|
|
|
143
147
|
async function dynamicDdlFindings(file, queries, where, baseLine) {
|
|
144
148
|
const findings = [];
|
|
145
|
-
|
|
149
|
+
await eachInOrder(queries, async ({ text, lineno }) => {
|
|
146
150
|
const line = lineno ? baseLine + lineno - 1 : undefined;
|
|
151
|
+
const lineNote = line ? `${file}:${line}` : file;
|
|
147
152
|
const analysis = await dynamicQueryAnalysis(text);
|
|
148
153
|
if (analysis.buildsDdl) {
|
|
149
|
-
findings.push(found(DB_DYNAMIC_DDL, file, `${
|
|
154
|
+
findings.push(found(DB_DYNAMIC_DDL, file, `${lineNote} ${where} builds DDL dynamically (\`${String(text).slice(0, 120)}\`); write the create/alter/drop or grant/revoke out as statements so the parser sees every one`, { ...(line ? { line } : {}), query: text }));
|
|
150
155
|
} else if (analysis.opaque) {
|
|
151
|
-
findings.push(found(DB_DYNAMIC_DDL, file, `${
|
|
156
|
+
findings.push(found(DB_DYNAMIC_DDL, file, `${lineNote} ${where} runs dynamic SQL the pass cannot read (\`${String(text).slice(0, 120)}\`); dynamic DDL is refused and a query built from an opaque value cannot be judged`, { ...(line ? { line } : {}), query: text }));
|
|
152
157
|
}
|
|
153
|
-
}
|
|
158
|
+
});
|
|
154
159
|
return findings;
|
|
155
160
|
}
|
|
156
161
|
|
|
@@ -163,17 +168,18 @@ function safeDefinerExpression(node) {
|
|
|
163
168
|
const template = stringValue(node.FuncCall.args?.[0]);
|
|
164
169
|
if (template === undefined) return false;
|
|
165
170
|
const specs = formatSpecifiers(template);
|
|
166
|
-
return specs
|
|
171
|
+
return specs?.every((spec) => ['I', 'L', '%'].includes(spec.type)) ?? false;
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
async function definerDynamicFindings(file, fn, queries, baseLine) {
|
|
170
175
|
const findings = [];
|
|
171
|
-
|
|
176
|
+
await eachInOrder(queries, async ({ text, lineno }) => {
|
|
172
177
|
const line = lineno ? baseLine + lineno - 1 : undefined;
|
|
178
|
+
const lineNote = line ? `${file}:${line}` : file;
|
|
173
179
|
const expression = await expressionOf(text);
|
|
174
|
-
if (expression !== null && safeDefinerExpression(expression))
|
|
175
|
-
findings.push(found(DB_DEFINER_SAFE, file, `${
|
|
176
|
-
}
|
|
180
|
+
if (expression !== null && safeDefinerExpression(expression)) return;
|
|
181
|
+
findings.push(found(DB_DEFINER_SAFE, file, `${lineNote} security definer function ${fn.name} builds dynamic SQL without quoting every parameter through format() %I or %L (\`${String(text).slice(0, 120)}\`)`, { ...(line ? { line } : {}), function: fn.name, query: text }));
|
|
182
|
+
});
|
|
177
183
|
return findings;
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -188,46 +194,55 @@ function revokedRolesFor(fn, grants) {
|
|
|
188
194
|
return roles;
|
|
189
195
|
}
|
|
190
196
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
for (const block of facts.doBlocks) {
|
|
195
|
-
if (block.body === undefined || block.language !== 'plpgsql') {
|
|
196
|
-
findings.push(found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block in ${block.language} cannot be inspected; a DO block is plpgsql so a static pass sees its statements`, { line: block.line }));
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
const ast = await plpgsqlOf(block.body);
|
|
200
|
-
if (ast === null) {
|
|
201
|
-
findings.push(found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block body is not valid plpgsql; the pass must see every statement a migration runs`, { line: block.line }));
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
findings.push(...await dynamicDdlFindings(file, dynamicQueries(ast), 'DO block', block.line));
|
|
197
|
+
async function doBlockFindings(file, block) {
|
|
198
|
+
if (block.body === undefined || block.language !== 'plpgsql') {
|
|
199
|
+
return [found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block in ${block.language} cannot be inspected; a DO block is plpgsql so a static pass sees its statements`, { line: block.line })];
|
|
205
200
|
}
|
|
201
|
+
const ast = await plpgsqlOf(block.body);
|
|
202
|
+
if (ast === null) return [found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block body is not valid plpgsql; the pass must see every statement a migration runs`, { line: block.line })];
|
|
203
|
+
return dynamicDdlFindings(file, dynamicQueries(ast), 'DO block', block.line);
|
|
204
|
+
}
|
|
206
205
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
206
|
+
function definerDeclarationFindings(file, fn, facts, exposed, definer) {
|
|
207
|
+
if (!definer) return [];
|
|
208
|
+
const findings = [];
|
|
209
|
+
const pathSet = functionOption(fn.node, 'set').some((option) => facts.isSearchPathSet(option))
|
|
210
|
+
|| facts.alterFunctions.some((alter) => alter.name === fn.name && alter.setsPath && alter.index > fn.index);
|
|
211
|
+
if (!pathSet) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} sets no search_path; add \`set search_path = ''\` (or a pinned schema list) on the create, or an alter function in the same migration`, { line: fn.line, function: fn.name }));
|
|
212
|
+
if (exposed.has(fn.schema)) {
|
|
213
|
+
const revoked = revokedRolesFor(fn, facts.grants);
|
|
214
|
+
if (!revoked.has('public') || !revoked.has('anon')) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} lives in the exposed schema ${fn.schema}; keep it in a non-exposed schema or revoke execute from public and anon in the same migration`, { line: fn.line, function: fn.name }));
|
|
215
|
+
}
|
|
216
|
+
if (!fn.node.is_procedure && !fn.node.returnType) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} declares no return type`, { line: fn.line, function: fn.name }));
|
|
217
|
+
return findings;
|
|
218
|
+
}
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const queries = dynamicQueries(ast);
|
|
229
|
-
findings.push(...await dynamicDdlFindings(file, queries, `function ${fn.name}`, fn.line));
|
|
230
|
-
if (definer) findings.push(...await definerDynamicFindings(file, fn, queries, fn.line));
|
|
220
|
+
async function functionBodyFindings(file, fn, definer) {
|
|
221
|
+
if (functionLanguage(fn.node) !== 'plpgsql') return [];
|
|
222
|
+
const body = functionBody(fn.node);
|
|
223
|
+
if (body === null) return [];
|
|
224
|
+
const ast = await plpgsqlOf(body, fn.node.parameters, functionReturnKind(fn.node));
|
|
225
|
+
if (ast === null) {
|
|
226
|
+
if (definer) return [found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} has a body the pass cannot read; a definer body must be plain plpgsql`, { line: fn.line, function: fn.name })];
|
|
227
|
+
return [];
|
|
231
228
|
}
|
|
229
|
+
const queries = dynamicQueries(ast);
|
|
230
|
+
const findings = await dynamicDdlFindings(file, queries, `function ${fn.name}`, fn.line);
|
|
231
|
+
if (definer) findings.push(...await definerDynamicFindings(file, fn, queries, fn.line));
|
|
232
|
+
return findings;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function functionFindings(file, fn, facts, exposed) {
|
|
236
|
+
const definer = securityDefiner(fn.node);
|
|
237
|
+
const findings = definerDeclarationFindings(file, fn, facts, exposed, definer);
|
|
238
|
+
findings.push(...await functionBodyFindings(file, fn, definer));
|
|
239
|
+
return findings;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** All PL/pgSQL-backed L04/L06 findings for one migration's extracted facts. */
|
|
243
|
+
export async function plpgsqlFindings(file, facts, exposed) {
|
|
244
|
+
const findings = [];
|
|
245
|
+
await eachInOrder(facts.doBlocks, async (block) => { findings.push(...await doBlockFindings(file, block)); });
|
|
246
|
+
await eachInOrder(facts.functions, async (fn) => { findings.push(...await functionFindings(file, fn, facts, exposed)); });
|
|
232
247
|
return findings;
|
|
233
248
|
}
|