@starci/hfs 4.9.11 → 4.10.1
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 +18 -0
- package/README.md +7 -9
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +1 -1
- package/db/dump.mjs +11 -10
- package/db/entity-list.mjs +1 -1
- package/db/migrate.mjs +83 -259
- 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/package.json +1 -1
- package/release/gate.mjs +3 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +44 -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 +179 -157
- package/runtime/knowledge/hfs/slots.yaml +185 -172
- 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 +65 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/entity-registry.mjs +53 -0
- 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/owners.mjs +11 -0
- 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/tiers.mjs +14 -3
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +6 -6
- 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 +9 -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 +19 -20
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +2 -2
- package/sync/index.mjs +32 -8
- 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/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-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 +6 -6
- 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 +7 -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/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
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// identity.mjs - the back-end identity machine. Under lite the one token verifier is be.integrations.passport: jose's jwtVerify
|
|
2
|
+
// there checks the app's OWN key set (createLocalJWKSet over the keys it signs with, never createRemoteJWKSet), pins issuer,
|
|
3
|
+
// audience and algorithms, and no file decodes a token unverified or reads authorization claims from a request body (R329).
|
|
4
|
+
// In both editions every jwtVerify call holds the JWT policy (R260, jwtPolicyProblems).
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import { machineKit } from './machine-ast.mjs';
|
|
5
|
-
import { importedFrom, reportAt, unwrap } from './
|
|
7
|
+
import { importedFrom, jwtPolicyProblems, reportAt, unwrap } from './jwt-ast.mjs';
|
|
6
8
|
import { accessName } from '../../lib/ts-ast.mjs';
|
|
7
9
|
|
|
8
|
-
const
|
|
9
|
-
const BE_JWT_VERIFIED = '
|
|
10
|
+
const PASSPORT_SLOT = 'be.integrations.passport';
|
|
11
|
+
const BE_JWT_VERIFIED = 'BE_AUTH_JWT_OWN_JWKS';
|
|
12
|
+
|
|
13
|
+
/** The rule ids the back-end identity machine reports. */
|
|
14
|
+
export const IDENTITY_RULE_IDS = Object.freeze(['BE_AUTH_JWT_OWN_JWKS', 'BE_JWT_ALG_PINNED']);
|
|
10
15
|
const JWT_MODULES = new Set(['jose', 'jsonwebtoken', 'jwt-decode']);
|
|
11
16
|
const CLAIM_NAMES = new Set(['role', 'roles', 'membership', 'memberships']);
|
|
12
17
|
|
|
@@ -33,7 +38,7 @@ const staticStrings = (kit, checker, node) => {
|
|
|
33
38
|
|
|
34
39
|
const jwksArgument = (kit, checker, node) => {
|
|
35
40
|
const current = unwrap(kit.ts, node);
|
|
36
|
-
if (kit.ts.isCallExpression(current)) return importedFrom(kit, checker, current.expression, new Set(['jose']), new Set(['
|
|
41
|
+
if (kit.ts.isCallExpression(current)) return importedFrom(kit, checker, current.expression, new Set(['jose']), new Set(['createLocalJWKSet']));
|
|
37
42
|
if (!kit.ts.isIdentifier(current)) return false;
|
|
38
43
|
return kit.declarationsOf(checker, current).some((declaration) => kit.ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
39
44
|
&& jwksArgument(kit, checker, declaration.initializer));
|
|
@@ -69,8 +74,8 @@ const fromRequestBody = (kit, checker, node, seen = new Set()) => {
|
|
|
69
74
|
|
|
70
75
|
function verifyJwtCall(kit, checker, file, node, counters, violations) {
|
|
71
76
|
counters.verifyCalls += 1;
|
|
72
|
-
if (file.slot !==
|
|
73
|
-
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `jwtVerify is called outside ${
|
|
77
|
+
if (file.slot !== PASSPORT_SLOT) {
|
|
78
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `jwtVerify is called outside ${PASSPORT_SLOT}; the integration is the one token verifier.`);
|
|
74
79
|
return true;
|
|
75
80
|
}
|
|
76
81
|
const options = objectLiteral(kit, checker, node.arguments[2]);
|
|
@@ -82,10 +87,10 @@ function verifyJwtCall(kit, checker, file, node, counters, violations) {
|
|
|
82
87
|
const issuer = property('issuer');
|
|
83
88
|
const audience = values('audience');
|
|
84
89
|
const algorithms = values('algorithms');
|
|
85
|
-
const good = jwksArgument(kit, checker, node.arguments[1]) && issuer != null && audience
|
|
90
|
+
const good = jwksArgument(kit, checker, node.arguments[1]) && issuer != null && audience !== null && audience.length > 0
|
|
86
91
|
&& algorithms !== null && algorithms.length > 0;
|
|
87
92
|
if (!good) {
|
|
88
|
-
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, '
|
|
93
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, 'jwtVerify must check the app\'s own key set (createLocalJWKSet(...) over the keys integrations/passport signs with) and pin issuer, audience and a non-empty algorithms list; jwtVerify then enforces signature and exp.');
|
|
89
94
|
} else counters.verifiedCalls += 1;
|
|
90
95
|
return false;
|
|
91
96
|
}
|
|
@@ -93,7 +98,7 @@ function verifyJwtCall(kit, checker, file, node, counters, violations) {
|
|
|
93
98
|
function inspectJwtCall(kit, checker, file, node, counters, violations) {
|
|
94
99
|
const binding = importedCall(kit, checker, node);
|
|
95
100
|
if (binding && JWT_MODULES.has(binding.module) && ['decode', 'decodeJwt', 'jwtDecode'].includes(binding.name)) {
|
|
96
|
-
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${binding.module}.${binding.name} decodes a token without proving its signature;
|
|
101
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${binding.module}.${binding.name} decodes a token without proving its signature; verify it through integrations/passport.`, { module: binding.module, method: binding.name });
|
|
97
102
|
}
|
|
98
103
|
if (binding?.module === 'jose' && binding.name === 'jwtVerify') return verifyJwtCall(kit, checker, file, node, counters, violations);
|
|
99
104
|
return false;
|
|
@@ -118,10 +123,10 @@ function inspectBackendNode(kit, checker, file, node, counters, violations) {
|
|
|
118
123
|
if (!handled) inspectRequestClaims(kit, file, node, checker, violations);
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
|
|
126
|
+
function checkOwnJwks(input) {
|
|
122
127
|
const kit = machineKit(input);
|
|
123
128
|
const violations = [];
|
|
124
|
-
const integrations = [...input.graph.files.values()].filter((file) => file.slot ===
|
|
129
|
+
const integrations = [...input.graph.files.values()].filter((file) => file.slot === PASSPORT_SLOT);
|
|
125
130
|
if (!integrations.length) return { violations, coverage: { status: 'not-applicable', integrationFiles: 0, verifiedCalls: 0 } };
|
|
126
131
|
const counters = { verifyCalls: 0, verifiedCalls: 0 };
|
|
127
132
|
for (const file of input.graph.files.values()) {
|
|
@@ -129,8 +134,33 @@ export function checkBackendJwt(input) {
|
|
|
129
134
|
kit.walk(file.sourceFile, node => inspectBackendNode(kit, checker, file, node, counters, violations));
|
|
130
135
|
}
|
|
131
136
|
if (counters.verifyCalls === 0) {
|
|
132
|
-
const anchor = integrations.find((file) => path.posix.basename(file.rel).endsWith('.
|
|
133
|
-
reportAt(violations, kit, BE_JWT_VERIFIED, anchor, anchor.sourceFile, `${
|
|
137
|
+
const anchor = integrations.find((file) => path.posix.basename(file.rel).endsWith('.tokens.ts')) ?? integrations[0];
|
|
138
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, anchor, anchor.sourceFile, `${PASSPORT_SLOT} has no jose.jwtVerify call backed by createLocalJWKSet over the app's own keys.`);
|
|
134
139
|
}
|
|
135
140
|
return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls: counters.verifyCalls, verifiedCalls: counters.verifiedCalls } };
|
|
136
141
|
}
|
|
142
|
+
|
|
143
|
+
/** R260 BE_JWT_ALG_PINNED: every jose jwtVerify call pins a literal secure algorithm list, a configured issuer and the authenticated role. */
|
|
144
|
+
export function checkJwtPolicy(input) {
|
|
145
|
+
const kit = machineKit(input);
|
|
146
|
+
const violations = [];
|
|
147
|
+
let verifyCalls = 0;
|
|
148
|
+
for (const file of input.graph.files.values()) {
|
|
149
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
150
|
+
kit.walk(file.sourceFile, node => {
|
|
151
|
+
if (!kit.ts.isCallExpression(node) || !kit.isImportOf(checker, node.expression, 'jwtVerify', 'jose')) return;
|
|
152
|
+
verifyCalls += 1;
|
|
153
|
+
const problems = jwtPolicyProblems(kit.ts, checker, node);
|
|
154
|
+
if (problems.length) reportAt(violations, kit, 'BE_JWT_ALG_PINNED', file, node, problems.join(' '));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { violations, coverage: { status: 'checked', verifyCalls } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The back-end identity machine: the own-JWKS law where the passport integration exists (lite), the JWT policy everywhere. */
|
|
161
|
+
export function checkBackendIdentity(input) {
|
|
162
|
+
const slot = input.graph.resolver.slot(PASSPORT_SLOT);
|
|
163
|
+
const own = slot && input.graph.resolver.slotEnabled(slot) ? checkOwnJwks(input) : { violations: [], coverage: { status: 'not-applicable', reason: 'no passport integration slot (full edition)' } };
|
|
164
|
+
const policy = checkJwtPolicy(input);
|
|
165
|
+
return { violations: [...own.violations, ...policy.violations], coverage: { status: 'checked', ownJwks: own.coverage, jwtPolicy: policy.coverage } };
|
|
166
|
+
}
|
|
@@ -48,7 +48,7 @@ import { checkPackageShape, PACKAGE_SHAPE_RULE_IDS } from './package-shape.mjs';
|
|
|
48
48
|
import { checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS } from './fe-slot-allows.mjs';
|
|
49
49
|
import { checkI18nKeys, I18N_KEYS_RULE_IDS } from './i18n-keys.mjs';
|
|
50
50
|
import { checkDocLanguage, DOC_LANGUAGE_RULE_IDS } from './doc-language.mjs';
|
|
51
|
-
import {
|
|
51
|
+
import { checkBackendIdentity, IDENTITY_RULE_IDS } from './identity.mjs';
|
|
52
52
|
import { checkServerActions, SERVER_ACTION_RULE_IDS } from './server-action.mjs';
|
|
53
53
|
import { LINT_CODES, onLintSurface } from './surface.mjs';
|
|
54
54
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
@@ -86,7 +86,7 @@ const BACKEND_MACHINE = {
|
|
|
86
86
|
backgroundUnowned: [checkBackgroundUnowned, BACKGROUND_UNOWNED_RULE_IDS],
|
|
87
87
|
testWorldFiles: [checkTestWorldFiles, TEST_WORLD_FILES_RULE_IDS],
|
|
88
88
|
contractFixtureGuard: [checkContractFixtureGuard, CONTRACT_FIXTURE_GUARD_RULE_IDS],
|
|
89
|
-
|
|
89
|
+
identity: [checkBackendIdentity, IDENTITY_RULE_IDS],
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
// The frontend repository machine (R21, R50, R54, R55, R56, R63, R94, R106): same shape, run for a front-end repository only.
|
|
@@ -99,7 +99,6 @@ const FRONTEND_MACHINE = {
|
|
|
99
99
|
crossAppDuplicate: [checkCrossAppDuplicate, CROSS_APP_DUPLICATE_RULE_IDS],
|
|
100
100
|
feSlotAllows: [checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS],
|
|
101
101
|
i18nKeys: [checkI18nKeys, I18N_KEYS_RULE_IDS],
|
|
102
|
-
supabaseFrontend: [checkFrontendSupabase, FRONTEND_SUPABASE_RULE_IDS],
|
|
103
102
|
serverActions: [checkServerActions, SERVER_ACTION_RULE_IDS],
|
|
104
103
|
};
|
|
105
104
|
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// jwt-ast.mjs - shared TypeScript-symbol and expression helpers of the identity checks (token verification and the principal).
|
|
2
|
+
// Recognition follows imported declarations and inferred types, never local spelling alone.
|
|
3
3
|
import { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
4
4
|
import { accessName as memberName, hasAnyFlag } from '../../lib/ts-ast.mjs';
|
|
5
5
|
export { accessName as memberName } from '../../lib/ts-ast.mjs';
|
|
6
6
|
|
|
7
|
-
export const SUPABASE_MODULES = Object.freeze({ has: name => typeof name === 'string' && name.startsWith('@supabase/') });
|
|
8
|
-
|
|
9
7
|
export const moduleNameOf = (ts, node) => {
|
|
10
8
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
11
9
|
if (!ts.isCallExpression(node)) return null;
|
|
@@ -19,39 +17,8 @@ export const importedFrom = (kit, checker, node, modules, names = null) => {
|
|
|
19
17
|
return Boolean(binding && modules.has(binding.module) && (names === null || names.has(binding.name)));
|
|
20
18
|
};
|
|
21
19
|
|
|
22
|
-
const declarationFromSupabase = (declaration) => /(?:^|\/)node_modules\/@supabase\/[^/]+(?:\/|$)/u
|
|
23
|
-
.test(String(declaration?.getSourceFile?.().fileName ?? '').replaceAll('\\', '/'));
|
|
24
|
-
|
|
25
|
-
const typeFromSupabase = (checker, node) => {
|
|
26
|
-
let type;
|
|
27
|
-
try { type = checker.getTypeAtLocation(node); } catch { return false; }
|
|
28
|
-
const queue = [type];
|
|
29
|
-
const seen = new Set();
|
|
30
|
-
while (queue.length) {
|
|
31
|
-
const item = queue.shift();
|
|
32
|
-
if (!item || seen.has(item)) continue;
|
|
33
|
-
seen.add(item);
|
|
34
|
-
if ([item.symbol, item.aliasSymbol].some((symbol) => symbol?.declarations?.some(declarationFromSupabase))) return true;
|
|
35
|
-
if (item.types) queue.push(...item.types);
|
|
36
|
-
if (item.target && item.target !== item) queue.push(item.target);
|
|
37
|
-
}
|
|
38
|
-
return false;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const signatureFromSupabase = (checker, call) => {
|
|
42
|
-
try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
|
|
43
|
-
};
|
|
44
|
-
|
|
45
20
|
export const unwrap = (ts, node) => unwrapExpression(ts, node, { awaitExpressions: true, satisfiesExpressions: false });
|
|
46
21
|
|
|
47
|
-
const isSupabaseValue = (kit, checker, node) => {
|
|
48
|
-
const current = unwrap(kit.ts, node);
|
|
49
|
-
if (!current) return false;
|
|
50
|
-
if (importedFrom(kit, checker, current, SUPABASE_MODULES)) return true;
|
|
51
|
-
if (kit.ts.isCallExpression(current) && signatureFromSupabase(checker, current)) return true;
|
|
52
|
-
return typeFromSupabase(checker, current);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
22
|
export const callName = (ts, call) => ts.isCallExpression(call) ? memberName(ts, call.expression) : null;
|
|
56
23
|
|
|
57
24
|
export const chainParts = (ts, expression) => {
|
|
@@ -73,74 +40,6 @@ export const chainParts = (ts, expression) => {
|
|
|
73
40
|
return names;
|
|
74
41
|
};
|
|
75
42
|
|
|
76
|
-
export const isSupabaseCall = (kit, checker, node) => kit.ts.isCallExpression(node)
|
|
77
|
-
&& (signatureFromSupabase(checker, node) || isSupabaseValue(kit, checker,
|
|
78
|
-
kit.ts.isPropertyAccessExpression(node.expression) || kit.ts.isElementAccessExpression(node.expression) ? node.expression.expression : node.expression));
|
|
79
|
-
|
|
80
|
-
const authStorageValue = (kit, checker, expression, seen = new Set()) => {
|
|
81
|
-
const current = unwrap(kit.ts, expression);
|
|
82
|
-
if (!current || seen.has(current)) return false;
|
|
83
|
-
seen.add(current);
|
|
84
|
-
if (['auth', 'storage'].includes(memberName(kit.ts, current)) && isSupabaseValue(kit, checker, current.expression)) return true;
|
|
85
|
-
if (checker.getTypeAtLocation(current).symbol?.declarations?.some(declaration =>
|
|
86
|
-
/\/node_modules\/@supabase\/(?:storage-js|auth-js)\//u.test(String(declaration.getSourceFile().fileName).replaceAll('\\', '/')))) return true;
|
|
87
|
-
if (!kit.ts.isIdentifier(current)) return false;
|
|
88
|
-
return kit.declarationsOf(checker, current).some(declaration =>
|
|
89
|
-
(kit.ts.isVariableDeclaration(declaration) || kit.ts.isPropertyDeclaration(declaration)) && declaration.initializer
|
|
90
|
-
&& authStorageValue(kit, checker, declaration.initializer, seen));
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const chainAccesses = (ts, node) => {
|
|
94
|
-
const accesses = [];
|
|
95
|
-
let current = unwrap(ts, node);
|
|
96
|
-
while (current) {
|
|
97
|
-
if (ts.isCallExpression(current)) current = unwrap(ts, current.expression);
|
|
98
|
-
else if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
99
|
-
accesses.unshift(current);
|
|
100
|
-
current = unwrap(ts, current.expression);
|
|
101
|
-
} else break;
|
|
102
|
-
}
|
|
103
|
-
return accesses;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
/** The first forbidden SDK access in a call chain, anchored once even when more methods follow it. */
|
|
107
|
-
export const supabaseDataAccess = (kit, checker, node, members, { storageAllowed = false } = {}) => {
|
|
108
|
-
for (const access of chainAccesses(kit.ts, node)) {
|
|
109
|
-
const name = memberName(kit.ts, access);
|
|
110
|
-
if (storageAllowed && (name === 'storage' || name === 'auth')) return null;
|
|
111
|
-
if (members.has(name) && isSupabaseValue(kit, checker, access.expression)) {
|
|
112
|
-
if (storageAllowed && authStorageValue(kit, checker, access.expression)) return null;
|
|
113
|
-
return { name, node: access };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return null;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
export const isDatabaseType = (ts, node) => {
|
|
120
|
-
if (!node) return false;
|
|
121
|
-
if (ts.isTypeReferenceNode(node)) {
|
|
122
|
-
const name = ts.isIdentifier(node.typeName) ? node.typeName.text : node.typeName.right.text;
|
|
123
|
-
return name === 'Database';
|
|
124
|
-
}
|
|
125
|
-
return false;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
export const supabaseClientType = (kit, checker, node) => {
|
|
129
|
-
if (!node || !kit.ts.isTypeReferenceNode(node)) return false;
|
|
130
|
-
const name = kit.ts.isIdentifier(node.typeName) ? node.typeName : null;
|
|
131
|
-
return Boolean(name && importedFrom(kit, checker, name, SUPABASE_MODULES, new Set(['SupabaseClient'])));
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
export const contextTypesClient = (kit, checker, call) => {
|
|
135
|
-
let parent = call.parent;
|
|
136
|
-
while (parent && (kit.ts.isParenthesizedExpression(parent) || kit.ts.isAsExpression(parent))) {
|
|
137
|
-
if (kit.ts.isAsExpression(parent) && supabaseClientType(kit, checker, parent.type) && isDatabaseType(kit.ts, parent.type.typeArguments?.[0])) return true;
|
|
138
|
-
parent = parent.parent;
|
|
139
|
-
}
|
|
140
|
-
return kit.ts.isVariableDeclaration(parent) && supabaseClientType(kit, checker, parent.type)
|
|
141
|
-
&& isDatabaseType(kit.ts, parent.type.typeArguments?.[0]);
|
|
142
|
-
};
|
|
143
|
-
|
|
144
43
|
export const reportAt = (violations, kit, ruleId, file, node, message, extra = {}) => {
|
|
145
44
|
violations.push({ ruleId, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
146
45
|
};
|
|
@@ -4,6 +4,7 @@ import { relativePath, workspaceExportSources } from './typescript.mjs';
|
|
|
4
4
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
5
5
|
import { globExpression } from '../../lib/glob.mjs';
|
|
6
6
|
import { posixPath } from '../../lib/path-key.mjs';
|
|
7
|
+
import { registryImporter, registrySlot } from './entity-registry.mjs';
|
|
7
8
|
|
|
8
9
|
function absolute(root, relative) {
|
|
9
10
|
return canonical(path.resolve(root, ...relative.split('/')));
|
|
@@ -68,6 +69,15 @@ function arrangesSchema(config, from, to) {
|
|
|
68
69
|
&& resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The entity registry (slot role entity-registry, entity-registry.mjs) is imported by deep path, and only from the files its slot's
|
|
74
|
+
* `importedBy` names: app composition and the test world. Any other importer goes through the owner's entry, which never exports it.
|
|
75
|
+
*/
|
|
76
|
+
function importsRegistry(config, from, to) {
|
|
77
|
+
const slot = registrySlot(config.hfs, relativePath(config.root, to));
|
|
78
|
+
return slot !== null && registryImporter(config.hfs, slot, relativePath(config.root, from));
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
function publicEntryViolations(config, context, owners, sourceFiles) {
|
|
72
82
|
const violations = [];
|
|
73
83
|
for (const owner of owners) {
|
|
@@ -104,6 +114,7 @@ function ownerImportViolations(config, context, owners) {
|
|
|
104
114
|
const bypass = privateOwnerChain(context, owners, edge);
|
|
105
115
|
if (!bypass) continue;
|
|
106
116
|
if (arrangesSchema(config, from, bypass.chain.at(-1))) continue;
|
|
117
|
+
if (bypass.chain.length === 2 && importsRegistry(config, from, bypass.chain.at(-1))) continue;
|
|
107
118
|
violations.push({
|
|
108
119
|
ruleId: 'ARCH_OWNER_EXPORT_BYPASS',
|
|
109
120
|
path: relativePath(config.root, from),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { isComposeGroup, machineKit } from './machine-ast.mjs';
|
|
3
|
-
import { callName } from './
|
|
3
|
+
import { callName } from './jwt-ast.mjs';
|
|
4
4
|
import { isTestWorldSlot } from '../test-world-slot.mjs';
|
|
5
5
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
6
6
|
|
|
@@ -20,6 +20,11 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
20
20
|
* (`<c>` the camelCase capability name), and no other name coming from `persistence/`;
|
|
21
21
|
* - a migration file is `<epochMs13>-<kebab-name>.ts`, holds a class `<Pascal><epochMs13>` and a `name` property equal to
|
|
22
22
|
* that class name, and has no unit spec beside it.
|
|
23
|
+
*
|
|
24
|
+
* Lite (persistence.md BE-PERSISTENCE-3, ruling lite-persistence-typeorm-entities) has entities and no be migration: the
|
|
25
|
+
* capability's `persistence/entities/index.ts` declares `<c>Entities`, its `index.ts` exports that one persistence name, and
|
|
26
|
+
* `platform/database/entities.ts` names it in `PRIMARY_ENTITIES`, the one list the app composition registers; there is a
|
|
27
|
+
* single primary connection, so the per-connection checks do not apply.
|
|
23
28
|
*/
|
|
24
29
|
export const SCHEMA_OWNER_RULE_IDS = ['BE_SCHEMA_OWNER'];
|
|
25
30
|
|
|
@@ -27,6 +32,8 @@ const RULE = 'BE_SCHEMA_OWNER';
|
|
|
27
32
|
const PERSISTENCE_SLOT = 'be.persistence';
|
|
28
33
|
const SCHEMA_FOLDERS = new Set(['entities', 'migrations']);
|
|
29
34
|
const MIGRATION_FILE = /^(\d{13})-([a-z0-9]+(?:-[a-z0-9]+)*)\.ts$/u;
|
|
35
|
+
/** The lite entity list every capability's `<c>Entities` is named in (BE-PERSISTENCE-3). */
|
|
36
|
+
const LITE_ENTITY_LIST = `${DATABASE_DIR}/entities.ts`;
|
|
30
37
|
const camel = name => { const text = pascal(name); return text[0].toLowerCase() + text.slice(1); };
|
|
31
38
|
|
|
32
39
|
function noteSchemaOwner(found, persistence, kind) {
|
|
@@ -127,6 +134,22 @@ function reportRegistrationSites(registered, connections, resolver, report) {
|
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
/** The identifiers a file of the graph declares or names, for the lite list checks. */
|
|
138
|
+
function identifiersOf(ts, file) {
|
|
139
|
+
const names = new Set();
|
|
140
|
+
const visit = node => { if (ts.isIdentifier(node)) names.add(node.text); ts.forEachChild(node, visit); };
|
|
141
|
+
if (file) visit(file.sourceFile);
|
|
142
|
+
return names;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function reportLiteEntityArrays(key, kinds, graph, ts, plain) {
|
|
146
|
+
const wanted = `${camel(kinds.name)}Entities`;
|
|
147
|
+
const listFile = `${key}/persistence/entities/index.ts`;
|
|
148
|
+
const declared = new Set((graph.files.get(listFile)?.sourceFile.statements.filter(ts.isVariableStatement).flatMap(statement => statement.declarationList.declarations) ?? []).map(item => item.name.text));
|
|
149
|
+
if (!declared.has(wanted)) plain(listFile, `${listFile} must export ${wanted}, the entities of the ${kinds.name} capability, the one file that lists them.`, { capability: kinds.name });
|
|
150
|
+
if (!identifiersOf(ts, graph.files.get(LITE_ENTITY_LIST)).has(wanted)) plain(LITE_ENTITY_LIST, `The ${kinds.name} capability has entities but ${LITE_ENTITY_LIST} does not name ${wanted} in PRIMARY_ENTITIES; list it there by name, the one list the app composition registers.`, { capability: kinds.name });
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
function reportDeclaredPersistenceArrays(key, kinds, registered, graph, ts, plain) {
|
|
131
154
|
if (!registered.get(key)?.connections.size) plain(`${key}/persistence/connection.ts`, `The ${kinds.name} capability has entities or migrations but no app registers them; add ${camel(kinds.name)}Entities and ${camel(kinds.name)}Migrations to a connection of DatabaseModule.register and of the cli app.`, { capability: kinds.name });
|
|
132
155
|
const connectionFile = graph.files.get(`${key}/persistence/connection.ts`);
|
|
@@ -160,6 +183,7 @@ function reportFoundCapability(input, kit, key, kinds, view) {
|
|
|
160
183
|
const { registered, connections, plain, report } = view;
|
|
161
184
|
const { graph } = input;
|
|
162
185
|
const { resolver } = kit;
|
|
186
|
+
if (view.lite) { reportLiteEntityArrays(key, kinds, graph, kit.ts, plain); return; }
|
|
163
187
|
reportDeclaredPersistenceArrays(key, kinds, registered, graph, kit.ts, plain);
|
|
164
188
|
const only = registered.get(key)?.connections;
|
|
165
189
|
if (!only || only.size < 1) return;
|
|
@@ -170,7 +194,7 @@ function reportFoundCapability(input, kit, key, kinds, view) {
|
|
|
170
194
|
}
|
|
171
195
|
|
|
172
196
|
function reportFoundCapabilities(input, kit, found, registered, connections, plain, report) {
|
|
173
|
-
const view = { registered, connections, plain, report };
|
|
197
|
+
const view = { registered, connections, plain, report, lite: kit.resolver.repo?.edition === 'lite' };
|
|
174
198
|
for (const [key, kinds] of found) reportFoundCapability(input, kit, key, kinds, view);
|
|
175
199
|
}
|
|
176
200
|
|
|
@@ -178,7 +202,7 @@ function reportPersistenceExport(capabilityRoot, index, statement, element, want
|
|
|
178
202
|
const from = statement.moduleSpecifier?.text;
|
|
179
203
|
const inPersistence = from?.startsWith('.') && path.posix.join(capabilityRoot, from).split('/').includes('persistence');
|
|
180
204
|
if (inPersistence && !Object.values(wanted).includes(element.name.text)) {
|
|
181
|
-
report(index, element, `${element.name.text} is exported from persistence/; the persistence of a capability is exposed only as ${wanted.
|
|
205
|
+
report(index, element, `${element.name.text} is exported from persistence/; the persistence of a capability is exposed only as ${Object.values(wanted).join(' and ')}.`, { name: element.name.text });
|
|
182
206
|
}
|
|
183
207
|
}
|
|
184
208
|
|
|
@@ -204,10 +228,11 @@ function reportCapabilityIndex(input, kit, capabilityRoot, kinds, report) {
|
|
|
204
228
|
const { graph } = input;
|
|
205
229
|
const index = graph.files.get(`${capabilityRoot}/index.ts`);
|
|
206
230
|
if (!index) return;
|
|
207
|
-
const
|
|
231
|
+
const lite = kit.resolver.repo?.edition === 'lite';
|
|
232
|
+
const wanted = lite ? { Entities: `${camel(kinds.name)}Entities` } : { Entities: `${camel(kinds.name)}Entities`, Migrations: `${camel(kinds.name)}Migrations` };
|
|
208
233
|
const exported = collectIndexExports(kit, capabilityRoot, index, wanted, report);
|
|
209
234
|
for (const [kind, has] of [['Entities', kinds.entities], ['Migrations', kinds.migrations]]) {
|
|
210
|
-
if (has && !exported.has(wanted[kind])) report(index, index.sourceFile, `${capabilityRoot}/index.ts must export ${wanted[kind]}, the ${kind.toLowerCase()} of the capability; apps concatenate them per connection into DatabaseModule.register.`, { capability: kinds.name, expected: wanted[kind] });
|
|
235
|
+
if (has && wanted[kind] && !exported.has(wanted[kind])) report(index, index.sourceFile, `${capabilityRoot}/index.ts must export ${wanted[kind]}, the ${kind.toLowerCase()} of the capability; apps concatenate them per connection into DatabaseModule.register.`, { capability: kinds.name, expected: wanted[kind] });
|
|
211
236
|
}
|
|
212
237
|
}
|
|
213
238
|
|
|
@@ -7,7 +7,7 @@ import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
|
7
7
|
* - FE_SERVER_ACTION_FORBIDDEN: a `"use server"` directive anywhere in a front-end source file, in the file's directive prologue
|
|
8
8
|
* (a Server Action module) or in the prologue of any function body (an inline Server Action), apps and packages alike;
|
|
9
9
|
* - FE_ROUTE_HANDLER_FORBIDDEN: a route handler (`route.ts`, `route.js` and their module variants) under `apps/<app>/src/app/` other than the
|
|
10
|
-
* liveness probe `health/live/route.ts
|
|
10
|
+
* liveness probe `health/live/route.ts`.
|
|
11
11
|
*
|
|
12
12
|
* The check is provider-independent: it runs for every front-end repository.
|
|
13
13
|
*/
|
|
@@ -15,7 +15,6 @@ export const SERVER_ACTION_RULE_IDS = Object.freeze(['FE_SERVER_ACTION_FORBIDDEN
|
|
|
15
15
|
|
|
16
16
|
const SERVER_ACTION = 'FE_SERVER_ACTION_FORBIDDEN';
|
|
17
17
|
const ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
|
|
18
|
-
const CALLBACK_SLOT = 'fe.route.callback';
|
|
19
18
|
const ROUTE_FILE = /^apps\/([^/]+)\/src\/app\/(?:.+\/)?route\.[cm]?[jt]s$/u;
|
|
20
19
|
|
|
21
20
|
/** The statement nodes of a directive prologue: the leading string-literal expression statements. */
|
|
@@ -57,10 +56,10 @@ function serverActionViolations(ts, file) {
|
|
|
57
56
|
function routeHandlerViolation(file) {
|
|
58
57
|
const match = ROUTE_FILE.exec(file.rel);
|
|
59
58
|
if (!match) return null;
|
|
60
|
-
if (file.rel === `apps/${match[1]}/src/app/health/live/route.ts`
|
|
59
|
+
if (file.rel === `apps/${match[1]}/src/app/health/live/route.ts`) return null;
|
|
61
60
|
return {
|
|
62
61
|
ruleId: ROUTE_HANDLER, path: file.rel, line: 1, column: 1,
|
|
63
|
-
message: `${file.rel} is a front-end route handler outside health/live/route.ts
|
|
62
|
+
message: `${file.rel} is a front-end route handler outside health/live/route.ts: the front end serves no API. Webhooks, cron, public APIs and third-party callbacks belong to be features; the browser reaches the back end through modules/api.`,
|
|
64
63
|
};
|
|
65
64
|
}
|
|
66
65
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// side-boundary.mjs - what counts as an import that leaves a side of an app (ARCH_INTERNAL_IMPORT_OUTSIDE).
|
|
2
2
|
// A side (be/ or fe/) imports nothing of the app outside itself: the root holds no source and the other side is another program; a
|
|
3
3
|
// declared read of the OTHER side (be/contracts/) is codegen input, never an import. A declared read of a tree the APP ROOT owns
|
|
4
|
-
// (
|
|
4
|
+
// (db/types/, sides.<side>.reads of the slot manifest) is the one thing both sides do import: the generated database types.
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { isInside, slash } from './config.mjs';
|
|
7
7
|
|
|
@@ -248,7 +248,7 @@ function activeMigrations(root) {
|
|
|
248
248
|
* Public-table key sets proven by ordered, tracked active migrations. Direct event triggers taint later DDL proof.
|
|
249
249
|
* Opaque bodies discard current facts; persistent schema machinery hidden inside them is outside static authority.
|
|
250
250
|
*/
|
|
251
|
-
export function
|
|
251
|
+
export function sqlUniqueSetsOf(appRoot) {
|
|
252
252
|
const root = path.resolve(appRoot);
|
|
253
253
|
const files = activeMigrations(root);
|
|
254
254
|
const state = { tables: new Map(), safe: new Set(), schema: 'public', tainted: false };
|
|
@@ -2,8 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { analyzeSql, HOLE } from './sql-tokens.mjs';
|
|
3
3
|
import { machineKit } from './machine-ast.mjs';
|
|
4
4
|
import { contextModelOf } from './context-map.mjs';
|
|
5
|
-
import {
|
|
6
|
-
import { supabaseUniqueSetsOf } from './supabase-keys.mjs';
|
|
5
|
+
import { sqlUniqueSetsOf } from './sql-keys.mjs';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* R86 `sql-owner` (BE_SQL_TABLE_OWNER). Every `sql` tagged template (the tag declared in platform/database) of a
|
|
@@ -14,7 +13,7 @@ import { supabaseUniqueSetsOf } from './supabase-keys.mjs';
|
|
|
14
13
|
* capability's, or those of a same-context capability its owner may import (the tier matrix, through `resolver.importAllowed`
|
|
15
14
|
* to the table owner's public entry); a table of another context is refused (cross-context data comes only by events);
|
|
16
15
|
* - every table it names is declared by some entity under TypeORM authority, or by the generated
|
|
17
|
-
*
|
|
16
|
+
* public tables the ordered db/migrations create under the SQL migration authority;
|
|
18
17
|
* - a SELECT carries LIMIT, or reads a single relation under a complete primary/unique-key equality to scalar values,
|
|
19
18
|
* or selects only proven scalar aggregates without GROUP BY. Inferred bounds reject multiplying projections and set operations.
|
|
20
19
|
* Table positions that hold a `${...}` substitution (SqlIdent) are dynamic; they are counted in the coverage and judged by
|
|
@@ -130,11 +129,10 @@ export function entitiesOf(kit, graph) {
|
|
|
130
129
|
|
|
131
130
|
const capabilityTable = root => path.posix.basename(root).replaceAll('-', '_').toLowerCase();
|
|
132
131
|
|
|
133
|
-
/**
|
|
134
|
-
function
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const tables = new Map([...generated].map(table => [table, { table, owner: null, rel: null, uniqueSets: keys.get(table) ?? [] }]));
|
|
132
|
+
/** SQL-authority declarations (the public tables db/migrations create), with an owner when a table name matches a capability's persistence folder. */
|
|
133
|
+
function sqlDeclarations(graph, appRoot) {
|
|
134
|
+
const keys = sqlUniqueSetsOf(appRoot);
|
|
135
|
+
const tables = new Map([...keys.keys()].map(table => [table, { table, owner: null, rel: null, uniqueSets: keys.get(table) ?? [] }]));
|
|
138
136
|
for (const file of graph.files.values()) {
|
|
139
137
|
if (file.slot !== 'be.persistence' || !file.owner) continue;
|
|
140
138
|
const key = capabilityTable(file.owner.root);
|
|
@@ -150,16 +148,16 @@ const isOwnTable = (table, ownTable) => {
|
|
|
150
148
|
return name === ownTable || name.startsWith(`${ownTable}_`);
|
|
151
149
|
};
|
|
152
150
|
|
|
153
|
-
function reportSqlWrite({ tables,
|
|
151
|
+
function reportSqlWrite({ tables, sqlAuthority, file, ownTable, report }, write) {
|
|
154
152
|
const declaration = tables.get(write.table.toLowerCase());
|
|
155
153
|
if (!declaration) {
|
|
156
|
-
report(
|
|
157
|
-
? `SQL writes table ${write.table}, which
|
|
154
|
+
report(sqlAuthority
|
|
155
|
+
? `SQL writes table ${write.table}, which no db/migrations migration creates; add the table in a migration first.`
|
|
158
156
|
: `SQL writes table ${write.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: write.table });
|
|
159
|
-
} else if (
|
|
157
|
+
} else if (sqlAuthority) {
|
|
160
158
|
if (isOwnTable(write.table, ownTable) && (declaration.owner === null || declaration.owner === file.owner.root)) return;
|
|
161
159
|
const ownerNote = declaration.owner ? `, owned by ${declaration.owner}` : '';
|
|
162
|
-
report(`SQL in ${file.owner.root} writes table ${write.table}${ownerNote}; under
|
|
160
|
+
report(`SQL in ${file.owner.root} writes table ${write.table}${ownerNote}; under the SQL migration authority a capability writes only its own table (${ownTable}), named for that capability.`, { table: write.table, ...(declaration.owner ? { tableOwner: declaration.owner } : {}) });
|
|
163
161
|
} else if (declaration.owner !== file.owner.root) {
|
|
164
162
|
report(`SQL in ${file.owner.root} writes table ${write.table}, owned by ${declaration.owner}; a capability writes only the tables of its own entities, call the owner's public API instead.`, { table: write.table, tableOwner: declaration.owner });
|
|
165
163
|
}
|
|
@@ -169,11 +167,11 @@ function reportSqlWrites(input) {
|
|
|
169
167
|
for (const write of input.result.writes) reportSqlWrite(input, write);
|
|
170
168
|
}
|
|
171
169
|
|
|
172
|
-
function reportSqlRead(tables,
|
|
170
|
+
function reportSqlRead(tables, sqlAuthority, file, model, resolver, report, read) {
|
|
173
171
|
const declaration = tables.get(read.table.toLowerCase());
|
|
174
172
|
if (!declaration) {
|
|
175
|
-
report(
|
|
176
|
-
? `SQL reads table ${read.table}, which
|
|
173
|
+
report(sqlAuthority
|
|
174
|
+
? `SQL reads table ${read.table}, which no db/migrations migration creates; add the table in a migration first.`
|
|
177
175
|
: `SQL reads table ${read.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: read.table });
|
|
178
176
|
return;
|
|
179
177
|
}
|
|
@@ -188,8 +186,8 @@ function reportSqlRead(tables, supabase, file, model, resolver, report, read) {
|
|
|
188
186
|
if (!verdict.allowed) report(`SQL in ${file.owner.root} reads table ${read.table}, owned by ${declaration.owner}, which ${file.owner.root} may not import (${verdict.reason}); read it through the owner's public API.`, { table: read.table, tableOwner: declaration.owner, reason: verdict.reason });
|
|
189
187
|
}
|
|
190
188
|
|
|
191
|
-
function reportSqlReads({ result, tables,
|
|
192
|
-
for (const read of result.reads) reportSqlRead(tables,
|
|
189
|
+
function reportSqlReads({ result, tables, sqlAuthority, file, model, resolver, report }) {
|
|
190
|
+
for (const read of result.reads) reportSqlRead(tables, sqlAuthority, file, model, resolver, report, read);
|
|
193
191
|
}
|
|
194
192
|
|
|
195
193
|
function reportSqlSelects({ result, tables, report }) {
|
|
@@ -201,7 +199,7 @@ function reportSqlSelects({ result, tables, report }) {
|
|
|
201
199
|
}
|
|
202
200
|
|
|
203
201
|
function inspectSqlTag(input, state, file, checker, node) {
|
|
204
|
-
const { kit, ts, tables,
|
|
202
|
+
const { kit, ts, tables, sqlAuthority, model, resolver } = input;
|
|
205
203
|
if (!ts.isTaggedTemplateExpression(node)) return true;
|
|
206
204
|
const home = kit.declarationsOf(checker, node.tag).map(kit.ownerOfDeclaration).find(Boolean);
|
|
207
205
|
if (home?.tier !== 'platform' || home?.name !== 'database') { state.foreignTags += 1; return true; }
|
|
@@ -217,13 +215,13 @@ function inspectSqlTag(input, state, file, checker, node) {
|
|
|
217
215
|
state.violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
218
216
|
};
|
|
219
217
|
const result = analyzeSql(text, { uniqueSetsOf: (table, schema) => {
|
|
220
|
-
if (
|
|
218
|
+
if (sqlAuthority && ((schema && schema !== 'public') || table !== table.toLowerCase())) return null;
|
|
221
219
|
return tables.get(table.toLowerCase())?.uniqueSets ?? null;
|
|
222
220
|
} });
|
|
223
221
|
state.dynamic += result.dynamic;
|
|
224
222
|
const ownTable = capabilityTable(file.owner.root);
|
|
225
|
-
reportSqlWrites({ result, tables,
|
|
226
|
-
reportSqlReads({ result, tables,
|
|
223
|
+
reportSqlWrites({ result, tables, sqlAuthority, file, ownTable, report });
|
|
224
|
+
reportSqlReads({ result, tables, sqlAuthority, file, model, resolver, report });
|
|
227
225
|
reportSqlSelects({ result, tables, report });
|
|
228
226
|
return true;
|
|
229
227
|
}
|
|
@@ -239,13 +237,13 @@ export function checkSqlOwner(input) {
|
|
|
239
237
|
const { config, graph } = input;
|
|
240
238
|
const kit = machineKit(input);
|
|
241
239
|
const { ts, resolver } = kit;
|
|
242
|
-
const
|
|
243
|
-
const entityDeclarations =
|
|
244
|
-
const tables =
|
|
240
|
+
const sqlAuthority = resolver.ruleParams().schemaAuthority === 'sql';
|
|
241
|
+
const entityDeclarations = sqlAuthority ? { tables: new Map(), unreadable: 0 } : entitiesOf(kit, graph);
|
|
242
|
+
const tables = sqlAuthority ? sqlDeclarations(graph, config.packageRoot) : entityDeclarations.tables;
|
|
245
243
|
const model = contextModelOf(kit, graph);
|
|
246
244
|
const state = { violations: [], templates: 0, dynamic: 0, foreignTags: 0 };
|
|
247
|
-
const inspection = { kit, ts, tables,
|
|
245
|
+
const inspection = { kit, ts, tables, sqlAuthority, model, resolver };
|
|
248
246
|
for (const file of graph.files.values()) inspectSqlFile(inspection, state, file);
|
|
249
247
|
return { violations: state.violations, coverage: { status: 'checked', entities: entityDeclarations.tables.size, unreadableEntities: entityDeclarations.unreadable,
|
|
250
|
-
...(
|
|
248
|
+
...(sqlAuthority ? { sqlTables: tables.size } : {}), templates: state.templates, dynamicIdentifiers: state.dynamic, foreignTags: state.foreignTags } };
|
|
251
249
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
2
|
+
import { registryListImport, registrySlot } from './entity-registry.mjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
|
|
@@ -8,7 +9,9 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
8
9
|
* kind (slot field `trigger`: api, webhooks, realtime, saga, reactors, jobs, cli) importing a feature of another is
|
|
9
10
|
* BE_KIND_ISOLATION, whose fix is the event bus,
|
|
10
11
|
* an app never imports another app (BE_SERVICE_ISOLATION / FE_APP_ISOLATION), and a component layer imports only the layers after it;
|
|
11
|
-
*
|
|
12
|
+
* the entity registry naming a capability's `<c>Entities` from its public entry is the one platform-to-domain edge (entity-registry.mjs);
|
|
13
|
+
* 2. owner cycles: a strongly connected component of the owner graph, type-only imports included (ARCH_OWNER_CYCLE), the entity
|
|
14
|
+
* registry counted as a unit of its own,
|
|
12
15
|
* reported with the cycle path and the import that closes each hop.
|
|
13
16
|
* Importing inside one owner is always allowed; the public-entry rule stays with owners.mjs (ARCH_OWNER_EXPORT_BYPASS).
|
|
14
17
|
*/
|
|
@@ -138,15 +141,23 @@ function inspectTierEdge(graph, edge, tierRule, counts, violations, coverage) {
|
|
|
138
141
|
return;
|
|
139
142
|
}
|
|
140
143
|
coverage.edgesChecked += 1;
|
|
144
|
+
if (verdict.reason === 'tierDirection' && registryListImport(graph.resolver, edge)) return;
|
|
141
145
|
reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The cycle unit of a file: its owner unit, except the entity registry (entity-registry.mjs), which is a unit of its own. It sits in
|
|
150
|
+
* platform/database but is composition-level by role (it imports every capability's `<c>Entities`), so it never closes a cycle
|
|
151
|
+
* among capabilities through the owner it lives in.
|
|
152
|
+
*/
|
|
153
|
+
const cycleUnit = (graph, resolver, rel) => (registrySlot(resolver, rel) ? `entity-registry:${rel}` : graph.unit(rel));
|
|
154
|
+
|
|
144
155
|
function ownerAdjacency(graph, resolver) {
|
|
145
156
|
const adjacency = new Map();
|
|
146
157
|
const witness = new Map();
|
|
147
158
|
for (const edge of graph.edges) {
|
|
148
|
-
const a = graph
|
|
149
|
-
const b = graph
|
|
159
|
+
const a = cycleUnit(graph, resolver, edge.from);
|
|
160
|
+
const b = cycleUnit(graph, resolver, edge.to);
|
|
150
161
|
if (!a || !b || a === b) continue;
|
|
151
162
|
if (resolver.tierOf(edge.from) === 'none' || resolver.tierOf(edge.to) === 'none') continue;
|
|
152
163
|
if (!adjacency.has(a)) adjacency.set(a, new Set());
|