create-ailk 0.11.5 → 0.12.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 (77) hide show
  1. package/component-catalog.json +35 -2
  2. package/component-catalog.md +9 -6
  3. package/dist/add-module.js +13 -0
  4. package/dist/module-architecture.d.ts +8 -0
  5. package/dist/module-architecture.js +33 -0
  6. package/dist/paid-sections.d.ts +23 -0
  7. package/dist/paid-sections.js +119 -0
  8. package/dist/programmatic.js +8 -0
  9. package/package.json +3 -3
  10. package/templates/.env.example +69 -3
  11. package/templates/apps/api/.env.example +22 -0
  12. package/templates/apps/api/CLAUDE.md +19 -7
  13. package/templates/apps/api/package.json +1 -0
  14. package/templates/apps/api/src/bin/spokes.ts +137 -0
  15. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  16. package/templates/apps/api/src/config/index.ts +97 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  18. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  19. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  20. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  21. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  22. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  23. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  24. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  25. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  26. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  27. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  28. package/templates/apps/api/src/server.ts +48 -0
  29. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  30. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  31. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  32. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  33. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  34. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  35. package/templates/apps/api/src/services/project-listings.ts +209 -18
  36. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  37. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  38. package/templates/apps/web/.env.example +14 -0
  39. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  40. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  41. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  42. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  43. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  44. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  45. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  46. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  47. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  48. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  49. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  50. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  51. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  53. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  54. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  55. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  56. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  57. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  58. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/jest.config.cjs +1 -1
  61. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  62. package/templates/apps/web/lib/listings.tsx +472 -0
  63. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  64. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  65. package/templates/apps/web/messages/en.json +10 -0
  66. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  67. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  68. package/templates/database/CHANGELOG.md +34 -0
  69. package/templates/database/__tests__/build.test.ts +131 -0
  70. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  71. package/templates/database/auth/schema.prisma +31 -0
  72. package/templates/database/config/schema.prisma +26 -0
  73. package/templates/database/inbox/schema.prisma +21 -0
  74. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  75. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  76. package/templates/database/package.json +3 -3
  77. package/versions.json +34 -34
