@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.
Files changed (222) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +7 -9
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +1 -1
  5. package/db/dump.mjs +11 -10
  6. package/db/entity-list.mjs +1 -1
  7. package/db/migrate.mjs +83 -259
  8. package/db/new.mjs +46 -0
  9. package/db/post-cutover.mjs +13 -17
  10. package/db/postgres.mjs +80 -0
  11. package/emit/contracts.mjs +5 -9
  12. package/package.json +1 -1
  13. package/release/gate.mjs +3 -15
  14. package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
  15. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  16. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  17. package/runtime/knowledge/hfs/rules.yaml +179 -157
  18. package/runtime/knowledge/hfs/slots.yaml +185 -172
  19. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  20. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  21. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  22. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  23. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  24. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  25. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  26. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  28. package/runtime/modules/kernel/command-policy.yaml +1 -1
  29. package/runtime/modules/kernel/failure-codes.yaml +65 -115
  30. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  31. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/entity-registry.mjs +53 -0
  33. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  34. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  35. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  36. package/runtime/scripts/hfs/architecture/owners.mjs +11 -0
  37. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  39. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  40. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  41. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  42. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  43. package/runtime/scripts/hfs/architecture/tiers.mjs +14 -3
  44. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  45. package/runtime/scripts/hfs/check.mjs +6 -6
  46. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  47. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  48. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  49. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  50. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  51. package/runtime/scripts/hfs/manifest-shape.mjs +9 -5
  52. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  53. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  56. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  57. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  58. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  59. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  60. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  61. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  62. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  63. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  64. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  65. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  66. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  67. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  68. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  69. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  70. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  71. package/runtime/scripts/hfs/slots.mjs +8 -2
  72. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  73. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  74. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  75. package/scaffold/add-app.mjs +2 -10
  76. package/scaffold/add-cli-lite.mjs +6 -6
  77. package/scaffold/add-compose.mjs +1 -1
  78. package/scaffold/add-inbox-lite.mjs +4 -8
  79. package/scaffold/add-plan.mjs +1 -1
  80. package/scaffold/add-queue-lite.mjs +16 -24
  81. package/scaffold/add-table.mjs +34 -26
  82. package/scaffold/add.mjs +2 -2
  83. package/scaffold/app.mjs +19 -86
  84. package/scaffold/lite-exports.mjs +1 -1
  85. package/src/main.mjs +19 -20
  86. package/stack/target-binding.mjs +0 -60
  87. package/stack/urgent-floor.mjs +2 -2
  88. package/sync/index.mjs +32 -8
  89. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  90. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  91. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  92. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  93. package/templates/app/gitignore-lite +0 -3
  94. package/templates/app/package-scripts/db.json +9 -0
  95. package/templates/app/package-scripts/package.json +0 -1
  96. package/templates/app/package-scripts-lite/package.json +1 -1
  97. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  98. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  99. package/templates/app/skeleton/README.md +1 -1
  100. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  101. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  102. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  103. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  104. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  105. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  106. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  107. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  108. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  109. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  110. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  111. package/templates/app/skeleton-lite/README.md +11 -11
  112. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  113. package/templates/app/tool-config-lite/prettierignore +0 -1
  114. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  115. package/templates/be/inbox/database-client.ts.tpl +13 -4
  116. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  117. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  118. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  119. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  120. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  121. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  122. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  123. package/templates/be/inbox/service.ts.tpl +1 -2
  124. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  125. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  126. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  127. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  128. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  129. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  130. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  131. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  132. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  133. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  134. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  135. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  136. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  137. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  138. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  139. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  140. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  141. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  142. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  143. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  145. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  146. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  147. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
  148. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  149. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  150. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  151. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  152. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  153. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  154. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  158. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  159. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  160. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  161. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  162. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  163. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  164. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  165. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  166. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  167. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  168. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  169. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  170. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  171. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +7 -0
  172. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  173. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  174. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  175. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  176. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  177. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  178. package/templates/be/table/entities.index.ts.tpl +4 -0
  179. package/templates/be/table/entity.ts.tpl +21 -0
  180. package/templates/be/table/index.ts.tpl +1 -0
  181. package/templates/be/table/module.ts.tpl +1 -1
  182. package/templates/be/table/service.api.ts.tpl +10 -11
  183. package/templates/be/table/service.ts.tpl +5 -6
  184. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  185. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  186. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  187. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  188. package/db/local-migrate.mjs +0 -194
  189. package/db/reset.mjs +0 -125
  190. package/emit/db-types.mjs +0 -271
  191. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  192. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  193. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  194. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  195. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  196. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  197. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  198. package/scaffold/supabase-ports.mjs +0 -26
  199. package/templates/app/package-scripts/supabase.json +0 -7
  200. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  201. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  202. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  203. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  204. package/templates/be/inbox/sql.ts.tpl +0 -16
  205. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  206. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  207. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  208. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  209. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  210. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  211. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  212. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  213. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  214. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  215. package/templates/be/table/rows.ts.tpl +0 -4
  216. package/templates/be/table/sql.api.ts.tpl +0 -7
  217. package/templates/be/table/sql.ts.tpl +0 -4
  218. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  219. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  220. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  221. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  222. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
