create-ailk 0.11.5 → 0.12.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.
Files changed (76) 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 +24 -0
  69. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  70. package/templates/database/auth/schema.prisma +31 -0
  71. package/templates/database/config/schema.prisma +26 -0
  72. package/templates/database/inbox/schema.prisma +21 -0
  73. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  74. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  75. package/templates/database/package.json +1 -1
  76. 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,29 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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)).
8
+
9
+ - `@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.
10
+ - `@working-theory/ids`: two registered prefixes, `spoke` and `apptorg`.
11
+ - `@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`.
12
+
13
+ - [#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.
14
+
15
+ - **`@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`.
16
+ - **`@working-theory/ids`** — registers the `schconn` prefix for the new model.
17
+ - **`@working-theory/validation`** — `SchedulingProviderIdSchema` and the strict `SchedulingConnectionInputSchema` (`{ provider, apiKey, baseUrl? }`; the key is printable ASCII with no whitespace or control byte).
18
+ - **`@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.
19
+ - **`create-ailk`** — the `scheduling` slice carries the new connection and resolution files and their suites.
20
+
21
+ ### Patch Changes
22
+
23
+ - 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)]:
24
+ - @working-theory/validation@0.34.0
25
+ - @working-theory/ids@0.9.0
26
+
3
27
  ## 0.33.0
4
28
 
5
29
  ### Patch Changes
