@starci/hfs 4.9.9 → 4.10.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 +33 -0
- package/README.md +20 -8
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +12 -11
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +84 -260
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +82 -31
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +250 -157
- package/runtime/knowledge/hfs/slots.yaml +199 -174
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +86 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +8 -8
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +24 -22
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
|
@@ -62,6 +62,9 @@ function beSpecificProblems(be, manifest) {
|
|
|
62
62
|
return bad;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** True for an absent list or a list of {name, reason} database functions, names unique. */
|
|
66
|
+
export const dbFunctionsOk = (list) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'name,reason' && /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/.test(entry.name) && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.name)).size === list.length);
|
|
67
|
+
|
|
65
68
|
/** The shape problems of `m.ruleParams`. */
|
|
66
69
|
export function ruleParamsProblems(m) {
|
|
67
70
|
const bad = [];
|
|
@@ -71,7 +74,7 @@ export function ruleParamsProblems(m) {
|
|
|
71
74
|
// common holds the parameters both sides share; a side may restate one of them (a valid value) as an override, nothing else.
|
|
72
75
|
// Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
|
|
73
76
|
if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock)) bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
|
|
74
|
-
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + ') and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
77
|
+
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]) and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
75
78
|
bad.push(...beSpecificProblems(rp.be, m));
|
|
76
79
|
if (rp.fe !== undefined && (!sideOk(rp.fe, [], ['lite']) || !liteOk(rp.fe.lite, SHARED_KEYS))) bad.push('ruleParams.fe may only restate the shared parameters of ruleParams.common, and carry a lite override of them');
|
|
77
80
|
if (!dbParamsOk(rp.db)) bad.push('ruleParams.db may contain only extensions (a list of unique non-empty extension names) and queueOutbox ({table: <schema>.<table>, columns: [unique column names including queue]})');
|
|
@@ -48,7 +48,7 @@ export const A15_CODES = [
|
|
|
48
48
|
'HFS_WORKFLOW_CONTEXT',
|
|
49
49
|
'CI_STANDARD',
|
|
50
50
|
'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
|
|
51
|
-
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', '
|
|
51
|
+
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BOOT_PROOF_TIMEOUT', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
52
52
|
// codes-end
|
|
53
53
|
];
|
|
54
54
|
|
|
@@ -26,7 +26,7 @@ export function cliFindings({ files, repo, resolver }) {
|
|
|
26
26
|
const commandFiles = onBe.filter((file) => beResolver.classifyPath(file).slot === CLI_FEATURE_SLOT);
|
|
27
27
|
const findings = [];
|
|
28
28
|
const cliApps = be.apps.filter((app) => app.kind === CLI_KIND);
|
|
29
|
-
// Where the cli app is only optional (litePresence: schema migration is
|
|
29
|
+
// Where the cli app is only optional (litePresence: schema migration is `starci app db migrate`'s job) a connection no longer forces it; a tracked command still does.
|
|
30
30
|
const migrates = (be.connections ?? []).length > 0 && beResolver.slot(CLI_APP_SLOT)?.presence !== 'optional';
|
|
31
31
|
const needs = migrates || commandFiles.length > 0;
|
|
32
32
|
let why = 'it tracks commands under be/src/features/cli/';
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
//
|
|
1
|
+
// R255-R256 inspect the final database access state: the application role, forced RLS, policies, views and storage prefixes.
|
|
2
2
|
import { found } from './read.mjs';
|
|
3
3
|
import { parseSql } from '../sql/pg-parse.mjs';
|
|
4
4
|
import { eachInOrder } from '../../lib/in-order.mjs';
|
|
5
5
|
|
|
6
|
-
export const DB_BE_ONLY_ACCESS = 'DB_BE_ONLY_ACCESS';
|
|
7
|
-
export const DB_REALTIME_UNLISTED = 'DB_REALTIME_UNLISTED';
|
|
8
6
|
export const DB_APP_ROLE = 'DB_APP_ROLE';
|
|
9
7
|
export const DB_FORCE_RLS_DEFAULT = 'DB_FORCE_RLS_DEFAULT';
|
|
10
8
|
export const DB_POLICY_TAUTOLOGY = 'DB_POLICY_TAUTOLOGY';
|
|
@@ -20,8 +18,6 @@ const unwrap = (node) => node?.TypeCast ? unwrap(node.TypeCast.arg) : node;
|
|
|
20
18
|
const stringValue = (node) => unwrap(node)?.A_Const?.sval?.sval;
|
|
21
19
|
const operator = (node) => names(node.name);
|
|
22
20
|
const publicRead = (policy) => policy.cmd === 'select' && policy.name.endsWith('_public_read');
|
|
23
|
-
const anonymous = (role) => role === 'anon' || role === 'public';
|
|
24
|
-
const browserRole = (role) => anonymous(role) || role === 'authenticated';
|
|
25
21
|
const AND = ['AND', 'EXPR'].join('_');
|
|
26
22
|
const OR = ['OR', 'EXPR'].join('_');
|
|
27
23
|
const NOT = ['NOT', 'EXPR'].join('_');
|
|
@@ -119,40 +115,10 @@ function updatePrivileges(state, granting, privileges) {
|
|
|
119
115
|
if (privileges.some((privilege) => ['select', 'all'].includes(privilege))) state.revokedRead = true;
|
|
120
116
|
}
|
|
121
117
|
|
|
122
|
-
function
|
|
123
|
-
const findings = [];
|
|
124
|
-
for (const state of grants.values()) {
|
|
125
|
-
if (!state.privileges.size || !browserRole(state.role)) continue;
|
|
126
|
-
if (!exposed.has(state.target.split('.')[0]) || directAccess.has(state.target)) continue;
|
|
127
|
-
const readOnly = state.privileges.size === 1 && state.privileges.has('select');
|
|
128
|
-
const policy = policies.some((item) => `${item.schema}.${item.table}` === state.target && publicRead(item)
|
|
129
|
-
&& item.roles.some((role) => role === state.role || role === 'public'));
|
|
130
|
-
if (anonymous(state.role) && state.objtype === 'OBJECT_TABLE' && readOnly && policy) continue;
|
|
131
|
-
findings.push(note(DB_BE_ONLY_ACCESS, state, `grants ${state.role} data privileges on ${state.target}; browser data access requires supabase.directAccess, or anonymous SELECT beside *_public_read`, { table: state.target }));
|
|
132
|
-
}
|
|
133
|
-
return findings;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function defaultAccessFindings(analyses, exposed) {
|
|
137
|
-
const findings = [];
|
|
138
|
-
for (const { file, facts } of analyses) {
|
|
139
|
-
for (const defaults of facts.defaultPrivileges) {
|
|
140
|
-
if (!defaults.grant || !['OBJECT_TABLE', SEQUENCE].includes(defaults.objtype)) continue;
|
|
141
|
-
if (defaults.schemas?.length && !defaults.schemas.some((schema) => exposed.has(schema))) continue;
|
|
142
|
-
if (defaults.grantees.some(browserRole)) findings.push(note(DB_BE_ONLY_ACCESS, { ...defaults, file }, 'grants default browser data privileges; exposed data grants must be explicit per table'));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return findings;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function policyFindings(policies, exposed, directAccess, appRole, helpers) {
|
|
118
|
+
function policyFindings(policies, appRole, helpers) {
|
|
149
119
|
const findings = [];
|
|
150
120
|
for (const policy of policies) {
|
|
151
121
|
const table = `${policy.schema}.${policy.table}`;
|
|
152
|
-
const listed = directAccess.has(table);
|
|
153
|
-
if (exposed.has(policy.schema) && !listed && policy.roles.some((role) => browserRole(role) && !(anonymous(role) && publicRead(policy)))) {
|
|
154
|
-
findings.push(note(DB_BE_ONLY_ACCESS, policy, `policy ${policy.name} opens ${table} to browser roles; list intentional access in supabase.directAccess`, { table, policy: policy.name }));
|
|
155
|
-
}
|
|
156
122
|
if (policy.roles.every((role) => role === appRole) || publicRead(policy)) continue;
|
|
157
123
|
const predicates = [policy.qual, policy.withCheck].filter((expression) => expression !== undefined);
|
|
158
124
|
if (!predicates.length || predicates.some((expression) => !principalPredicate(expression, helpers))) {
|
|
@@ -187,34 +153,6 @@ function roleStatementFindings(statement, appRole) {
|
|
|
187
153
|
return [note(DB_APP_ROLE, statement, `${appRole} must LOGIN NOINHERIT without SUPERUSER, BYPASSRLS or administrative role attributes`)];
|
|
188
154
|
}
|
|
189
155
|
|
|
190
|
-
function publicationTargets(node, defaultSchema) {
|
|
191
|
-
if (node.for_all_tables) return ['*'];
|
|
192
|
-
return (node.pubobjects ?? []).map(({ PublicationObjSpec: spec }) => {
|
|
193
|
-
if (spec.pubtable?.relation) return relationName(spec.pubtable.relation, defaultSchema);
|
|
194
|
-
return `${spec.name ?? defaultSchema}.*`;
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function publicationFindings(analyses, directAccess) {
|
|
199
|
-
const tables = new Map();
|
|
200
|
-
for (const { file, facts } of analyses) {
|
|
201
|
-
for (const statement of facts.statements) {
|
|
202
|
-
applyPublication(tables, { ...statement, file });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return [...tables].filter(([table]) => !directAccess.has(table)).map(([table, source]) => note(DB_REALTIME_UNLISTED, source, `supabase_realtime publishes unlisted ${table}; every browser data table must be in supabase.directAccess`, { table }));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function applyPublication(tables, statement) {
|
|
209
|
-
const { type, node, defaultSchema } = statement;
|
|
210
|
-
if (!['CreatePublicationStmt', 'AlterPublicationStmt'].includes(type) || node.pubname !== 'supabase_realtime') return;
|
|
211
|
-
if (type === 'CreatePublicationStmt' || node.action === 'AP_SetObjects') tables.clear();
|
|
212
|
-
for (const table of publicationTargets(node, defaultSchema)) {
|
|
213
|
-
if (node.action === 'AP_DropObjects') tables.delete(table);
|
|
214
|
-
else tables.set(table, statement);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
156
|
function tableStates(analyses) {
|
|
219
157
|
const tables = new Map();
|
|
220
158
|
let owner = null;
|
|
@@ -251,10 +189,10 @@ function applyTableAlter(tables, statement) {
|
|
|
251
189
|
tables.set(key, table);
|
|
252
190
|
}
|
|
253
191
|
|
|
254
|
-
function forceFindings(analyses,
|
|
255
|
-
const exemptions = new Set((
|
|
192
|
+
function forceFindings(analyses, db, appRole) {
|
|
193
|
+
const exemptions = new Set((db.forceRlsExemptions ?? []).filter((item) => typeof item?.table === 'string' && typeof item.reason === 'string' && item.reason.trim()).map((item) => qualify(item.table)));
|
|
256
194
|
return [...tableStates(analyses)].filter(([name, table]) => table.owner === appRole && !table.forced && !exemptions.has(name))
|
|
257
|
-
.map(([table, source]) => note(DB_FORCE_RLS_DEFAULT, source, `${appRole} owns ${table} without FORCE ROW LEVEL SECURITY; an exemption needs
|
|
195
|
+
.map(([table, source]) => note(DB_FORCE_RLS_DEFAULT, source, `${appRole} owns ${table} without FORCE ROW LEVEL SECURITY; an exemption needs hfs.json db.forceRlsExemptions with a table and reason`, { table }));
|
|
258
196
|
}
|
|
259
197
|
|
|
260
198
|
function viewFindings(analyses, grants, exposed) {
|
|
@@ -318,10 +256,9 @@ function storagePrefixFindings(policies, helpers) {
|
|
|
318
256
|
}
|
|
319
257
|
|
|
320
258
|
/** All new access checks run on the ordered active migrations in either edition, through checkDatabase. */
|
|
321
|
-
export async function databaseAccessFindings({ analyses, exposed,
|
|
322
|
-
const declared =
|
|
259
|
+
export async function databaseAccessFindings({ analyses, exposed, db = {} }) {
|
|
260
|
+
const declared = db ?? {};
|
|
323
261
|
const appRole = declared.appRole ?? 'app_be';
|
|
324
|
-
const directAccess = new Set((declared.directAccess ?? []).filter((table) => typeof table === 'string').map(qualify));
|
|
325
262
|
const policyMap = new Map();
|
|
326
263
|
const grants = new Map();
|
|
327
264
|
for (const { file, facts } of analyses) {
|
|
@@ -331,10 +268,7 @@ export async function databaseAccessFindings({ analyses, exposed, supabase = {}
|
|
|
331
268
|
const policies = [...policyMap.values()];
|
|
332
269
|
const helpers = await principalHelpers(analyses);
|
|
333
270
|
return [
|
|
334
|
-
...
|
|
335
|
-
...defaultAccessFindings(analyses, exposed),
|
|
336
|
-
...policyFindings(policies, exposed, directAccess, appRole, helpers),
|
|
337
|
-
...publicationFindings(analyses, directAccess),
|
|
271
|
+
...policyFindings(policies, appRole, helpers),
|
|
338
272
|
...roleFindings(analyses, appRole),
|
|
339
273
|
...forceFindings(analyses, declared, appRole),
|
|
340
274
|
...viewFindings(analyses, grants, exposed),
|
|
@@ -8,8 +8,8 @@ import { found, readText } from './read.mjs';
|
|
|
8
8
|
import { migrationStamp } from './database-migrations.mjs';
|
|
9
9
|
import { DB_BASELINE_SHAPE, DB_ARCHIVE_MUTATED, MIGRATIONS_DIR, MIGRATIONS_ARCHIVE_DIR } from './database-constants.mjs';
|
|
10
10
|
|
|
11
|
-
const ACTIVE = /^
|
|
12
|
-
const ARCHIVED = /^
|
|
11
|
+
const ACTIVE = /^db\/migrations\/\d{14}_[^/]+\.sql$/;
|
|
12
|
+
const ARCHIVED = /^db\/migrations\/archive\/\d{14}_[^/]+\.sql$/;
|
|
13
13
|
const stampOf = (file) => /(?:^|\/)(\d{14})_/.exec(file)?.[1] ?? null;
|
|
14
14
|
|
|
15
15
|
/** Header comments only: SQL literals and later comments cannot impersonate a baseline marker. */
|
|
@@ -68,7 +68,7 @@ function shapeFindings(repoRoot, active, archived) {
|
|
|
68
68
|
if (!archived.length) return { findings: [], marked };
|
|
69
69
|
const findings = [];
|
|
70
70
|
if (marked.length !== 1) {
|
|
71
|
-
findings.push(found(DB_BASELINE_SHAPE, MIGRATIONS_DIR, 'a nonempty
|
|
71
|
+
findings.push(found(DB_BASELINE_SHAPE, MIGRATIONS_DIR, 'a nonempty archive requires exactly one active -- starci:baseline <archived-last-stamp> header marker'));
|
|
72
72
|
return { findings, marked };
|
|
73
73
|
}
|
|
74
74
|
const latest = archived.map(stampOf).filter((stamp) => stamp && migrationStamp(stamp) !== null).sort((left, right) => left.localeCompare(right)).at(-1);
|
|
@@ -1,141 +1,13 @@
|
|
|
1
|
-
// database-config.mjs -
|
|
2
|
-
//
|
|
3
|
-
import {
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { parse as parseTomlSync } from 'smol-toml';
|
|
6
|
-
import { found, readJson, readText } from './read.mjs';
|
|
1
|
+
// database-config.mjs - the database content checks of db/ (R259: extensions, custody SQL, destructive migrations, seed data). It starts no
|
|
2
|
+
// database and no Docker process.
|
|
3
|
+
import { found, readText } from './read.mjs';
|
|
7
4
|
import {
|
|
8
|
-
|
|
9
|
-
HFS_SEED_PII, isActiveMigration, TYPES_FILE,
|
|
5
|
+
DB_EXTENSION_ALLOWED, DB_VAULT_WRITE, HFS_SEED_PII, isActiveMigration, SEED_FILE,
|
|
10
6
|
} from './database-constants.mjs';
|
|
11
7
|
import { databaseExecutableTokens, databaseSqlStatements, databaseSqlTokens, destructiveMigrationFindings } from './database-migrations.mjs';
|
|
12
8
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
13
|
-
const ENV_REF = /^env\([A-Za-z_]\w*\)$/;
|
|
14
|
-
const PUBLIC_KEYS = new Set(['anon_key', 'publishable_key', 'public_key']);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
function tomlEntries(value, path = []) {
|
|
18
|
-
if (!value || typeof value !== 'object') return [];
|
|
19
|
-
if (Array.isArray(value)) return value.flatMap((item) => tomlEntries(item, path));
|
|
20
|
-
return Object.entries(value).flatMap(([key, child]) => {
|
|
21
|
-
const at = [...path, key];
|
|
22
|
-
if (child && typeof child === 'object') {
|
|
23
|
-
if (Array.isArray(child)) {
|
|
24
|
-
if (child.every((item) => item && typeof item === 'object' && !Array.isArray(item))) return child.flatMap((item) => tomlEntries(item, at));
|
|
25
|
-
} else return tomlEntries(child, at);
|
|
26
|
-
}
|
|
27
|
-
return [[at.join('.'), key, child]];
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const isCredentialKey = (key) => {
|
|
32
|
-
const leaf = key.toLowerCase();
|
|
33
|
-
return ['secret', 'client_id', 'key', 'pass', 'password', 'token'].includes(leaf)
|
|
34
|
-
|| /_(secret|password|token|client_id|api_key)$/.test(leaf)
|
|
35
|
-
|| (leaf.endsWith('_key') && !PUBLIC_KEYS.has(leaf));
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const escapeRegexPart = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
39
|
-
|
|
40
|
-
const tomlLine = (text, key) => {
|
|
41
|
-
const expression = new RegExp(String.raw`^\s*["']?${escapeRegexPart(key)}["']?\s*=`, 'm');
|
|
42
|
-
const match = expression.exec(text);
|
|
43
|
-
return match ? text.slice(0, match.index).split('\n').length : undefined;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function credentialConfigFindings(file, text, toml) {
|
|
47
|
-
const findings = [];
|
|
48
|
-
for (const [at, key, value] of tomlEntries(toml)) {
|
|
49
|
-
if (!isCredentialKey(key)) continue;
|
|
50
|
-
const line = tomlLine(text, key);
|
|
51
|
-
if (typeof value !== 'string' || !ENV_REF.test(value)) {
|
|
52
|
-
const lineNote = line ? `:${line}` : '';
|
|
53
|
-
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 }));
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return findings;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// A finding for an auth setting of config.toml that differs from what hfs.json supabase.<field> declares; `shown` is the setting as read.
|
|
60
|
-
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 });
|
|
61
|
-
|
|
62
|
-
const shownSetting = (value) => JSON.stringify(value ?? 'absent');
|
|
63
|
-
|
|
64
|
-
function signupFindings(file, auth, declared) {
|
|
65
|
-
const findings = [];
|
|
66
|
-
if (declared.enableSignup === undefined) return findings;
|
|
67
|
-
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'));
|
|
68
|
-
if (auth.email?.enable_signup !== undefined && auth.email.enable_signup !== declared.enableSignup) {
|
|
69
|
-
findings.push(settingFinding(file, 'auth.email.enable_signup', 'enableSignup', JSON.stringify(auth.email.enable_signup), declared.enableSignup));
|
|
70
|
-
}
|
|
71
|
-
return findings;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function redirectUrlFindings(file, auth, declared) {
|
|
75
|
-
const findings = [];
|
|
76
|
-
if (declared.redirectUrls === undefined) return findings;
|
|
77
|
-
const configured = Array.isArray(auth.additional_redirect_urls) ? auth.additional_redirect_urls : [];
|
|
78
|
-
for (const url of configured) {
|
|
79
|
-
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 }));
|
|
80
|
-
}
|
|
81
|
-
for (const url of declared.redirectUrls) {
|
|
82
|
-
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 }));
|
|
83
|
-
}
|
|
84
|
-
return findings;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function declarationConfigFindings(file, auth, declared) {
|
|
88
|
-
const findings = [];
|
|
89
|
-
if (declared.jwtExpiry !== undefined && auth.jwt_expiry !== declared.jwtExpiry) findings.push(settingFinding(file, 'auth.jwt_expiry', 'jwtExpiry', shownSetting(auth.jwt_expiry), declared.jwtExpiry));
|
|
90
|
-
findings.push(...signupFindings(file, auth, declared));
|
|
91
|
-
if (declared.siteUrl !== undefined && auth.site_url !== declared.siteUrl) findings.push(settingFinding(file, 'auth.site_url', 'siteUrl', shownSetting(auth.site_url), declared.siteUrl));
|
|
92
|
-
findings.push(...redirectUrlFindings(file, auth, declared));
|
|
93
|
-
return findings;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The parsed TOML of `text`, or null when it does not parse. */
|
|
97
|
-
export async function parseToml(text) {
|
|
98
|
-
try {
|
|
99
|
-
const module = await import('smol-toml');
|
|
100
|
-
return module.parse(text);
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** L08 policy findings in one already-parsed supabase/config.toml. */
|
|
107
|
-
export function configFindings({ file, text, toml, supabase }) {
|
|
108
|
-
const findings = credentialConfigFindings(file, text, toml);
|
|
109
|
-
const auth = toml?.auth ?? {};
|
|
110
|
-
if (auth.jwt_expiry !== undefined && (typeof auth.jwt_expiry !== 'number' || auth.jwt_expiry > 3600)) {
|
|
111
|
-
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' }));
|
|
112
|
-
}
|
|
113
|
-
if (!supabase) return findings;
|
|
114
|
-
const declared = {
|
|
115
|
-
enableSignup: supabase.enableSignup, jwtExpiry: supabase.jwtExpiry, siteUrl: supabase.siteUrl,
|
|
116
|
-
redirectUrls: Array.isArray(supabase.redirectUrls) ? supabase.redirectUrls : undefined,
|
|
117
|
-
};
|
|
118
|
-
findings.push(...declarationConfigFindings(file, auth, declared));
|
|
119
|
-
return findings;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** L09 drift findings for the committed generated database type file. */
|
|
123
|
-
export async function typesFindings({ repoRoot, files, emitTypes, declaration }) {
|
|
124
|
-
if (typeof emitTypes !== 'function') return [];
|
|
125
|
-
const tracked = new Set(files);
|
|
126
|
-
let emitted;
|
|
127
|
-
try { emitted = await emitTypes({ repoRoot, declaration }); } catch (error) {
|
|
128
|
-
return [found(DB_TYPES_DRIFT, TYPES_FILE, `${TYPES_FILE} cannot be verified: the types emit failed (${String(error?.message ?? error).split('\n').filter(Boolean).slice(0, 3).join(' | ')})`, { drift: 'emit-failed' })];
|
|
129
|
-
}
|
|
130
|
-
const committed = tracked.has(TYPES_FILE) ? readText(repoRoot, TYPES_FILE) : null;
|
|
131
|
-
if (committed === null) return [found(DB_TYPES_DRIFT, TYPES_FILE, `${TYPES_FILE} is not committed; run \`npm run contract:emit\` and commit the generated types`, { drift: 'not-committed' })];
|
|
132
|
-
if (!sameText(committed, String(emitted))) return [found(DB_TYPES_DRIFT, TYPES_FILE, `${TYPES_FILE} differs from what \`contract:emit\` generates from the migrations now; run \`npm run contract:emit\` and commit the result`, { drift: 'stale' })];
|
|
133
|
-
return [];
|
|
134
|
-
}
|
|
135
9
|
|
|
136
10
|
const DEFAULT_EXTENSIONS = ['pgmq', 'pg_cron', 'pgcrypto', 'pg_trgm', 'vector', 'uuid-ossp', 'pg_net', 'supabase_vault', 'unaccent'];
|
|
137
|
-
const DEFAULT_API_SCHEMAS = ['public', 'graphql_public'];
|
|
138
|
-
const FORBIDDEN_API_SCHEMAS = new Set(['pgmq', 'pgmq_public']);
|
|
139
11
|
const word = (token, value) => token?.t === 'word' && token.v === value;
|
|
140
12
|
const identifier = (token) => token && ['word', 'ident'].includes(token.t) ? token.v : null;
|
|
141
13
|
|
|
@@ -143,8 +15,6 @@ function dbParameters(manifest) {
|
|
|
143
15
|
const declared = manifest?.ruleParams?.db ?? {};
|
|
144
16
|
return {
|
|
145
17
|
extensions: Array.isArray(declared.extensions) ? declared.extensions : DEFAULT_EXTENSIONS,
|
|
146
|
-
apiSchemas: Array.isArray(declared.apiSchemas) ? declared.apiSchemas : DEFAULT_API_SCHEMAS,
|
|
147
|
-
majorVersion: declared.majorVersion ?? 17,
|
|
148
18
|
};
|
|
149
19
|
}
|
|
150
20
|
|
|
@@ -257,76 +127,6 @@ function applyPublicReadPolicy(statement, policies) {
|
|
|
257
127
|
policies.set(key, { name, schema: target.schema, select: word(statement[forAt + 1], 'select'), roles: roles ?? ['public'] });
|
|
258
128
|
}
|
|
259
129
|
|
|
260
|
-
function publicReadSchemas(sources) {
|
|
261
|
-
const policies = new Map();
|
|
262
|
-
for (const { tokens } of sources) for (const statement of databaseSqlStatements(tokens)) applyPublicReadPolicy(statement, policies);
|
|
263
|
-
return new Set([...policies.values()].filter((policy) => policy.select && policy.name.endsWith('_public_read')
|
|
264
|
-
&& policy.roles.some((role) => ['anon', 'public'].includes(role))).map((policy) => policy.schema));
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function schemaConfigFindings(file, config, parameters, edition, publicSchemas) {
|
|
268
|
-
const schemas = config.api?.schemas;
|
|
269
|
-
const findings = [];
|
|
270
|
-
const allowed = new Set(parameters.apiSchemas);
|
|
271
|
-
if (!Array.isArray(schemas) || schemas.some((schema) => typeof schema !== 'string' || !allowed.has(schema))) {
|
|
272
|
-
findings.push(found(DB_CONFIG_POLICY, file, `${file} api.schemas must be an array drawn from ruleParams.db.apiSchemas`, { key: 'api.schemas' }));
|
|
273
|
-
}
|
|
274
|
-
if (Array.isArray(schemas) && schemas.some((schema) => FORBIDDEN_API_SCHEMAS.has(schema))) findings.push(found(DB_API_EXPOSED, file,
|
|
275
|
-
`${file} api.schemas exposes a pgmq schema; queues are accessible only to the application role`, { key: 'api.schemas' }));
|
|
276
|
-
const exposed = Array.isArray(schemas) ? schemas : DEFAULT_API_SCHEMAS;
|
|
277
|
-
if (edition === 'lite' && config.api?.enabled !== false && exposed.length > 0 && !exposed.some((schema) => publicSchemas.has(schema))) {
|
|
278
|
-
findings.push(found(DB_API_EXPOSED, file, `${file} enables the lite data API without an exposed *_public_read FOR SELECT policy; disable api or set schemas = []`, { key: 'api.enabled' }));
|
|
279
|
-
}
|
|
280
|
-
return findings;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function safetyConfigFindings(file, config, parameters, supabase) {
|
|
284
|
-
const findings = [];
|
|
285
|
-
const anonymous = config.auth?.enable_anonymous_sign_ins;
|
|
286
|
-
if (anonymous !== undefined && (typeof anonymous !== 'boolean' || (anonymous && supabase?.enableAnonymousSignIns !== true))) findings.push(found(DB_CONFIG_POLICY, file,
|
|
287
|
-
`${file} auth.enable_anonymous_sign_ins is enabled without hfs.json supabase.enableAnonymousSignIns`, { key: 'auth.enable_anonymous_sign_ins' }));
|
|
288
|
-
if (config.edge_runtime?.enabled !== false) findings.push(found(DB_CONFIG_POLICY, file,
|
|
289
|
-
`${file} edge_runtime.enabled must be false; application execution belongs to the back end`, { key: 'edge_runtime.enabled' }));
|
|
290
|
-
const storageLimit = config.storage?.file_size_limit;
|
|
291
|
-
if (config.storage?.enabled !== false && (typeof storageLimit !== 'string' || !/^[1-9]\d*(?:B|KB|MB|GB|KiB|MiB|GiB)$/.test(storageLimit))) findings.push(found(DB_CONFIG_POLICY, file,
|
|
292
|
-
`${file} storage.file_size_limit must declare a positive byte limit`, { key: 'storage.file_size_limit' }));
|
|
293
|
-
if (config.db?.major_version !== parameters.majorVersion) findings.push(found(DB_CONFIG_POLICY, file,
|
|
294
|
-
`${file} db.major_version must equal the ruleParams.db.majorVersion pin (${parameters.majorVersion})`, { key: 'db.major_version' }));
|
|
295
|
-
return findings;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function seedPattern(value) {
|
|
299
|
-
if (typeof value !== 'string' || !value || value.includes('\\') || path.posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) return null;
|
|
300
|
-
const relative = path.posix.normalize(`supabase/${value}`);
|
|
301
|
-
if (!relative.startsWith('supabase/')) return null;
|
|
302
|
-
const expression = relative.split(/(\*\*|\*|\?)/).map((part) => {
|
|
303
|
-
if (part === '**') return '.*';
|
|
304
|
-
if (part === '*') return '[^/]*';
|
|
305
|
-
if (part === '?') return '[^/]';
|
|
306
|
-
return escapeRegexPart(part);
|
|
307
|
-
}).join('');
|
|
308
|
-
return new RegExp(`^${expression}$`);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function seedConfigResult(repoRoot, files, config) {
|
|
312
|
-
const seeds = new Set(files.filter((file) => /^(?:supabase\/(?:seed\.sql|seeds?\/.+\.sql)|\.starcistacks\/[^/]+\/seeds\/.+\.sql)$/.test(file)));
|
|
313
|
-
const seed = config?.db?.seed;
|
|
314
|
-
if (!seed || seed.enabled === false) return { findings: [], seeds };
|
|
315
|
-
const paths = seed.sql_paths ?? ['./seed.sql'];
|
|
316
|
-
const findings = [];
|
|
317
|
-
if (!Array.isArray(paths) || paths.length === 0) return {
|
|
318
|
-
seeds, findings: [found(DB_CONFIG_POLICY, CONFIG_FILE, `${CONFIG_FILE} db.seed.sql_paths must list tracked SQL seed files`, { key: 'db.seed.sql_paths' })],
|
|
319
|
-
};
|
|
320
|
-
for (const value of paths) {
|
|
321
|
-
const pattern = seedPattern(value);
|
|
322
|
-
const matches = pattern ? files.filter((file) => file.endsWith('.sql') && pattern.test(file) && readText(repoRoot, file) !== null) : [];
|
|
323
|
-
if (matches.length === 0) findings.push(found(DB_CONFIG_POLICY, CONFIG_FILE,
|
|
324
|
-
`${CONFIG_FILE} db.seed.sql_paths contains a path with no readable tracked SQL seed file`, { key: 'db.seed.sql_paths' }));
|
|
325
|
-
for (const file of matches) seeds.add(file);
|
|
326
|
-
}
|
|
327
|
-
return { findings, seeds };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
130
|
const EMAIL_LITERAL = /^[A-Z0-9._%+-]+@([A-Z0-9-]+(?:\.[A-Z0-9-]+)+)$/i;
|
|
331
131
|
function piiLiteral(value) {
|
|
332
132
|
if (value.split(/[^\w.%+@-]+/).some((part) => {
|
|
@@ -356,20 +156,12 @@ function seedPiiFindings(repoRoot, seeds) {
|
|
|
356
156
|
export function databaseConfigFindings({ repoRoot, files = [], all = files, repo = {}, manifest, isRoot, inScope = () => true }) {
|
|
357
157
|
if (isRoot === false || ['be', 'fe'].includes(repo.profile)) return [];
|
|
358
158
|
const parameters = dbParameters(manifest);
|
|
359
|
-
const declaration = readJson(repoRoot, 'hfs.json');
|
|
360
|
-
const edition = repo.edition ?? declaration?.edition ?? 'full';
|
|
361
159
|
const migrations = all.filter((file) => isActiveMigration(file) && file.endsWith('.sql')).sort(byCodeUnit);
|
|
362
160
|
const sources = migrations.map((file) => ({ file, tokens: databaseExecutableTokens(readText(repoRoot, file) ?? '') }));
|
|
363
161
|
const findings = [];
|
|
364
162
|
for (const { file, tokens } of sources) findings.push(...extensionFindings(file, tokens, new Set(parameters.extensions)), ...custodySqlFindings(file, tokens));
|
|
365
163
|
findings.push(...destructiveMigrationFindings({ repoRoot, migrations, files: all }));
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
try { const text = readText(repoRoot, CONFIG_FILE); if (text !== null) config = parseTomlSync(text); } catch { /* L08 owns invalid TOML. */ }
|
|
369
|
-
}
|
|
370
|
-
if (config) findings.push(...schemaConfigFindings(CONFIG_FILE, config, parameters, edition, publicReadSchemas(sources)),
|
|
371
|
-
...safetyConfigFindings(CONFIG_FILE, config, parameters, declaration?.supabase));
|
|
372
|
-
const seeds = seedConfigResult(repoRoot, all, config);
|
|
373
|
-
findings.push(...seeds.findings, ...seedPiiFindings(repoRoot, seeds.seeds));
|
|
164
|
+
const seeds = all.filter((file) => file === SEED_FILE || /^\.starcistacks\/[^/]+\/seeds\/.+\.sql$/.test(file));
|
|
165
|
+
findings.push(...seedPiiFindings(repoRoot, seeds));
|
|
374
166
|
return findings.filter((finding) => inScope(finding.path));
|
|
375
167
|
}
|
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
// Single source of database-rule finding codes and owned
|
|
1
|
+
// Single source of database-rule finding codes and the owned db/ paths of a lite app (slot app.db).
|
|
2
2
|
export const DB_MIGRATION_SHAPE = 'DB_MIGRATION_SHAPE';
|
|
3
3
|
export const DB_RLS_REQUIRED = 'DB_RLS_REQUIRED';
|
|
4
4
|
export const DB_DYNAMIC_DDL = 'DB_DYNAMIC_DDL';
|
|
5
5
|
export const DB_POLICY_SHAPE = 'DB_POLICY_SHAPE';
|
|
6
6
|
export const DB_DEFINER_SAFE = 'DB_DEFINER_SAFE';
|
|
7
7
|
export const DB_STORAGE_POLICY = 'DB_STORAGE_POLICY';
|
|
8
|
-
export const
|
|
9
|
-
export const DB_TYPES_DRIFT = 'DB_TYPES_DRIFT';
|
|
8
|
+
export const LITE_DB_MIGRATION = 'LITE_DB_MIGRATION';
|
|
10
9
|
export const DB_BASELINE_SHAPE = 'DB_BASELINE_SHAPE';
|
|
11
10
|
export const DB_ARCHIVE_MUTATED = 'DB_ARCHIVE_MUTATED';
|
|
12
11
|
export const DB_PGMQ_DECLARED = 'DB_PGMQ_DECLARED';
|
|
13
12
|
export const DB_CRON_OWNED = 'DB_CRON_OWNED';
|
|
14
13
|
export const DB_EXTENSION_ALLOWED = 'DB_EXTENSION_ALLOWED';
|
|
15
|
-
export const DB_API_EXPOSED = 'DB_API_EXPOSED';
|
|
16
14
|
export const DB_MIGRATION_DESTRUCTIVE = 'DB_MIGRATION_DESTRUCTIVE';
|
|
17
15
|
export const HFS_SEED_PII = 'HFS_SEED_PII';
|
|
18
16
|
export const DB_VAULT_WRITE = 'DB_VAULT_WRITE';
|
|
19
17
|
|
|
20
|
-
export const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
export const DB_DIR = 'db';
|
|
19
|
+
export const MIGRATIONS_DIR = `${DB_DIR}/migrations`;
|
|
20
|
+
/** The migrations retired at a baseline cutover (slot app.db.migrations.archive): never applied, never judged by L02-L08. */
|
|
21
|
+
export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/archive`;
|
|
22
|
+
/** True for a migration `starci app db migrate` applies: a path under db/migrations/ outside the archive. */
|
|
24
23
|
export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
|
|
25
|
-
export const
|
|
26
|
-
export const TYPES_FILE = 'supabase/types/database.types.ts';
|
|
24
|
+
export const SEED_FILE = `${DB_DIR}/seed.sql`;
|
|
@@ -74,7 +74,7 @@ function localMigrationFindings(local) {
|
|
|
74
74
|
const stamps = new Map();
|
|
75
75
|
for (const entry of local) {
|
|
76
76
|
if (!entry.match) {
|
|
77
|
-
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\` (
|
|
77
|
+
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\` (starci app db new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
|
|
78
78
|
continue;
|
|
79
79
|
}
|
|
80
80
|
const stamp = migrationStamp(entry.match[1]);
|
|
@@ -143,8 +143,8 @@ function removedMigrationFindings(local, retired, baseNames, baseSha) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* L02 findings for names, stamps, ordering and immutability of
|
|
147
|
-
*
|
|
146
|
+
* L02 findings for names, stamps, ordering and immutability of db/migrations/*.sql. `archived` are the files of
|
|
147
|
+
* db/migrations/archive/ (app.db.migrations.archive): never judged, and a migration moved there at a baseline
|
|
148
148
|
* cutover is retired from the ordering chain, not removed. New files append after HEAD's active migrations; timestamps
|
|
149
149
|
* on archived migrations, remote branches and the wall clock do not constrain a replacement baseline.
|
|
150
150
|
*/
|
|
@@ -172,7 +172,7 @@ function migrationStatement(state, node, file) {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
function migrationFacts(state, files) {
|
|
175
|
-
for (const file of files.filter((item) => item.endsWith('.sql') && !item.
|
|
175
|
+
for (const file of files.filter((item) => item.endsWith('.sql') && !item.startsWith('db/migrations/archive/')).sort((left, right) => left.localeCompare(right))) {
|
|
176
176
|
const text = readText(state.repoRoot, file);
|
|
177
177
|
if (text === null) continue;
|
|
178
178
|
const tree = sqlTree(text);
|
|
@@ -373,9 +373,8 @@ function tickOwner(state, tick, name, schedule, ticks) {
|
|
|
373
373
|
function queueState({ repoRoot, files = [], all = files, repo = {}, manifest }) {
|
|
374
374
|
const paths = [...new Set(all)];
|
|
375
375
|
const declaration = readJson(repoRoot, 'hfs.json') ?? repo;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const state = { repoRoot, appRole: declaration.supabase?.appRole ?? 'app_be', findings: [], outbox: queueOutbox(manifest ?? loadSlotManifest()), declaredOutbox: queueOutboxDeclaration(declaration), scheduledNames: new Set(), constants: new Map(), topics: new Map(), created: new Map(), functions: new Map(), schedules: [], activeCron: new Map(), liveSchedules: new Map(), unschedules: [], drops: [], grants: [], queueAcls: new Map() };
|
|
376
|
+
if (repo.edition !== 'lite' && declaration.edition !== 'lite' && !paths.some((file) => file.startsWith('db/'))) return null;
|
|
377
|
+
const state = { repoRoot, appRole: declaration.db?.appRole ?? 'app_be', findings: [], outbox: queueOutbox(manifest ?? loadSlotManifest()), declaredOutbox: queueOutboxDeclaration(declaration), scheduledNames: new Set(), constants: new Map(), topics: new Map(), created: new Map(), functions: new Map(), schedules: [], activeCron: new Map(), liveSchedules: new Map(), unschedules: [], drops: [], grants: [], queueAcls: new Map() };
|
|
379
378
|
state.sources = queueSourceFacts(state, paths, inspectCalls);
|
|
380
379
|
migrationFacts(state, paths);
|
|
381
380
|
return state;
|
|
@@ -394,7 +393,7 @@ function validateQueues(state, inScope) {
|
|
|
394
393
|
return state.findings.filter((finding) => inScope(finding.path));
|
|
395
394
|
}
|
|
396
395
|
|
|
397
|
-
/** Cross-file R258 findings. A full app participates when it
|
|
396
|
+
/** Cross-file R258 findings. A full app participates when it contains a db/ tree. */
|
|
398
397
|
export function databaseQueuesFindings(context) {
|
|
399
398
|
// The app-root pass owns the cross-file inventory; a side pass cannot see its migrations.
|
|
400
399
|
if (context.appSide === true) return [];
|
|
@@ -283,7 +283,7 @@ function rlsFindings(file, facts, exposed, forceRls) {
|
|
|
283
283
|
findings.push(found(DB_RLS_REQUIRED, file, `${file}:${table.line} ${operation} ${qualified} into the exposed schema ${table.schema} without a later \`alter table ${qualified} enable row level security\` in the same migration`, { line: table.line, table: qualified }));
|
|
284
284
|
}
|
|
285
285
|
if (forceRls.has(qualified) && !relationHasLaterEvent(facts.forces, table)) {
|
|
286
|
-
findings.push(found(DB_RLS_REQUIRED, file, `${file}:${table.line} ${operation} ${qualified}, which hfs.json
|
|
286
|
+
findings.push(found(DB_RLS_REQUIRED, file, `${file}:${table.line} ${operation} ${qualified}, which hfs.json db.forceRls declares definer-owned, without \`alter table ${qualified} force row level security\` in the same migration`, { line: table.line, table: qualified }));
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
289
|
findings.push(...rlsWeakeningFindings(file, facts.rlsEvents, exposed));
|