@starci/hfs 4.9.11 → 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 +13 -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 +177 -155
- package/runtime/knowledge/hfs/slots.yaml +164 -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/{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 +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 +4 -4
- 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 +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/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
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
|
|
|
@@ -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
|
+
}
|
|
@@ -1,18 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import { supabaseAnonKey, supabaseUrl } from "@/modules/config"
|
|
1
|
+
import { callAuthDoor } from "./auth-door"
|
|
2
|
+
import type { DoorAnswer } from "./auth-door"
|
|
4
3
|
import type { AuthClient, AuthFailure, AuthSession } from "./contracts"
|
|
4
|
+
import { heldSession, heldToken, hold, listen } from "./token-store"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
(authentication ??= createClient(supabaseUrl(), supabaseAnonKey(), {
|
|
15
|
-
auth: { flowType: "pkce", detectSessionInUrl: true, persistSession: true, autoRefreshToken: true },
|
|
16
|
-
}).auth)
|
|
8
|
+
/** The token and session an auth door issued, narrowed from its unread body; undefined when the body is not one. */
|
|
9
|
+
const issuedOf = (body: unknown): { readonly token: string; readonly session: AuthSession } | undefined => {
|
|
10
|
+
if (!isRecord(body) || typeof body.accessToken !== "string" || !isRecord(body.user) || typeof body.user.id !== "string") return undefined
|
|
11
|
+
const email = typeof body.user.email === "string" ? body.user.email : undefined
|
|
12
|
+
return { token: body.accessToken, session: { userId: body.user.id, ...(email === undefined ? {} : { email }) } }
|
|
13
|
+
}
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
/** The failure kind of a door's status: refused credentials, rejected input, or the API unavailable. */
|
|
16
|
+
const kindOf = (status: number): string => {
|
|
17
|
+
if (status === 400 || status === 401 || status === 403) return "refused"
|
|
18
|
+
return status === 422 ? "invalid" : "unavailable"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const failureOf = (answer: DoorAnswer): AuthFailure => ({ code: `AUTH_${String(answer.status)}`, kind: kindOf(answer.status), message: "" })
|
|
22
|
+
|
|
23
|
+
const ok = (answer: DoorAnswer): boolean => answer.status >= 200 && answer.status < 300
|
|
24
|
+
|
|
25
|
+
/** Rotates the refresh cookie and holds the new access token; signs out when the API refuses the cookie. */
|
|
26
|
+
const refresh = async (): Promise<string | undefined> => {
|
|
27
|
+
const answer = await callAuthDoor("refresh")
|
|
28
|
+
hold(ok(answer) ? issuedOf(answer.body) : undefined)
|
|
29
|
+
return heldToken()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The lite session issuer's client: the app's own auth doors through `auth-door.ts`, the token in `token-store.ts`. */
|
|
33
|
+
export const authClient: AuthClient = {
|
|
34
|
+
session: async () => heldSession() ?? ((await refresh()) === undefined ? undefined : heldSession()),
|
|
35
|
+
accessToken: async () => heldToken() ?? refresh(),
|
|
36
|
+
refreshAccessToken: refresh,
|
|
37
|
+
signInWithPassword: async (email, password) => {
|
|
38
|
+
const answer = await callAuthDoor("sign-in", { email, password })
|
|
39
|
+
const issued = ok(answer) ? issuedOf(answer.body) : undefined
|
|
40
|
+
if (issued === undefined) return failureOf(answer)
|
|
41
|
+
hold(issued)
|
|
42
|
+
return undefined
|
|
43
|
+
},
|
|
44
|
+
signOut: async () => {
|
|
45
|
+
const answer = await callAuthDoor("sign-out")
|
|
46
|
+
hold(undefined)
|
|
47
|
+
return ok(answer) ? undefined : failureOf(answer)
|
|
48
|
+
},
|
|
49
|
+
onChange: listen,
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AuthSession } from "./contracts"
|
|
2
|
+
|
|
3
|
+
/** The signed-in reader and the access token, in memory only; the refresh token never reaches the browser's script. */
|
|
4
|
+
interface Held {
|
|
5
|
+
readonly token: string
|
|
6
|
+
readonly session: AuthSession
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let held: Held | undefined
|
|
10
|
+
const listeners = new Set<(session: AuthSession | undefined) => void>()
|
|
11
|
+
|
|
12
|
+
/** The held access token, or undefined when signed out. */
|
|
13
|
+
export const heldToken = (): string | undefined => held?.token
|
|
14
|
+
|
|
15
|
+
/** The held session, or undefined when signed out. */
|
|
16
|
+
export const heldSession = (): AuthSession | undefined => held?.session
|
|
17
|
+
|
|
18
|
+
/** Replaces the held token and session (undefined signs out) and tells every listener. */
|
|
19
|
+
export const hold = (next: Held | undefined): void => {
|
|
20
|
+
held = next
|
|
21
|
+
for (const listener of listeners) listener(held?.session)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Subscribes to session changes; the listener hears the current session first. */
|
|
25
|
+
export const listen = (listener: (session: AuthSession | undefined) => void): (() => void) => {
|
|
26
|
+
listeners.add(listener)
|
|
27
|
+
listener(held?.session)
|
|
28
|
+
return () => {
|
|
29
|
+
listeners.delete(listener)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -6,8 +6,5 @@ const required = (name: string, value: string | undefined): string => {
|
|
|
6
6
|
|
|
7
7
|
/** The public origin used for metadata. */
|
|
8
8
|
export const siteUrl = (): string => required("NEXT_PUBLIC_SITE_URL", process.env.NEXT_PUBLIC_SITE_URL)
|
|
9
|
-
/** The public
|
|
10
|
-
export const
|
|
11
|
-
/** The public anonymous key of Supabase Auth; the back end verifies every access token it is sent. */
|
|
12
|
-
export const supabaseAnonKey = (): string =>
|
|
13
|
-
required("NEXT_PUBLIC_SUPABASE_ANON_KEY", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
|
9
|
+
/** The public origin of the API whose auth doors modules/auth calls; the browser holds no credential of its own. */
|
|
10
|
+
export const apiUrl = (): string => required("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL)
|