@@ -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
+ }
@@ -0,0 +1,80 @@
1
+ -- #7532 — the hub's spoke registry and the booking-origin record (spec
2
+ -- docs/specs/7532-a-spoke-s-public-reads-spec.md D1, D5).
3
+ --
4
+ -- spokes (auth) — one row per separately deployed site that
5
+ -- calls this hub, bound to ONE workspace. It
6
+ -- stores the SHA-256 digest of the spoke's
7
+ -- hub-minted key, never the key. Deleting a
8
+ -- workspace deletes its spokes (the FK below).
9
+ -- appointment_origins (inbox) — (provider, booking uid) → workspace, written
10
+ -- when a spoke's booking is created so the
11
+ -- deployment-wide Cal.com webhook stores the
12
+ -- booking in the spoke's workspace. One origin
13
+ -- per booking uid. No foreign key and no
14
+ -- relation (ADR 0009): workspace_id and
15
+ -- spoke_id are plain columns with format CHECKs.
16
+ --
17
+ -- PURELY ADDITIVE: two new tables, their indexes and one FK to "workspaces".
18
+ -- No statement touches an existing table's rows or columns, so there is no
19
+ -- pre-assertion block — this migration cannot lose a row.
20
+ --
21
+ -- Rollback: DROP TABLE "appointment_origins"; DROP TABLE "spokes". Safe while
22
+ -- nothing but #7532's own code reads them.
23
+
24
+ -- CreateTable
25
+ CREATE TABLE "spokes" (
26
+ "id" TEXT NOT NULL DEFAULT ('spoke_'::text || (uuidv7())::text),
27
+ "workspace_id" TEXT NOT NULL,
28
+ "name" VARCHAR(120) NOT NULL,
29
+ "credential_hash" CHAR(64) NOT NULL,
30
+ "credential_prefix" VARCHAR(24) NOT NULL,
31
+ "revoked_at" TIMESTAMP(3),
32
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
33
+ "updated_at" TIMESTAMP(3) NOT NULL,
34
+
35
+ CONSTRAINT "spokes_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ -- CreateTable
39
+ CREATE TABLE "appointment_origins" (
40
+ "id" TEXT NOT NULL DEFAULT ('apptorg_'::text || (uuidv7())::text),
41
+ "provider" VARCHAR(32) NOT NULL DEFAULT 'cal',
42
+ "provider_booking_uid" VARCHAR(200) NOT NULL,
43
+ "workspace_id" TEXT NOT NULL,
44
+ "spoke_id" TEXT NOT NULL,
45
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+
47
+ CONSTRAINT "appointment_origins_pkey" PRIMARY KEY ("id")
48
+ );
49
+
50
+ -- CreateIndex
51
+ -- The lookup a spoke's every request makes (`findSpokeByKeyHash`).
52
+ CREATE UNIQUE INDEX "spokes_credential_hash_key" ON "spokes"("credential_hash");
53
+
54
+ -- CreateIndex
55
+ CREATE INDEX "spokes_workspace_id_idx" ON "spokes"("workspace_id");
56
+
57
+ -- CreateIndex
58
+ CREATE INDEX "appointment_origins_workspace_id_idx" ON "appointment_origins"("workspace_id");
59
+
60
+ -- CreateIndex
61
+ -- One origin per booking uid (spec D5). The bind inserts with
62
+ -- ON CONFLICT DO NOTHING against this key.
63
+ CREATE UNIQUE INDEX "appointment_origins_provider_provider_booking_uid_key" ON "appointment_origins"("provider", "provider_booking_uid");
64
+
65
+ -- AddForeignKey
66
+ ALTER TABLE "spokes" ADD CONSTRAINT "spokes_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
67
+
68
+ -- ADR-0031 rule 3 — spokes: spoke_, appointment_origins: apptorg_ (registered
69
+ -- in packages/ids/src/registry.ts; this migration's path is added to
70
+ -- migration-sync.test.ts's PK_CHECK_MIGRATIONS).
71
+ ALTER TABLE "spokes" ALTER COLUMN "id" TYPE text COLLATE "C";
72
+ ALTER TABLE "spokes" ALTER COLUMN "id" SET DEFAULT ('spoke_'::text || (uuidv7())::text);
73
+ ALTER TABLE "spokes" ADD CONSTRAINT "spokes_id_format" CHECK ("id" ~ '^spoke_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
74
+ ALTER TABLE "spokes" ADD CONSTRAINT "spokes_workspace_id_format" CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
75
+
76
+ ALTER TABLE "appointment_origins" ALTER COLUMN "id" TYPE text COLLATE "C";
77
+ ALTER TABLE "appointment_origins" ALTER COLUMN "id" SET DEFAULT ('apptorg_'::text || (uuidv7())::text);
78
+ ALTER TABLE "appointment_origins" ADD CONSTRAINT "appointment_origins_id_format" CHECK ("id" ~ '^apptorg_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
79
+ ALTER TABLE "appointment_origins" ADD CONSTRAINT "appointment_origins_spoke_id_format" CHECK ("spoke_id" ~ '^spoke_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
80
+ ALTER TABLE "appointment_origins" ADD CONSTRAINT "appointment_origins_workspace_id_format" CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
@@ -0,0 +1,49 @@
1
+ -- #7533 — the SchedulingConnection model: a workspace's own scheduling
2
+ -- provider account (spec docs/specs/7533-a-scheduling-connection-per-workspace-spec.md
3
+ -- D1). One row per workspace, keyed `@unique(workspace_id)` like its config
4
+ -- sibling workspace_meetings; the credential and webhook secret columns hold
5
+ -- AES-256-GCM ciphertext written only by apps/api's
6
+ -- services/scheduling/connections.ts. `webhook_token` is the per-connection
7
+ -- webhook entry point's routing id, unique so a delivery resolves at most
8
+ -- one row.
9
+ --
10
+ -- One new table, two unique indexes, in the house style of
11
+ -- 20260919130000_workspace_meetings (CreateTable + CreateIndex immediately
12
+ -- followed by the ADR-0031 rule-3 id-format completion, in ONE migration
13
+ -- since the table is new and needs no retrofit). No `workspace_id_format`
14
+ -- CHECK and no `provider` CHECK, matching that same precedent (the API
15
+ -- accepts only the provider ids its factory table knows).
16
+ --
17
+ -- No backfill, no constraint relaxed, no existing table touched.
18
+ --
19
+ -- Rollback: DROP TABLE "scheduling_connections". Every stored connection is
20
+ -- lost; each workspace returns to the unconfigured state (or, on a
21
+ -- one-workspace deployment, to the CAL_API_KEY env fallback).
22
+
23
+ -- CreateTable
24
+ CREATE TABLE "scheduling_connections" (
25
+ "id" TEXT NOT NULL DEFAULT ('schconn_'::text || (uuidv7())::text),
26
+ "workspace_id" TEXT NOT NULL,
27
+ "provider" VARCHAR(32) NOT NULL,
28
+ "credential_ciphertext" TEXT NOT NULL,
29
+ "webhook_token" VARCHAR(64) NOT NULL,
30
+ "webhook_secret_ciphertext" TEXT NOT NULL,
31
+ "base_url" TEXT,
32
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
33
+ "updated_at" TIMESTAMPTZ NOT NULL,
34
+
35
+ CONSTRAINT "scheduling_connections_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ -- CreateIndex
39
+ CREATE UNIQUE INDEX "scheduling_connections_workspace_id_key" ON "scheduling_connections"("workspace_id");
40
+
41
+ -- CreateIndex
42
+ CREATE UNIQUE INDEX "scheduling_connections_webhook_token_key" ON "scheduling_connections"("webhook_token");
43
+
44
+ -- ADR-0031 rule 3 — scheduling_connections: schconn_ (registered in
45
+ -- packages/ids/src/registry.ts ENTITY_PREFIX.schedulingConnection; this
46
+ -- migration's path is added to migration-sync.test.ts's PK_CHECK_MIGRATIONS).
47
+ ALTER TABLE "scheduling_connections" ALTER COLUMN "id" TYPE text COLLATE "C";
48
+ ALTER TABLE "scheduling_connections" ALTER COLUMN "id" SET DEFAULT ('schconn_'::text || (uuidv7())::text);
49
+ ALTER TABLE "scheduling_connections" ADD CONSTRAINT "scheduling_connections_id_format" CHECK ("id" ~ '^schconn_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"