@@ -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 Supabase PostgreSQL connection and exposes its shared EntityManager without entities or migrations. */
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 Supabase PostgreSQL provider; schema changes remain under `supabase/migrations`. */
8
- readonly provider: "supabase"
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
- /** Requires the row selected by an id-and-owner statement and lets the capability own its typed not-found error of that id. */
39
- export const requireOwnedRow = async <T>(
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
- statement: SqlText,
50
+ entity: EntityTarget<ObjectLiteral>,
42
51
  id: string,
43
52
  ownerId: string,
44
53
  notFound: NotFoundFactory,
45
- ): Promise<T> => {
46
- const row = (await entityManager.query<Array<T>>(statement, [id, ownerId]))[0]
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,7 @@
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 (the entity registry, slot be.platform.database.entities). Only app composition and the test world import this
4
+ * file, from the deep path `@modules/platform/database/entities`; DatabaseModule and the database barrel never do, since the
5
+ * capability barrels import the database capability back.
6
+ */
7
+ 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"
@@ -12,7 +12,7 @@ const runDbPush = (): Promise<void> =>
12
12
  })
13
13
 
14
14
  @Injectable()
15
- /** The lite migration runner delegates the Supabase schema authority to the managed db:push wrapper. */
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 Supabase PostgreSQL connection with no default. */
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: "supabase",
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 = "supabase/seed.sql"
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 Supabase seed through the shared least-privilege EntityManager. */
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 { SUPABASE_ACCESS_TOKEN_VERIFIER, SUPABASE_ADMIN_CLIENT } from "@modules/integrations/supabase"
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: "supabase",
18
+ provider: "postgres",
18
19
  url: new Secret("postgresql://app_be@127.0.0.1:5432/postgres"),
19
20
  schema: "public",
20
21
  },
21
- supabase: { url: "https://fixture.supabase.co", serviceRoleKey: new Secret("fixture-only") },
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: SUPABASE_ADMIN_CLIENT, value: { auth: {}, storage: {} } },
26
- { token: SUPABASE_ACCESS_TOKEN_VERIFIER, value: () => ({ ok: false }) },
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
  })
@@ -7,7 +7,7 @@ export const buildWiringProof = () => ({
7
7
  options: {
8
8
  connections: [{
9
9
  name: PRIMARY_CONNECTION,
10
- provider: "supabase",
10
+ provider: "postgres",
11
11
  url: new Secret("postgresql://app_be@127.0.0.1:5432/postgres"),
12
12
  schema: "public",
13
13
  }],
@@ -0,0 +1,4 @@
1
+ import { {{Singular}}Entity } from "./{{name}}.entity"
2
+
3
+ /** The entities of the {{name}} capability, named in PRIMARY_ENTITIES of platform/database/entities.ts. */
4
+ export const {{nameCamel}}Entities = [{{Singular}}Entity]
@@ -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
+ }
@@ -1,2 +1,3 @@
1
1
  export { {{upper}}_ERROR_KINDS } from "./errors/{{name}}.error"
2
2
  export { {{Name}}Module } from "./{{name}}.module"
3
+ export { {{nameCamel}}Entities } from "./persistence/entities/index"
@@ -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 Supabase PostgreSQL connection. */
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, requireOwnedRow } from "@modules/platform/database"
3
+ import { InjectPrimaryEntityManager, requireOwnedEntity } from "@modules/platform/database"
4
4
  import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
5
- import type { {{Name}}Row } from "./persistence/{{name}}.rows"
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<{{Name}}Row, "id">
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 answered; an insert that answered none throws the typed not-created error. */
17
- const createdRow = ([row]: ReadonlyArray<{{Name}}Result>): {{Name}}Result => {
18
- if (row === undefined) throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotCreated })
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 requireOwnedRow<{{Name}}Result>(this.entityManager, FIND_{{upper}}, id, principalId, notFound)
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.query<Array<{{Name}}Result>>(INSERT_{{upper}}, [principalId]).then(createdRow)
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, requireOwnedRow } from "@modules/platform/database"
3
+ import { InjectPrimaryEntityManager, requireOwnedEntity } from "@modules/platform/database"
4
4
  import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
5
- import type { {{Name}}Row } from "./persistence/{{name}}.rows"
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<{{Name}}Row, "id">
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 requireOwnedRow<{{Name}}Result>(this.entityManager, FIND_{{upper}}, id, principalId, notFound)
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 { createClient } from "@supabase/supabase-js"
2
- import type { Session, SupabaseClient } from "@supabase/supabase-js"
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
- let authentication: SupabaseClient["auth"] | undefined
6
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null
7
7
 
8
- /**
9
- * The one Supabase client of the front end, used for Supabase Auth only (the PKCE flow runs in the browser: a returning
10
- * redirect's code is exchanged by `detectSessionInUrl`, no route handler). It never calls `.from()`, `.rpc()` or storage:
11
- * data goes through modules/api to the back end, which verifies the access token.
12
- */
13
- const authOf = (): SupabaseClient["auth"] =>
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
- // {{> fe/auth/supabase-client.ts.tpl}}
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 Supabase project origin, read by modules/auth only. */
10
- export const supabaseUrl = (): string => required("NEXT_PUBLIC_SUPABASE_URL", process.env.NEXT_PUBLIC_SUPABASE_URL)
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)