@@ -1,92 +1,54 @@
1
+ // oss: false
2
+ import { loadUiCommerce } from "../../lib/paid-sections.generated";
3
+
1
4
  /**
2
- * Lazily registers the paid `@working-theory/ui-commerce` slice's sections
3
- * (`payment-receipt`) — never a static top-level import (#6084, code-review
4
- * finding on this issue's PR).
5
+ * Registers the paid `@working-theory/ui-commerce` slice's sections
6
+ * (`payment-receipt`) once per process, through the generated paid-sections
7
+ * registry (#6084, #7536).
5
8
  *
6
9
  * `/purchase/success` and `/purchase/cancel` ship in the BASE TREE
7
10
  * (`surfaces.ts` `CORE_ROUTE_DIRS`, D12) so a site that buys the `commerce`
8
- * add-on after its last deploy gets working return pages without a
9
- * redeploy. But a KEYLESS scaffold carries NO `ui-commerce` dependency by
10
- * design — `packages/create-ailk/scripts/build-templates.mjs`'s
11
- * `transformPaidUiPackages()` strips it from a keyless `apps/web/package.json`,
12
- * and `__tests__/paid-module-honest-states.test.ts`'s "#5581 U6" suite
13
- * asserts a keyless scaffold declares NONE of the four paid `ui-*` packages
14
- * as a dependency. A plain top-level, STATIC named-import of that package's
15
- * `registerCommerceSections` export would leave every keyless customer's
16
- * build failing with `ERR_MODULE_NOT_FOUND` — webpack resolves a static
17
- * import at BUILD time regardless of whether the code path importing it
18
- * ever executes at runtime, so gating the CALL at request time (as
19
- * `resolveCommerceClaim()` already does) is not enough on its own.
11
+ * add-on after its last deploy gets working return pages without a redeploy.
12
+ * This file stays in the base tree for the same reason (#6084 D12, disposed
13
+ * again on #6440): slicing it out would take the return pages with it.
20
14
  *
21
- * The `webpackIgnore` magic comment tells webpack to leave this `import()`
22
- * unresolved at build time; Node's own ESM loader then attempts it at
23
- * RUNTIME, where a keyless deployment's absent package throws a genuine
24
- * (caught) resolution error instead of a build failure. This repo's `dev`
25
- * and `build` scripts both force `--webpack` (never Turbopack, which does
26
- * not honor this comment), so the magic comment is reliably honored here.
15
+ * Whether the paid package is carried is decided at SCAFFOLD time, not here.
16
+ * `create-ailk` writes `apps/web/lib/paid-sections.generated.ts` from this
17
+ * app's `package.json`: a carried package is a plain `import()` that webpack
18
+ * bundles, and an absent one is a stub that answers `null`. So a KEYLESS build
19
+ * type-checks and builds with no paid package anywhere in its import graph, and
20
+ * `loadUiCommerce()` answers `null` there. The earlier runtime-loaded import
21
+ * handed resolution to Node's ESM loader, which cannot read our `dist` and
22
+ * answered `false` in production (#7536).
27
23
  *
28
24
  * On success, the real `payment-receipt` renderer is registered and the
29
- * caller may build a `payment-receipt` section as usual. On failure (the
30
- * package genuinely is not installed), the caller MUST NOT construct a bare
31
- * `payment-receipt` section anyway — `SectionRenderer`'s dispatch table
32
- * falls back to `ModuleAbsent`, which renders `null` in production
25
+ * caller may build a `payment-receipt` section as usual. On `false` (the
26
+ * package is not carried), the caller MUST NOT construct a bare
27
+ * `payment-receipt` section anyway — `SectionRenderer`'s dispatch table falls
28
+ * back to `ModuleAbsent`, which renders `null` in production
33
29
  * (`packages/ui/src/sections/ModuleAbsent.tsx`) — silently dropping the
34
30
  * title/message/status that is this page's entire point. The caller must
35
31
  * build an OSS `content-block` fallback carrying the same copy instead.
36
32
  *
33
+ * A package that IS carried but whose import rejects is a broken deploy: the
34
+ * rejection propagates and the page errors, never degrading to `false`. Only
35
+ * absence degrades, and only a stub produces it.
36
+ *
37
37
  * Registered (or found absent) once per process — memoised, so a concurrent
38
- * request never double-registers or double-attempts a failed resolution.
39
- * This is a build-presence fact, not the license claim itself (which
40
- * `resolveCommerceClaim()` deliberately re-checks every request); a
41
- * process only changes which packages are on disk via a fresh deploy, which
42
- * is a fresh process.
38
+ * request never double-registers. A rejected registration stays memoised: a
39
+ * process cannot recover from a bundled chunk that fails. This is a
40
+ * build-presence fact, not the license claim itself (which
41
+ * `resolveCommerceClaim()` deliberately re-checks every request).
43
42
  */
44
43
  let registration: Promise<boolean> | undefined;
45
44
 
