@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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { exportPKCS8, generateKeyPair } from "jose"
|
|
2
|
+
import { Secret } from "@modules/platform/config"
|
|
3
|
+
import { createTokenKit } from "./passport.tokens"
|
|
4
|
+
|
|
5
|
+
const options = async () => {
|
|
6
|
+
const { privateKey } = await generateKeyPair("ES256", { extractable: true })
|
|
7
|
+
return {
|
|
8
|
+
issuer: "https://api.example.test",
|
|
9
|
+
audience: "app",
|
|
10
|
+
privateKey: new Secret(await exportPKCS8(privateKey)),
|
|
11
|
+
keyId: "k1",
|
|
12
|
+
accessTtlSeconds: 60,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("createTokenKit", () => {
|
|
17
|
+
it("verifies its own token against its own key set and publishes only the public key", async () => {
|
|
18
|
+
const kit = await createTokenKit(await options())
|
|
19
|
+
const verified = await kit.verify(await kit.issue({ id: "user-1", email: "a@example.test" }))
|
|
20
|
+
|
|
21
|
+
expect(verified).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated", email: "a@example.test" } })
|
|
22
|
+
expect(kit.jwks.keys).toHaveLength(1)
|
|
23
|
+
expect(kit.jwks.keys[0]).not.toHaveProperty("d")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("refuses a token another key signed", async () => {
|
|
27
|
+
const kit = await createTokenKit(await options())
|
|
28
|
+
const other = await createTokenKit(await options())
|
|
29
|
+
|
|
30
|
+
expect((await kit.verify(await other.issue({ id: "user-1" }))).kind).toBe("refused")
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createLocalJWKSet, exportJWK, importPKCS8, jwtVerify, SignJWT } from "jose"
|
|
2
|
+
import type { JSONWebKeySet } from "jose"
|
|
3
|
+
import { ok, refused } from "@modules/platform/primitives"
|
|
4
|
+
import { PassportErrorCode } from "./errors/passport.error"
|
|
5
|
+
import type { IssueAccessToken, OwnPrincipal, TokenOptions, VerifyAccessToken } from "./passport.options"
|
|
6
|
+
|
|
7
|
+
const ALGORITHM = "ES256"
|
|
8
|
+
const ROLE = "authenticated"
|
|
9
|
+
|
|
10
|
+
/** The app's own token kit: the signer, the verifier over its own key set, and the public JWKS the API serves. */
|
|
11
|
+
export interface TokenKit {
|
|
12
|
+
/** Signs one access token. */
|
|
13
|
+
readonly issue: IssueAccessToken
|
|
14
|
+
/** Verifies one access token against the app's own keys. */
|
|
15
|
+
readonly verify: VerifyAccessToken
|
|
16
|
+
/** The public key set served at `/.well-known/jwks.json`. */
|
|
17
|
+
readonly jwks: JSONWebKeySet
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Builds the token kit from the ES256 private key; the verifier checks the app's own key set, never a remote one. */
|
|
21
|
+
export const createTokenKit = async (options: TokenOptions): Promise<TokenKit> => {
|
|
22
|
+
const privateKey = await importPKCS8(options.privateKey.reveal(), ALGORITHM, { extractable: true })
|
|
23
|
+
const { d: _private, ...publicJwk } = await exportJWK(privateKey)
|
|
24
|
+
const jwks: JSONWebKeySet = { keys: [{ ...publicJwk, kid: options.keyId, alg: ALGORITHM, use: "sig" }] }
|
|
25
|
+
const keys = createLocalJWKSet(jwks)
|
|
26
|
+
const issue: IssueAccessToken = (user) =>
|
|
27
|
+
new SignJWT({ role: ROLE, ...(user.email === undefined ? {} : { email: user.email }) })
|
|
28
|
+
.setProtectedHeader({ alg: ALGORITHM, kid: options.keyId })
|
|
29
|
+
.setSubject(user.id)
|
|
30
|
+
.setIssuer(options.issuer)
|
|
31
|
+
.setAudience(options.audience)
|
|
32
|
+
.setIssuedAt()
|
|
33
|
+
.setExpirationTime(`${String(options.accessTtlSeconds)}s`)
|
|
34
|
+
.sign(privateKey)
|
|
35
|
+
const verify: VerifyAccessToken = async (token) => {
|
|
36
|
+
try {
|
|
37
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
38
|
+
algorithms: ["ES256"],
|
|
39
|
+
audience: options.audience,
|
|
40
|
+
issuer: options.issuer,
|
|
41
|
+
})
|
|
42
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || payload.role !== ROLE) {
|
|
43
|
+
return refused(PassportErrorCode.AccessTokenInvalid)
|
|
44
|
+
}
|
|
45
|
+
const principal: OwnPrincipal = {
|
|
46
|
+
id: payload.sub,
|
|
47
|
+
role: ROLE,
|
|
48
|
+
...(typeof payload.email === "string" ? { email: payload.email } : {}),
|
|
49
|
+
}
|
|
50
|
+
return ok(principal)
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
return refused(PassportErrorCode.AccessTokenInvalid, { cause: String(cause) })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { issue, verify, jwks }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The token kit of a composition root that registers synchronously: each call awaits the one key import started here, so a
|
|
60
|
+
* bad key fails the first verification instead of hiding behind a default.
|
|
61
|
+
*/
|
|
62
|
+
export const tokenKit = (options: TokenOptions): { readonly issue: IssueAccessToken; readonly verify: VerifyAccessToken; readonly jwks: () => Promise<JSONWebKeySet> } => {
|
|
63
|
+
const kit = createTokenKit(options)
|
|
64
|
+
return {
|
|
65
|
+
issue: async (user) => (await kit).issue(user),
|
|
66
|
+
verify: async (token) => (await kit).verify(token),
|
|
67
|
+
jwks: async () => (await kit).jwks,
|
|
68
|
+
}
|
|
69
|
+
}
|
package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import { PassportStrategy } from "@nestjs/passport"
|
|
3
|
+
import { Strategy } from "passport-google-oauth20"
|
|
4
|
+
import type { Profile } from "passport-google-oauth20"
|
|
5
|
+
import { InjectPassportOptions } from "../passport.decorators"
|
|
6
|
+
import type { PassportOptions } from "../passport.options"
|
|
7
|
+
import { CookieStateStore } from "./oauth-state.store"
|
|
8
|
+
|
|
9
|
+
/** The Google identity a callback proves: the provider subject and the verified email when Google states one. */
|
|
10
|
+
export interface GoogleIdentity {
|
|
11
|
+
/** Google's stable subject id (auth.identities.provider_id). */
|
|
12
|
+
readonly providerId: string
|
|
13
|
+
/** The verified email, when present. */
|
|
14
|
+
readonly email?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
/** Google OAuth 2.0 (authorization code with state and PKCE): it proves the Google identity and never signs the user in itself. */
|
|
19
|
+
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
|
|
20
|
+
constructor(@InjectPassportOptions() options: PassportOptions) {
|
|
21
|
+
super({
|
|
22
|
+
clientID: options.google.clientId,
|
|
23
|
+
clientSecret: options.google.clientSecret.reveal(),
|
|
24
|
+
callbackURL: options.google.callbackUrl,
|
|
25
|
+
scope: ["openid", "email"],
|
|
26
|
+
pkce: true,
|
|
27
|
+
state: true,
|
|
28
|
+
store: new CookieStateStore(new URL(options.google.callbackUrl).pathname),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The identity of the callback; the identity domain links it to a user and issues the session. */
|
|
33
|
+
validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
|
|
34
|
+
const email = profile.emails?.find((entry) => entry.verified)?.value
|
|
35
|
+
return { providerId: profile.id, ...(email === undefined ? {} : { email }) }
|
|
36
|
+
}
|
|
37
|
+
}
|
package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import { PassportStrategy } from "@nestjs/passport"
|
|
3
|
+
import { Strategy } from "passport-local"
|
|
4
|
+
import { PassportError, PassportErrorCode } from "../errors/passport.error"
|
|
5
|
+
import { InjectPasswordAccounts } from "../passport.decorators"
|
|
6
|
+
import { verifyPassword } from "../passport.password"
|
|
7
|
+
|
|
8
|
+
/** The account a password sign-in proves. */
|
|
9
|
+
export interface PasswordAccount {
|
|
10
|
+
/** The user id (auth.users.id). */
|
|
11
|
+
readonly id: string
|
|
12
|
+
/** The account email. */
|
|
13
|
+
readonly email: string
|
|
14
|
+
/** The stored bcrypt hash (auth.users.encrypted_password), null for an account without a password. */
|
|
15
|
+
readonly passwordHash: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The lookup the identity domain provides: the account of one email, or undefined. */
|
|
19
|
+
export type FindPasswordAccount = (email: string) => Promise<PasswordAccount | undefined>
|
|
20
|
+
|
|
21
|
+
@Injectable()
|
|
22
|
+
/** Email and password sign-in: bcrypt against the stored hash; a miss and a wrong password fail alike. */
|
|
23
|
+
export class LocalStrategy extends PassportStrategy(Strategy, "local") {
|
|
24
|
+
constructor(@InjectPasswordAccounts() private readonly findAccount: FindPasswordAccount) {
|
|
25
|
+
super({ usernameField: "email", passwordField: "password" })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The proven account; every refusal is the same error so an email's existence never leaks. */
|
|
29
|
+
async validate(email: string, password: string): Promise<{ readonly id: string; readonly email: string }> {
|
|
30
|
+
const account = await this.findAccount(email.trim().toLowerCase())
|
|
31
|
+
if (!(await verifyPassword(password, account?.passwordHash ?? null)) || account === undefined) {
|
|
32
|
+
throw new PassportError({ code: PassportErrorCode.CredentialsRefused })
|
|
33
|
+
}
|
|
34
|
+
return { id: account.id, email: account.email }
|
|
35
|
+
}
|
|
36
|
+
}
|
package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
2
|
+
import type { Request, Response } from "express"
|
|
3
|
+
|
|
4
|
+
const COOKIE = "oauth_state"
|
|
5
|
+
const MAX_AGE_MS = 600_000
|
|
6
|
+
|
|
7
|
+
type Stored = { readonly state: string; readonly verifier: string }
|
|
8
|
+
type StoreCallback = (error: Error | null, state?: string) => void
|
|
9
|
+
type VerifyCallback = (error: Error | null, verifier?: string | false, state?: string) => void
|
|
10
|
+
|
|
11
|
+
/** The response of the request a passport-oauth2 store is called with: passport keeps it on `req.res`. */
|
|
12
|
+
const responseOf = (request: Request): Response | undefined => request.res
|
|
13
|
+
|
|
14
|
+
/** The value of the state cookie in the request's Cookie header (no cookie middleware is installed). */
|
|
15
|
+
const cookieOf = (request: Request): string | undefined =>
|
|
16
|
+
request.headers.cookie
|
|
17
|
+
?.split(";")
|
|
18
|
+
.map((part) => part.trim())
|
|
19
|
+
.find((part) => part.startsWith(`${COOKIE}=`))
|
|
20
|
+
?.slice(COOKIE.length + 1)
|
|
21
|
+
|
|
22
|
+
const readStored = (request: Request): Stored | undefined => {
|
|
23
|
+
const raw = cookieOf(request)
|
|
24
|
+
if (raw === undefined) return undefined
|
|
25
|
+
const [state, verifier, extra] = raw.split(".")
|
|
26
|
+
return state && verifier && extra === undefined ? { state, verifier } : undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sameText = (left: string, right: string): boolean => {
|
|
30
|
+
const a = Buffer.from(left)
|
|
31
|
+
const b = Buffer.from(right)
|
|
32
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The OAuth state and PKCE verifier store of the Google strategy without a server session: both live in one short-lived
|
|
37
|
+
* httpOnly, Secure, SameSite=Lax cookie scoped to the callback path, and the callback must present the same state.
|
|
38
|
+
*/
|
|
39
|
+
export class CookieStateStore {
|
|
40
|
+
constructor(private readonly callbackPath: string) {}
|
|
41
|
+
|
|
42
|
+
/** Stores a fresh state with the PKCE verifier and hands the state to the authorization redirect. */
|
|
43
|
+
store(request: Request, verifier: string, _state: unknown, _meta: unknown, callback: StoreCallback): void {
|
|
44
|
+
const state = randomBytes(24).toString("base64url")
|
|
45
|
+
responseOf(request)?.cookie(COOKIE, `${state}.${verifier}`, { httpOnly: true, secure: true, sameSite: "lax", path: this.callbackPath, maxAge: MAX_AGE_MS })
|
|
46
|
+
callback(null, state)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Answers the stored verifier only when the callback's state equals the stored one; the cookie is cleared either way. */
|
|
50
|
+
verify(request: Request, provided: string, callback: VerifyCallback): void {
|
|
51
|
+
const stored = readStored(request)
|
|
52
|
+
responseOf(request)?.clearCookie(COOKIE, { httpOnly: true, secure: true, sameSite: "lax", path: this.callbackPath })
|
|
53
|
+
if (stored === undefined || !sameText(stored.state, provided)) {
|
|
54
|
+
callback(null, false)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
callback(null, stored.verifier, stored.state)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EntityManager } from "typeorm"
|
|
2
|
+
|
|
3
|
+
/** The database functions this app may call, each listed with its reason in ruleParams.be.dbFunctions or hfs.json sides.be.dbFunctions. */
|
|
4
|
+
const IDENTIFIER = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calls one database function TypeORM cannot express (a pg built-in or a SECURITY DEFINER function of a migration) with numbered
|
|
8
|
+
* parameters; the function name is an identifier the canon lint holds to the listed set, never a value.
|
|
9
|
+
*/
|
|
10
|
+
export const callDbFunction = async <T>(manager: EntityManager, name: string, args: ReadonlyArray<unknown>): Promise<ReadonlyArray<T>> => {
|
|
11
|
+
if (!IDENTIFIER.test(name)) throw new TypeError(`${name} is not a database function name`)
|
|
12
|
+
const placeholders = args.map((_, index) => `$${String(index + 1)}`).join(", ")
|
|
13
|
+
return manager.query<Array<T>>(`SELECT * FROM ${name}(${placeholders})`, [...args])
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends. */
|
|
17
|
+
export const advisoryXactLock = async (manager: EntityManager, key: string): Promise<void> => {
|
|
18
|
+
await manager.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Sends one NOTIFY on `channel` with `payload`, delivered when the manager's transaction commits. */
|
|
22
|
+
export const notify = async (manager: EntityManager, channel: string, payload: string): Promise<void> => {
|
|
23
|
+
await manager.query("SELECT pg_notify($1, $2)", [channel, payload])
|
|
24
|
+
}
|
|
@@ -21,7 +21,7 @@ const ENTITY_MANAGER_TOKENS: ReadonlyMap<string, EntityManagerToken> = new Map<s
|
|
|
21
21
|
])
|
|
22
22
|
|
|
23
23
|
@Module({})
|
|
24
|
-
/** Opens each named
|
|
24
|
+
/** Opens each named PostgreSQL connection with the entities the composition root hands it, and exposes its shared EntityManager; no migrations. */
|
|
25
25
|
export class DatabaseModule extends ConfigurableModuleClass {
|
|
26
26
|
/** Registers the capability once per app with the connections it opens. */
|
|
27
27
|
static register(options: typeof OPTIONS_TYPE): DynamicModule {
|
|
@@ -53,6 +53,7 @@ export class DatabaseModule extends ConfigurableModuleClass {
|
|
|
53
53
|
type: "postgres",
|
|
54
54
|
url: connection.url.reveal(),
|
|
55
55
|
...schemaOption(connection.schema),
|
|
56
|
+
entities: [...options.entities],
|
|
56
57
|
synchronize: false,
|
|
57
58
|
retryAttempts: 2,
|
|
58
59
|
}),
|
|
@@ -4,8 +4,8 @@ import type { Secret } from "@modules/platform/config"
|
|
|
4
4
|
export interface DatabaseConnectionConfig {
|
|
5
5
|
/** The logical connection name declared in `hfs.json`. */
|
|
6
6
|
readonly name: string
|
|
7
|
-
/** The
|
|
8
|
-
readonly provider: "
|
|
7
|
+
/** The PostgreSQL provider; schema changes remain under `db/migrations`. */
|
|
8
|
+
readonly provider: "postgres"
|
|
9
9
|
/** The dedicated least-privilege application role's connection URL. */
|
|
10
10
|
readonly url: Secret
|
|
11
11
|
}
|
|
@@ -20,4 +20,6 @@ export interface DatabaseConnectionOptions extends DatabaseConnectionConfig {
|
|
|
20
20
|
export interface DatabaseOptions {
|
|
21
21
|
/** The connections this app opens; one per physical database. */
|
|
22
22
|
readonly connections: ReadonlyArray<DatabaseConnectionOptions>
|
|
23
|
+
/** The TypeORM entities the connections map: PRIMARY_ENTITIES of `@modules/platform/database/entities`, passed by the app's composition root only. */
|
|
24
|
+
readonly entities: ReadonlyArray<Function>
|
|
23
25
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EntityManager } from "typeorm"
|
|
1
|
+
import type { EntityManager, EntityTarget, ObjectLiteral } from "typeorm"
|
|
2
2
|
import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
|
|
3
3
|
|
|
4
4
|
declare const sqlTextBrand: unique symbol
|
|
@@ -35,15 +35,28 @@ function assertSqlIdent(_value: string): asserts _value is SqlIdent {
|
|
|
35
35
|
|
|
36
36
|
type NotFoundFactory = (id: string) => never
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/** The id an owned read answers. */
|
|
39
|
+
interface OwnedId {
|
|
40
|
+
readonly id: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Requires the row of `entity` with this id and owner (its `id` and `owner_id` columns) and lets the capability own its
|
|
45
|
+
* typed not-found error of that id; absent and denied rows share it. The read is the entity's query builder with named
|
|
46
|
+
* parameters, so the capability writes no SQL (R334).
|
|
47
|
+
*/
|
|
48
|
+
export const requireOwnedEntity = async (
|
|
40
49
|
entityManager: EntityManager,
|
|
41
|
-
|
|
50
|
+
entity: EntityTarget<ObjectLiteral>,
|
|
42
51
|
id: string,
|
|
43
52
|
ownerId: string,
|
|
44
53
|
notFound: NotFoundFactory,
|
|
45
|
-
): Promise<
|
|
46
|
-
const row =
|
|
54
|
+
): Promise<OwnedId> => {
|
|
55
|
+
const row = await entityManager
|
|
56
|
+
.createQueryBuilder(entity, "owned")
|
|
57
|
+
.select("owned.id", "id")
|
|
58
|
+
.where("owned.id = :id AND owned.owner_id = :ownerId", { id, ownerId })
|
|
59
|
+
.getRawOne<OwnedId>()
|
|
47
60
|
if (row === undefined) return notFound(id)
|
|
48
61
|
return row
|
|
49
62
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entity list of the primary connection: each capability's `<c>Entities` array (its `persistence/entities/index.ts`), by
|
|
3
|
+
* name, no glob. Only an app's composition root imports this file, from the deep path `@modules/platform/database/entities`;
|
|
4
|
+
* DatabaseModule and the database barrel never do, since the capability barrels import the database capability back.
|
|
5
|
+
*/
|
|
6
|
+
export const PRIMARY_ENTITIES: ReadonlyArray<Function> = []
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { DatabaseModule } from "./database.module"
|
|
2
|
+
export { advisoryXactLock, callDbFunction, notify } from "./database.functions"
|
|
2
3
|
export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
|
|
3
4
|
export { DatabaseProbeService } from "./database-probe.service"
|
|
4
5
|
export type { DatabaseConnectionOptions } from "./database.options"
|
package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts
CHANGED
|
@@ -12,7 +12,7 @@ const runDbPush = (): Promise<void> =>
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
@Injectable()
|
|
15
|
-
/** The lite migration runner delegates the
|
|
15
|
+
/** The lite migration runner delegates the db/migrations schema authority to the managed db:push wrapper. */
|
|
16
16
|
export class MigrationRunnerService {
|
|
17
17
|
/** Runs the managed wrapper and propagates a CLI or migration failure. */
|
|
18
18
|
async run(): Promise<void> {
|
|
@@ -2,10 +2,10 @@ import type { EnvSource } from "@modules/platform/config"
|
|
|
2
2
|
import type { DatabaseConnectionOptions } from "./database.options"
|
|
3
3
|
import { PRIMARY_CONNECTION } from "./primary.connection"
|
|
4
4
|
|
|
5
|
-
/** Reads the least-privilege
|
|
5
|
+
/** Reads the least-privilege PostgreSQL connection with no default. */
|
|
6
6
|
export const parsePrimaryDatabaseConfig = (env: EnvSource): DatabaseConnectionOptions => ({
|
|
7
7
|
name: PRIMARY_CONNECTION,
|
|
8
|
-
provider: "
|
|
8
|
+
provider: "postgres",
|
|
9
9
|
url: env.secret("PRIMARY_DB_URL"),
|
|
10
10
|
schema: env.string("PRIMARY_DB_SCHEMA"),
|
|
11
11
|
})
|
|
@@ -4,7 +4,7 @@ import type { EntityManager } from "typeorm"
|
|
|
4
4
|
import type { SqlText } from "./database.sql"
|
|
5
5
|
import { InjectPrimaryEntityManager } from "./primary.decorators"
|
|
6
6
|
|
|
7
|
-
const SEED_FILE = "
|
|
7
|
+
const SEED_FILE = "db/seed.sql"
|
|
8
8
|
|
|
9
9
|
function assertSeedText(_text: string): asserts _text is SqlText {
|
|
10
10
|
// The brand is compile-time only; this function's caller reads the tracked seed file verbatim.
|
|
@@ -16,7 +16,7 @@ const seedText = (text: string): SqlText => {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
@Injectable()
|
|
19
|
-
/** Executes the one tracked
|
|
19
|
+
/** Executes the one tracked seed through the shared least-privilege EntityManager. */
|
|
20
20
|
export class SeedRunnerService {
|
|
21
21
|
constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
|
|
22
22
|
|
|
@@ -4,7 +4,7 @@ export { parseHttpSecurityConfig } from "./http-security.config"
|
|
|
4
4
|
export { HttpSecurityModule } from "./http-security.module"
|
|
5
5
|
export type { HttpSecurityOptions } from "./http-security.options"
|
|
6
6
|
export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
|
|
7
|
-
export { OriginGuard } from "./origin.guard"
|
|
7
|
+
export { OriginGuard, ServerToServer } from "./origin.guard"
|
|
8
8
|
export { RateLimitGuard } from "./rate-limit.guard"
|
|
9
9
|
export { applyHttpSecurity } from "./edge-security.service"
|
|
10
10
|
export { ReadinessService } from "./readiness.service"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Secret } from "@modules/platform/config"
|
|
2
2
|
import { PRIMARY_DATA_SOURCE } from "@modules/platform/database"
|
|
3
3
|
import { ACCESS_TOKEN_VERIFIER } from "@modules/domain/identity"
|
|
4
|
-
import {
|
|
4
|
+
import { OWN_TOKEN_VERIFIER } from "@modules/integrations/passport"
|
|
5
|
+
import { OBJECT_STORAGE } from "@modules/integrations/object-storage"
|
|
5
6
|
|
|
6
7
|
/** Compiles the real API root with public configuration and explicit infrastructure doubles. */
|
|
7
8
|
export const buildWiringProof = () => ({
|
|
@@ -14,16 +15,28 @@ export const buildWiringProof = () => ({
|
|
|
14
15
|
},
|
|
15
16
|
database: {
|
|
16
17
|
name: "primary",
|
|
17
|
-
provider: "
|
|
18
|
+
provider: "postgres",
|
|
18
19
|
url: new Secret("postgresql://app_be@127.0.0.1:5432/postgres"),
|
|
19
20
|
schema: "public",
|
|
20
21
|
},
|
|
21
|
-
|
|
22
|
+
passport: {
|
|
23
|
+
google: { clientId: "fixture", clientSecret: new Secret("fixture-only"), callbackUrl: "https://api.fixture.example/auth/google/callback" },
|
|
24
|
+
tokens: { issuer: "https://api.fixture.example", audience: "fixture", privateKey: new Secret("fixture-only"), keyId: "k1", accessTtlSeconds: 900 },
|
|
25
|
+
refresh: { name: "refresh_token", path: "/auth/refresh", ttlSeconds: 2592000 },
|
|
26
|
+
},
|
|
27
|
+
objectStorage: {
|
|
28
|
+
endpoint: "https://storage.fixture.example",
|
|
29
|
+
region: "us-east-1",
|
|
30
|
+
bucket: "fixture",
|
|
31
|
+
accessKeyId: new Secret("fixture-only"),
|
|
32
|
+
secretAccessKey: new Secret("fixture-only"),
|
|
33
|
+
presignTtlSeconds: 300,
|
|
34
|
+
},
|
|
22
35
|
},
|
|
23
36
|
overrides: [
|
|
24
37
|
{ token: PRIMARY_DATA_SOURCE, value: { manager: {} } },
|
|
25
|
-
{ token:
|
|
26
|
-
{ token:
|
|
38
|
+
{ token: OBJECT_STORAGE, value: {} },
|
|
39
|
+
{ token: OWN_TOKEN_VERIFIER, value: () => ({ ok: false }) },
|
|
27
40
|
{ token: ACCESS_TOKEN_VERIFIER, value: () => ({ ok: false }) },
|
|
28
41
|
],
|
|
29
42
|
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
|
|
2
|
+
|
|
3
|
+
/** One row of public.{{table}}, mapped from its creating migration in db/migrations; edit it beside every migration that changes the table. */
|
|
4
|
+
@Entity({ schema: "public", name: "{{table}}" })
|
|
5
|
+
export class {{Singular}}Entity {
|
|
6
|
+
/** The row id. */
|
|
7
|
+
@PrimaryGeneratedColumn("uuid", { name: "id" })
|
|
8
|
+
id!: string
|
|
9
|
+
|
|
10
|
+
/** The owning user (auth.users.id). */
|
|
11
|
+
@Column({ name: "owner_id", type: "uuid" })
|
|
12
|
+
ownerId!: string
|
|
13
|
+
|
|
14
|
+
/** When the row was created; the database sets it. */
|
|
15
|
+
@Column({ name: "created_at", type: "timestamptz", insert: false, update: false })
|
|
16
|
+
createdAt!: Date
|
|
17
|
+
|
|
18
|
+
/** When the row last changed; the database trigger sets it. */
|
|
19
|
+
@Column({ name: "updated_at", type: "timestamptz", insert: false, update: false })
|
|
20
|
+
updatedAt!: Date
|
|
21
|
+
}
|
|
@@ -4,7 +4,7 @@ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./{{name}}.module-definit
|
|
|
4
4
|
import { {{Name}}Service } from "./{{name}}.service"
|
|
5
5
|
|
|
6
6
|
@Module({})
|
|
7
|
-
/** The {{name}} capability over the primary
|
|
7
|
+
/** The {{name}} capability over the primary PostgreSQL connection. */
|
|
8
8
|
export class {{Name}}Module extends ConfigurableModuleClass {
|
|
9
9
|
/** Registers the capability once in the API app. */
|
|
10
10
|
static register(options: typeof OPTIONS_TYPE): DynamicModule {
|
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
import { Injectable } from "@nestjs/common"
|
|
2
2
|
import type { EntityManager } from "typeorm"
|
|
3
|
-
import { InjectPrimaryEntityManager,
|
|
3
|
+
import { InjectPrimaryEntityManager, requireOwnedEntity } from "@modules/platform/database"
|
|
4
4
|
import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
|
|
5
|
-
import
|
|
6
|
-
import { FIND_{{upper}}, INSERT_{{upper}} } from "./persistence/{{name}}.sql"
|
|
5
|
+
import { {{Singular}}Entity } from "./persistence/entities/{{name}}.entity"
|
|
7
6
|
|
|
8
7
|
/** What the capability answers for one row: its id. */
|
|
9
|
-
type {{Name}}Result = Pick<{{
|
|
8
|
+
type {{Name}}Result = Pick<{{Singular}}Entity, "id">
|
|
10
9
|
|
|
11
10
|
/** Throws the typed not-found error of one id: absent and denied rows share it. */
|
|
12
11
|
const notFound = (id: string): never => {
|
|
13
12
|
throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotFound, params: { id } })
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
/** The row an insert
|
|
17
|
-
const createdRow = (
|
|
18
|
-
if (row
|
|
19
|
-
return row
|
|
15
|
+
/** The id of the row an insert saved; a save that answered none throws the typed not-created error. */
|
|
16
|
+
const createdRow = (row: {{Singular}}Entity): {{Name}}Result => {
|
|
17
|
+
if (!row.id) throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotCreated })
|
|
18
|
+
return { id: row.id }
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
@Injectable()
|
|
23
|
-
/** The {{name}} rows reached only through the shared primary EntityManager. */
|
|
22
|
+
/** The {{name}} rows reached only through the {{Singular}}Entity on the shared primary EntityManager. */
|
|
24
23
|
export class {{Name}}Service {
|
|
25
24
|
constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
|
|
26
25
|
|
|
27
26
|
/** Answers one row owned by the authenticated principal; absent and denied rows share one typed not-found result. */
|
|
28
27
|
{{nameCamel}}(principalId: string, id: string): Promise<{{Name}}Result> {
|
|
29
|
-
return
|
|
28
|
+
return requireOwnedEntity(this.entityManager, {{Singular}}Entity, id, principalId, notFound)
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
/** Creates one row owned by the authenticated principal and answers its id. */
|
|
33
32
|
create{{Singular}}(principalId: string): Promise<{{Name}}Result> {
|
|
34
|
-
return this.entityManager.
|
|
33
|
+
return this.entityManager.save({{Singular}}Entity, { ownerId: principalId }).then(createdRow)
|
|
35
34
|
}
|
|
36
35
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Injectable } from "@nestjs/common"
|
|
2
2
|
import type { EntityManager } from "typeorm"
|
|
3
|
-
import { InjectPrimaryEntityManager,
|
|
3
|
+
import { InjectPrimaryEntityManager, requireOwnedEntity } from "@modules/platform/database"
|
|
4
4
|
import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
|
|
5
|
-
import
|
|
6
|
-
import { FIND_{{upper}} } from "./persistence/{{name}}.sql"
|
|
5
|
+
import { {{Singular}}Entity } from "./persistence/entities/{{name}}.entity"
|
|
7
6
|
|
|
8
7
|
/** What the capability answers for one row: its id. */
|
|
9
|
-
type {{Name}}Result = Pick<{{
|
|
8
|
+
type {{Name}}Result = Pick<{{Singular}}Entity, "id">
|
|
10
9
|
|
|
11
10
|
/** Throws the typed not-found error of one id: absent and denied rows share it. */
|
|
12
11
|
const notFound = (id: string): never => {
|
|
@@ -14,12 +13,12 @@ const notFound = (id: string): never => {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
@Injectable()
|
|
17
|
-
/** The {{name}} rows reached only through the shared primary EntityManager. */
|
|
16
|
+
/** The {{name}} rows reached only through the {{Singular}}Entity on the shared primary EntityManager. */
|
|
18
17
|
export class {{Name}}Service {
|
|
19
18
|
constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
|
|
20
19
|
|
|
21
20
|
/** Answers one row owned by the authenticated principal; absent and denied rows share one typed not-found result. */
|
|
22
21
|
{{nameCamel}}(principalId: string, id: string): Promise<{{Name}}Result> {
|
|
23
|
-
return
|
|
22
|
+
return requireOwnedEntity(this.entityManager, {{Singular}}Entity, id, principalId, notFound)
|
|
24
23
|
}
|
|
25
24
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import axios from "axios"
|
|
2
|
+
import { apiUrl } from "@/modules/config"
|
|
3
|
+
|
|
4
|
+
/** What one auth door answered: its status and its body, unread (the client narrows it). */
|
|
5
|
+
export interface DoorAnswer {
|
|
6
|
+
readonly status: number
|
|
7
|
+
readonly body: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The auth doors of the app's own back end; the refresh token rides in their httpOnly cookie, never in a body. */
|
|
11
|
+
export type AuthDoor = "sign-in" | "sign-up" | "refresh" | "sign-out" | "password-reset" | "resend-verification"
|
|
12
|
+
|
|
13
|
+
const TIMEOUT_MS = 10_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The one auth door of the front end: a POST to `<api>/auth/<door>` with the cookies attached. A network failure answers
|
|
17
|
+
* status 0, so the client maps it to "unavailable" instead of throwing.
|
|
18
|
+
*/
|
|
19
|
+
export const callAuthDoor = async (door: AuthDoor, body?: Readonly<Record<string, string>>): Promise<DoorAnswer> => {
|
|
20
|
+
try {
|
|
21
|
+
const response = await axios.post<unknown>(`${apiUrl()}/auth/${door}`, body, {
|
|
22
|
+
withCredentials: true,
|
|
23
|
+
validateStatus: () => true,
|
|
24
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
25
|
+
})
|
|
26
|
+
return { status: response.status, body: response.data }
|
|
27
|
+
} catch {
|
|
28
|
+
return { status: 0, body: undefined }
|
|
29
|
+
}
|
|
30
|
+
}
|