46
- /**
47
- * The paid package's specifier, held in a CONST rather than written inline at
48
- * the `import()` call (#6440 AC2).
49
- *
50
- * `webpackIgnore` defers only WEBPACK's resolution; `tsc` resolves a dynamic
51
- * `import()` whose argument is a string LITERAL exactly as eagerly as a static
52
- * one, so the literal form failed a keyless scaffold's type-check with TS2307
53
- * "Cannot find module '@working-theory/ui-commerce'" — the build error #6440
54
- * was filed for. This monorepo never saw it: `packages-paid/ui-commerce` is on
55
- * disk here and `apps/web/package.json` declares it `workspace:*`, so the
56
- * specifier resolves and the defect is reachable only from a scaffold.
57
- *
58
- * A non-literal specifier is the whole fix. `tsc` does not attempt module
59
- * resolution on an `import()` it cannot constant-fold, so the paid package's
60
- * ABSENCE stops being a type error — while webpack still honours the magic
61
- * comment beside it and Node still performs the real resolution at runtime,
62
- * leaving the `catch` below as the one and only thing that observes whether
63
- * the package is installed. The behaviour is unchanged in both directions:
64
- * a licensed deployment registers the sections; a keyless one returns false.
65
- *
66
- * The file deliberately stays in the BASE TREE rather than moving into the
67
- * commerce slice: `/purchase/success` and `/purchase/cancel` import it
68
- * statically, so slicing it out would take the return pages with it and
69
- * regress #6084 D12 (disposed again on #6440, 2026-09-22).
70
- */
71
- const COMMERCE_UI_PACKAGE = "@working-theory/ui-commerce";
72
-
73
- /** The one export this module needs from the paid package. */
74
- interface CommerceSectionsModule {
75
- registerCommerceSections: () => void;
76
- }
77
-
78
45
  export function ensureCommerceSectionsRegistered(): Promise<boolean> {
79
46
  if (!registration) {
80
47
  registration = (async () => {
81
- try {
82
- const mod = (await import(
83
- /* webpackIgnore: true */ COMMERCE_UI_PACKAGE
84
- )) as CommerceSectionsModule;
85
- mod.registerCommerceSections();
86
- return true;
87
- } catch {
88
- return false;
89
- }
48
+ const mod = await loadUiCommerce();
49
+ if (mod === null) return false;
50
+ mod.registerCommerceSections();
51
+ return true;
90
52
  })();
91
53
  }
92
54
  return registration;
@@ -1,5 +1,39 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.34.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#7566](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7566) [`64a3245`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/64a324556595861b42166a49d98a4c73cef5115a) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `build` runs `prisma generate` first and always re-emits `dist/client` ([#7565](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7565)), so a stale or skipped generated client can no longer be built into a release. Before this, `build` was `tsc -b` alone: it never regenerated the client, and `tsc -b` trusted `tsconfig.build.tsbuildinfo`, so `database@0.14.0` shipped without `FlowCheckout` and `database@0.34.0` without `Spoke`, `SchedulingConnection` and `AppointmentOrigin`. `db:generate` is now `pnpm run build`, so it still generates and builds, once.
8
+
9
+ - Updated dependencies [[`4df3e2c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/4df3e2cd0f4d22471f7422ada4018040ecca402b)]:
10
+ - @working-theory/ids@0.9.1
11
+ - @working-theory/validation@0.34.1
12
+
13
+ ## 0.34.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#7544](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7544) [`aeefe93`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/aeefe93ea62d4b1c96da18f07b8a68674923e091) Thanks [@samuelhenry](https://github.com/samuelhenry)! - A hub API can register its spokes, and a spoke's public reads and writes resolve to its own workspace ([#7532](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7532)).
18
+
19
+ - `@working-theory/database`: two new tables. `spokes` (auth) binds one spoke to one workspace and stores the SHA-256 digest of its hub-minted key, never the key. `appointment_origins` (inbox) records which workspace a booking made through a spoke belongs to. Migration `20261003120000_spoke_registry` is purely additive.
20
+ - `@working-theory/ids`: two registered prefixes, `spoke` and `apptorg`.
21
+ - `@working-theory/api-client`: `get`, `post` and `patch` send `AILK_SPOKE_KEY` as `x-ailk-spoke-key` on server-side calls over `https:` or loopback only, and refuse to follow a redirect while they carry it. With the variable unset, every call is unchanged. `isSafeTransport`, `LOOPBACK_HOSTS` and `SAFE_HEADER_TOKEN` move from `leads.ts` into `http.ts`.
22
+
23
+ - [#7547](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7547) [`d519d9f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d519d9ff7db3059375842d0729c482f94f74a13d) Thanks [@samuelhenry](https://github.com/samuelhenry)! - A scheduling connection per workspace ([#7533](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7533)): each workspace can read and receive bookings through its own scheduling provider account.
24
+
25
+ - **`@working-theory/database`** — new `SchedulingConnection` model (`scheduling_connections`, config namespace): one row per workspace holding the provider id, the credential and the webhook secret as AES-256-GCM ciphertext, an optional base URL and a unique webhook routing token. Migration `20261003130000_scheduling_connection`.
26
+ - **`@working-theory/ids`** — registers the `schconn` prefix for the new model.
27
+ - **`@working-theory/validation`** — `SchedulingProviderIdSchema` and the strict `SchedulingConnectionInputSchema` (`{ provider, apiKey, baseUrl? }`; the key is printable ASCII with no whitespace or control byte).
28
+ - **`@working-theory/admin`** — the Meetings "not connected" state gains a Connect form for owners, showing the webhook URL and one-time secret to paste into the provider.
29
+ - **`create-ailk`** — the `scheduling` slice carries the new connection and resolution files and their suites.
30
+
31
+ ### Patch Changes
32
+
33
+ - Updated dependencies [[`84c8f52`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/84c8f5206f1393f079ae2e542f352c03c3605488), [`a83dca7`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a83dca7cb2d327f990e53154fcfca9be2268dc8a), [`aeefe93`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/aeefe93ea62d4b1c96da18f07b8a68674923e091), [`d519d9f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d519d9ff7db3059375842d0729c482f94f74a13d), [`8f835bb`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/8f835bbc57059acfef157ffc60a5b250be6981e7)]:
34
+ - @working-theory/validation@0.34.0
35
+ - @working-theory/ids@0.9.0
36
+
3
37
  ## 0.33.0
4
38
 
5
39
  ### Patch Changes
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @file build.test.ts
3
+ * @description The package `build` cannot emit a stale generated client
4
+ * (#7565). `build` used to be `tsc -b` alone: it never ran `prisma generate`,
5
+ * and `tsc -b` trusts `tsconfig.build.tsbuildinfo`, so a checkout whose
6
+ * generated client predated a schema change built — and reported as built —
7
+ * a dist missing the new models (database@0.14.0 shipped without
8
+ * FlowCheckout, database@0.34.0 without Spoke).
9
+ *
10
+ * The suite runs the package's REAL scripts and tsconfigs against a copy of
11
+ * the package in a temp dir, so it never touches this package's generated
12
+ * client or dist (the tenancy/flows configs still build `packages/ids` and
13
+ * `packages/validation` in place when those are stale, as any database build
14
+ * does). The copy keeps the real model-free `schema.prisma`
15
+ * (generator + datasource) and swaps the namespace schemas for one probe
16
+ * model, which keeps both builds to seconds. `node_modules` and the sibling
17
+ * `packages/` the tenancy/flows configs reference are symlinked in, and the
18
+ * root `tsconfig.base.json` is copied to where `extends` expects it.
19
+ */
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import {
23
+ cpSync,
24
+ existsSync,
25
+ mkdtempSync,
26
+ readFileSync,
27
+ rmSync,
28
+ symlinkSync,
29
+ writeFileSync,
30
+ } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+
34
+ const PACKAGE_DIR = join(__dirname, "..");
35
+ const REPO_ROOT = join(PACKAGE_DIR, "..");
36
+
37
+ const PACKAGE_FILES = [
38
+ "package.json",
39
+ "prisma.config.ts",
40
+ "schema.prisma",
41
+ "tenancy.ts",
42
+ "tsconfig.build.json",
43
+ "tsconfig.tenancy.json",
44
+ "tsconfig.flows.json",
45
+ ];
46
+
47
+ const BUILD_TIMEOUT_MS = 180_000;
48
+
49
+ let root: string;
50
+ let db: string;
51
+
52
+ function run(script: string): void {
53
+ try {
54
+ execFileSync("pnpm", ["run", script], { cwd: db, stdio: "pipe" });
55
+ } catch (error) {
56
+ // tsc reports type errors on stdout, which execFileSync's message omits.
57
+ const { stdout } = error as { stdout?: Buffer };
58
+ throw new Error(`pnpm run ${script} failed:\n${stdout?.toString() ?? ""}`, {
59
+ cause: error,
60
+ });
61
+ }
62
+ }
63
+
64
+ function addModel(name: string): void {
65
+ writeFileSync(
66
+ join(db, `${name}.prisma`),
67
+ `model ${name} {\n id String @id\n}\n`,
68
+ );
69
+ }
70
+
71
+ function emitted(file: string): string {
72
+ return readFileSync(join(db, "dist", "client", "internal", file), "utf8");
73
+ }
74
+
75
+ beforeAll(() => {
76
+ root = mkdtempSync(join(tmpdir(), "ailk-database-build-"));
77
+ db = join(root, "database");
78
+ cpSync(
79
+ join(REPO_ROOT, "tsconfig.base.json"),
80
+ join(root, "tsconfig.base.json"),
81
+ );
82
+ symlinkSync(join(REPO_ROOT, "packages"), join(root, "packages"), "dir");
83
+ for (const file of PACKAGE_FILES) {
84
+ cpSync(join(PACKAGE_DIR, file), join(db, file));
85
+ }
86
+ cpSync(join(PACKAGE_DIR, "flows"), join(db, "flows"), { recursive: true });
87
+ symlinkSync(
88
+ join(PACKAGE_DIR, "node_modules"),
89
+ join(db, "node_modules"),
90
+ "dir",
91
+ );
92
+
93
+ // The state every release that shipped a stale client started from: the
94
+ // client was generated and built once, and tsc -b recorded it as built.
95
+ addModel("BuildProbeAlpha");
96
+ run("db:generate");
97
+ }, BUILD_TIMEOUT_MS);
98
+
99
+ afterAll(() => {
100
+ if (root) rmSync(root, { recursive: true, force: true });
101
+ });
102
+
103
+ describe("@working-theory/database build", () => {
104
+ it(
105
+ "emits a model added to the schema after the last build, despite an existing tsconfig.build.tsbuildinfo",
106
+ () => {
107
+ expect(existsSync(join(db, "tsconfig.build.tsbuildinfo"))).toBe(true);
108
+ expect(emitted("class.d.ts")).not.toContain("buildProbeBravo");
109
+
110
+ addModel("BuildProbeBravo");
111
+ run("build");
112
+
113
+ expect(emitted("class.d.ts")).toContain("get buildProbeBravo()");
114
+ expect(emitted("class.js")).toContain("BuildProbeBravo");
115
+ },
116
+ BUILD_TIMEOUT_MS,
117
+ );
118
+
119
+ it(
120
+ "re-emits a deleted dist/client although the generated sources did not change",
121
+ () => {
122
+ rmSync(join(db, "dist", "client"), { recursive: true, force: true });
123
+
124
+ run("build");
125
+
126
+ expect(emitted("class.d.ts")).toContain("get buildProbeAlpha()");
127
+ expect(emitted("class.js")).toContain("BuildProbeAlpha");
128
+ },
129
+ BUILD_TIMEOUT_MS,
130
+ );
131
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @file spoke-registry.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the hub's spoke registry and the booking-origin
5
+ * record (#7532, spec docs/specs/7532-a-spoke-s-public-reads-spec.md D1, D5,
6
+ * D8; AC-2). Requires DATABASE_URL pointing at a Postgres with the migrations
7
+ * applied — the whole suite SKIPS when DATABASE_URL is unset, keeping the
8
+ * workspace Jest run DB-free (the repo convention; the
9
+ * `apps/api Route Tests (Postgres)` workflow exercises it).
10
+ *
11
+ * Uses the `pg` driver directly, as `prefixed-ids.db.test.ts` does. What it
12
+ * proves that a mock cannot:
13
+ * 1. The five format CHECKs reject a malformed id.
14
+ * 2. The `appointment_origins` unique key refuses a second origin for one
15
+ * booking uid.
16
+ * 3. Deleting a workspace cascades its `spokes`.
17
+ * 4. The per-booking advisory lock serializes: while connection A's
18
+ * transaction holds the lock for uidA, connection B cannot take it, and
19
+ * can once A commits; a different uid's lock is free throughout.
20
+ */
21
+ import { Client } from "pg";
22
+
23
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
24
+
25
+ const V7 = "01920000-0000-7000-8000";
26
+ const LOCK_SQL =
27
+ "SELECT pg_try_advisory_xact_lock(hashtextextended($1, 0)) AS locked";
28
+
29
+ describeDb("the spoke registry and booking origins (#7532, live DB)", () => {
30
+ let a: Client;
31
+ let b: Client;
32
+ let workspaceId: string;
33
+
34
+ beforeAll(async () => {
35
+ a = new Client({ connectionString: process.env.DATABASE_URL });
36
+ b = new Client({ connectionString: process.env.DATABASE_URL });
37
+ await a.connect();
38
+ await b.connect();
39
+
40
+ const org = (
41
+ await a.query<{ id: string }>(
42
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
43
+ VALUES ('Spoke Registry Org', 'spoke7532-org', now()) RETURNING "id"`,
44
+ )
45
+ ).rows[0]!;
46
+ workspaceId = (
47
+ await a.query<{ id: string }>(
48
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
49
+ VALUES ($1, 'Spoke Registry Workspace', 'spoke7532-ws', now()) RETURNING "id"`,
50
+ [org.id],
51
+ )
52
+ ).rows[0]!.id;
53
+ });
54
+
55
+ afterAll(async () => {
56
+ await a.query(
57
+ `DELETE FROM "appointment_origins" WHERE "provider_booking_uid" LIKE 'spoke7532-%'`,
58
+ );
59
+ await a.query(`DELETE FROM "organizations" WHERE "slug" LIKE 'spoke7532-%'`);
60
+ await a.end();
61
+ await b.end();
62
+ });
63
+
64
+ /** Insert one spoke; the database mints its id. */
65
+ async function insertSpoke(
66
+ client: Client,
67
+ overrides: { id?: string; workspaceId?: string; hash?: string } = {},
68
+ ): Promise<string> {
69
+ const { rows } = await client.query<{ id: string }>(
70
+ `INSERT INTO "spokes" (${overrides.id ? '"id", ' : ""}"workspace_id", "name", "credential_hash", "credential_prefix", "updated_at")
71
+ VALUES (${overrides.id ? "$4, " : ""}$1, 'acme', $2, $3, now()) RETURNING "id"`,
72
+ [
73
+ overrides.workspaceId ?? workspaceId,
74
+ overrides.hash ?? "a".repeat(64),
75
+ "ailk_spk_abcdefgh",
76
+ ...(overrides.id ? [overrides.id] : []),
77
+ ],
78
+ );
79
+ return rows[0]!.id;
80
+ }
81
+
82
+ describe("format CHECKs (ADR-0031)", () => {
83
+ it("a spoke takes a database-minted spoke_ id", async () => {
84
+ const id = await insertSpoke(a, { hash: "1".repeat(64) });
85
+ expect(id).toMatch(/^spoke_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
86
+ });
87
+
88
+ it("REJECTS a malformed spoke id", async () => {
89
+ await expect(
90
+ insertSpoke(a, { id: "bad", hash: "2".repeat(64) }),
91
+ ).rejects.toThrow(/spokes_id_format/);
92
+ });
93
+
94
+ it("REJECTS a malformed spoke workspace_id", async () => {
95
+ // The format CHECK fires before the foreign key.
96
+ await expect(
97
+ insertSpoke(a, { workspaceId: "not-a-workspace", hash: "3".repeat(64) }),
98
+ ).rejects.toThrow(/spokes_workspace_id_format/);
99
+ });
100
+
101
+ it.each([
102
+ ["id", "appointment_origins_id_format"],
103
+ ["spoke_id", "appointment_origins_spoke_id_format"],
104
+ ["workspace_id", "appointment_origins_workspace_id_format"],
105
+ ])("REJECTS a malformed origin %s", async (column, constraint) => {
106
+ const values: Record<string, string> = {
107
+ id: `apptorg_${V7}-0000000000e1`,
108
+ spoke_id: `spoke_${V7}-0000000000c1`,
109
+ workspace_id: workspaceId,
110
+ };
111
+ values[column] = "bad";
112
+ await expect(
113
+ a.query(
114
+ `INSERT INTO "appointment_origins" ("id", "provider_booking_uid", "workspace_id", "spoke_id")
115
+ VALUES ($1, $2, $3, $4)`,
116
+ [values["id"], `spoke7532-${column}`, values["workspace_id"], values["spoke_id"]],
117
+ ),
118
+ ).rejects.toThrow(new RegExp(constraint));
119
+ });
120
+ });
121
+
122
+ describe("unique keys", () => {
123
+ it("a credential digest is unique", async () => {
124
+ await insertSpoke(a, { hash: "4".repeat(64) });
125
+ await expect(insertSpoke(a, { hash: "4".repeat(64) })).rejects.toThrow(
126
+ /spokes_credential_hash_key/,
127
+ );
128
+ });
129
+
130
+ it("refuses a second origin for one booking uid, and ON CONFLICT DO NOTHING absorbs it", async () => {
131
+ const insert = (spokeSuffix: string) =>
132
+ a.query(
133
+ `INSERT INTO "appointment_origins" ("provider", "provider_booking_uid", "workspace_id", "spoke_id")
134
+ VALUES ('cal', 'spoke7532-uidA', $1, $2)`,
135
+ [workspaceId, `spoke_${V7}-0000000000${spokeSuffix}`],
136
+ );
137
+ await insert("c1");
138
+ await expect(insert("c2")).rejects.toThrow(
139
+ /appointment_origins_provider_provider_booking_uid_key/,
140
+ );
141
+
142
+ const skipped = await a.query(
143
+ `INSERT INTO "appointment_origins" ("provider", "provider_booking_uid", "workspace_id", "spoke_id")
144
+ VALUES ('cal', 'spoke7532-uidA', $1, $2) ON CONFLICT DO NOTHING`,
145
+ [workspaceId, `spoke_${V7}-0000000000c3`],
146
+ );
147
+ expect(skipped.rowCount).toBe(0);
148
+ });
149
+ });
150
+
151
+ it("deleting a workspace deletes its spokes", async () => {
152
+ const org = (
153
+ await a.query<{ id: string }>(
154
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
155
+ VALUES ('Cascade Org', 'spoke7532-cascade', now()) RETURNING "id"`,
156
+ )
157
+ ).rows[0]!;
158
+ const doomed = (
159
+ await a.query<{ id: string }>(
160
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
161
+ VALUES ($1, 'Doomed', 'doomed', now()) RETURNING "id"`,
162
+ [org.id],
163
+ )
164
+ ).rows[0]!.id;
165
+ const spokeId = await insertSpoke(a, { workspaceId: doomed, hash: "5".repeat(64) });
166
+
167
+ await a.query(`DELETE FROM "workspaces" WHERE "id" = $1`, [doomed]);
168
+
169
+ const { rowCount } = await a.query(`SELECT 1 FROM "spokes" WHERE "id" = $1`, [spokeId]);
170
+ expect(rowCount).toBe(0);
171
+ });
172
+
173
+ it("the per-booking advisory lock serializes one uid and leaves another free", async () => {
174
+ const keyA = "appointment:cal:spoke7532-uidA";
175
+ const keyB = "appointment:cal:spoke7532-uidB";
176
+
177
+ await a.query("BEGIN");
178
+ await a.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [keyA]);
179
+
180
+ await b.query("BEGIN");
181
+ expect((await b.query(LOCK_SQL, [keyA])).rows[0].locked).toBe(false);
182
+ expect((await b.query(LOCK_SQL, [keyB])).rows[0].locked).toBe(true);
183
+ await b.query("COMMIT");
184
+
185
+ await a.query("COMMIT");
186
+
187
+ await b.query("BEGIN");
188
+ expect((await b.query(LOCK_SQL, [keyA])).rows[0].locked).toBe(true);
189
+ await b.query("COMMIT");
190
+ });
191
+ });
@@ -100,12 +100,43 @@ model Workspace {
100
100
  updatedAt DateTime @updatedAt @map("updated_at")
101
101
 
102
102
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
103
+ // #7532 — the hub's spoke registry rows bound to this workspace.
104
+ spokes Spoke[]
103
105
 
104
106
  @@unique([organizationId, slug])
105
107
  @@index([organizationId])
106
108
  @@map("workspaces")
107
109
  }
108
110
 
111
+ /// Spoke — one separately deployed site (a spoke) that calls this hub API
112
+ /// server-to-server, bound to exactly ONE workspace (#7532, spec
113
+ /// docs/specs/7532-a-spoke-s-public-reads-spec.md D1).
114
+ ///
115
+ /// The spoke authenticates with a hub-minted key sent in `x-ailk-spoke-key`.
116
+ /// Only the key's SHA-256 digest is stored (`credentialHash`) plus a short
117
+ /// recognisable `credentialPrefix`; the key itself never is. Several rows may
118
+ /// bind one workspace, so a key rotates by registering a second spoke and
119
+ /// revoking the first. `revokedAt` set refuses the key on the next request.
120
+ ///
121
+ /// Room to extend (operator amendment; built by neither this unit): REL-002's
122
+ /// sign-in client and #7533's SchedulingConnection each add ONE nullable,
123
+ /// unique column in their own migration, so a spoke stays one record.
124
+ model Spoke {
125
+ id String @id @default(dbgenerated("('spoke_'::text || (uuidv7())::text)")) @db.Text
126
+ workspaceId String @map("workspace_id")
127
+ name String @db.VarChar(120)
128
+ credentialHash String @unique @map("credential_hash") @db.Char(64)
129
+ credentialPrefix String @map("credential_prefix") @db.VarChar(24)
130
+ revokedAt DateTime? @map("revoked_at")
131
+ createdAt DateTime @default(now()) @map("created_at")
132
+ updatedAt DateTime @updatedAt @map("updated_at")
133
+
134
+ workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
135
+
136
+ @@index([workspaceId])
137
+ @@map("spokes")
138
+ }
139
+
109
140
  /// Membership — User ↔ Organization with a Role. Managed by the Better Auth
110
141
  /// organization plugin (modelName "membership"). Org-level: grants visibility
111
142
  /// into every workspace of the organization.
@@ -136,6 +136,32 @@ model WorkspaceMeetings {
136
136
  @@map("workspace_meetings")
137
137
  }
138
138
 
139
+ /// SchedulingConnection — a workspace's own scheduling provider account
140
+ /// (#7533 D1): the provider id, its credential and the per-connection webhook
141
+ /// entry point + secret, so each workspace reads and receives bookings through
142
+ /// its own account. One row per workspace, mirroring WorkspaceMeetings above:
143
+ /// a plain `workspaceId @unique` scalar, no relation, no foreign key (ADR 0009
144
+ /// invariant 1). The credential and the webhook secret are AES-256-GCM
145
+ /// ciphertext (`v1.<iv>.<tag>.<ct>`) under SCHEDULING_CREDENTIAL_KEY, bound to
146
+ /// the workspace and the column; only apps/api's
147
+ /// services/scheduling/connections.ts reads or writes them. `webhookToken` is
148
+ /// a 256-bit routing id, not an authenticator. `provider` has no CHECK — the
149
+ /// API accepts only the ids its factory table knows. Keep every `@map` in
150
+ /// lockstep with the migration's column names (see WorkspaceMeetings).
151
+ model SchedulingConnection {
152
+ id String @id @default(dbgenerated("('schconn_'::text || (uuidv7())::text)")) @db.Text
153
+ workspaceId String @unique @map("workspace_id")
154
+ provider String @db.VarChar(32)
155
+ credentialCiphertext String @map("credential_ciphertext") @db.Text
156
+ webhookToken String @unique @map("webhook_token") @db.VarChar(64)
157
+ webhookSecretCiphertext String @map("webhook_secret_ciphertext") @db.Text
158
+ baseUrl String? @map("base_url") @db.Text
159
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
160
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
161
+
162
+ @@map("scheduling_connections")
163
+ }
164
+
139
165
  /// SiteListingConfig — per-SITE project-listings config blob (#5033).
140
166
  ///
141
167
  /// The fourth site-config model, and the one keyed on `siteId` rather than
@@ -1131,3 +1131,24 @@ model Appointment {
1131
1131
  @@index([workspaceId])
1132
1132
  @@map("appointments")
1133
1133
  }
1134
+
1135
+ /// AppointmentOrigin — which workspace a booking made THROUGH A SPOKE belongs
1136
+ /// to (#7532, spec D5). Written by `POST /v1/schedule/book` the moment Cal
1137
+ /// answers, and consulted by the deployment-wide Cal.com webhook BEFORE the
1138
+ /// site alias, so a spoke's booking is stored in the spoke's workspace.
1139
+ ///
1140
+ /// One origin per booking uid: the unique key leaves `site_id` out. No
1141
+ /// relation directive (ADR 0009) — `workspace_id` and `spoke_id` are plain
1142
+ /// columns with format CHECKs, joined in apps/api/src/services/.
1143
+ model AppointmentOrigin {
1144
+ id String @id @default(dbgenerated("('apptorg_'::text || (uuidv7())::text)")) @db.Text
1145
+ provider String @default("cal") @db.VarChar(32)
1146
+ providerBookingUid String @map("provider_booking_uid") @db.VarChar(200)
1147
+ workspaceId String @map("workspace_id")
1148
+ spokeId String @map("spoke_id")
1149
+ createdAt DateTime @default(now()) @map("created_at")
1150
+
1151
+ @@unique([provider, providerBookingUid])
1152
+ @@index([workspaceId])
1153
+ @@map("appointment_origins")
1154
+ }