create-ailk 0.11.4 → 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 (81) hide show
  1. package/component-catalog.json +36 -3
  2. package/component-catalog.md +10 -7
  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/__tests__/content-write-production-boot.test.ts +104 -0
  15. package/templates/apps/api/src/__tests__/root-modules.test.ts +175 -0
  16. package/templates/apps/api/src/bin/spokes.ts +137 -0
  17. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  18. package/templates/apps/api/src/config/index.ts +97 -0
  19. package/templates/apps/api/src/config/modules.ts +38 -0
  20. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  21. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  22. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  24. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  25. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  26. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  27. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  28. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  29. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  30. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  31. package/templates/apps/api/src/server.ts +56 -1
  32. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  33. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +19 -0
  34. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  35. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  36. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  37. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  38. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  39. package/templates/apps/api/src/services/project-listings.ts +209 -18
  40. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  41. package/templates/apps/api/src/services/revalidation.ts +8 -1
  42. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  43. package/templates/apps/web/.env.example +14 -0
  44. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  45. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  46. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  47. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  48. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  50. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  51. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  53. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  54. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  55. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  56. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  57. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  58. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  61. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  62. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  63. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  65. package/templates/apps/web/jest.config.cjs +1 -1
  66. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  67. package/templates/apps/web/lib/listings.tsx +472 -0
  68. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  69. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  70. package/templates/apps/web/messages/en.json +49 -15
  71. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  72. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  73. package/templates/database/CHANGELOG.md +31 -0
  74. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  75. package/templates/database/auth/schema.prisma +31 -0
  76. package/templates/database/config/schema.prisma +26 -0
  77. package/templates/database/inbox/schema.prisma +21 -0
  78. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  79. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  80. package/templates/database/package.json +1 -1
  81. package/versions.json +34 -34
@@ -97,7 +97,7 @@
97
97
  "importPath": "@working-theory/ui-signal-collectors",
98
98
  "rootBarrel": "@working-theory/ui-signal-collectors",
99
99
  "sourceRoot": "packages-paid/ui-signal-collectors/src/sections",
100
- "count": 3
100
+ "count": 5
101
101
  },
102
102
  {
103
103
  "name": "signal-collectors-blocks",
@@ -105,7 +105,7 @@
105
105
  "importPath": "@working-theory/ui-signal-collectors",
106
106
  "rootBarrel": "@working-theory/ui-signal-collectors",
107
107
  "sourceRoot": "packages-paid/ui-signal-collectors/src/blocks",
108
- "count": 8
108
+ "count": 9
109
109
  },
110
110
  {
111
111
  "name": "commerce-sections",
@@ -821,7 +821,7 @@
821
821
  "sourcePath": "packages/ui/src/blocks/AssetLightbox.tsx",
822
822
  "importPath": "@working-theory/ui/blocks",
823
823
  "exported": true,
824
- "description": "The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy URL/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both its dialog and sheet forms (: one field array, walked once, shared by both forms).",
824
+ "description": "The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy link/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both layouts (: one field array, walked once, shared by both).",
825
825
  "descriptionSource": "doc-comment",
826
826
  "descriptionGrade": "strong",
827
827
  "sectionType": null
@@ -3213,6 +3213,28 @@
3213
3213
  "descriptionGrade": "strong",
3214
3214
  "sectionType": null
3215
3215
  },
3216
+ {
3217
+ "name": "ListingDetail",
3218
+ "tier": "signal-collectors-sections",
3219
+ "sourcePath": "packages-paid/ui-signal-collectors/src/sections/ListingDetail.tsx",
3220
+ "importPath": "@working-theory/ui-signal-collectors",
3221
+ "exported": true,
3222
+ "description": "One accepted entry's own page: its name as the page's `<h1>`, its summary, its own link, and its PROFILE — the applicant's answers to the listicle's Application fields, as label · value in the wizard's order. The profile is built and filtered server-side (`listingProfile`); this component renders what it is handed, in order, and nothing else.",
3223
+ "descriptionSource": "doc-comment",
3224
+ "descriptionGrade": "strong",
3225
+ "sectionType": null
3226
+ },
3227
+ {
3228
+ "name": "ListingShelves",
3229
+ "tier": "signal-collectors-sections",
3230
+ "sourcePath": "packages-paid/ui-signal-collectors/src/sections/ListingShelves.tsx",
3231
+ "importPath": "@working-theory/ui-signal-collectors",
3232
+ "exported": true,
3233
+ "description": "A listicle's accepted entries on its landing page, as two shelves partitioned from ONE list: `status === \"active\"` on one, `\"closed\"` on the other. `closed` is computed by the API from the window end; this component only reads it. Featured entries lead the active shelf (stable sort, so the API's order otherwise survives).",
3234
+ "descriptionSource": "doc-comment",
3235
+ "descriptionGrade": "strong",
3236
+ "sectionType": null
3237
+ },
3216
3238
  {
3217
3239
  "name": "ProjectListingShelves",
3218
3240
  "tier": "signal-collectors-sections",
@@ -3235,6 +3257,17 @@
3235
3257
  "descriptionGrade": "strong",
3236
3258
  "sectionType": null
3237
3259
  },
3260
+ {
3261
+ "name": "ListingCard",
3262
+ "tier": "signal-collectors-blocks",
3263
+ "sourcePath": "packages-paid/ui-signal-collectors/src/blocks/ListingCard.tsx",
3264
+ "importPath": "@working-theory/ui-signal-collectors",
3265
+ "exported": true,
3266
+ "description": "The tile for one accepted entry of a listicle's directory. Generic by name and by shape: it reads the directory's `ListingEntryPublic` — name, summary, featured flag, waitlist count — and none of the project-shaped columns `ProjectListingCard` renders. The whole tile is one anchor, composed from `Link` directly (the LinkCard precedent: Card renders a <div>).",
3267
+ "descriptionSource": "doc-comment",
3268
+ "descriptionGrade": "strong",
3269
+ "sectionType": null
3270
+ },
3238
3271
  {
3239
3272
  "name": "ListingFullPreview",
3240
3273
  "tier": "signal-collectors-blocks",
@@ -15,13 +15,13 @@ The component inventory of `@working-theory/ui` and the shells of `@working-theo
15
15
  3. Check the Primitives, Panels and Shells tables too — a low-level control, a collapsible layout container, or a route-level frame lives there, not under Sections or Blocks.
16
16
  4. Only then build. If you do build, you are declaring the capability is genuinely absent.
17
17
 
18
- Inventory: **304 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 6 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 3 signal-collectors-sections, 8 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 301 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
18
+ Inventory: **307 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 6 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 5 signal-collectors-sections, 9 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 304 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
19
19
 
20
- Description coverage: **300/304** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 225 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
20
+ Description coverage: **303/307** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 228 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
21
21
 
22
- Of those, **269** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
22
+ Of those, **272** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 89%**.
23
23
 
24
- Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 5/6 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 3/3 · **Signal-Collectors Blocks** 7/8 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
24
+ Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 5/6 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 5/5 · **Signal-Collectors Blocks** 8/9 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
25
25
 
26
26
  That grade is **mechanical**. It counts what it can measure — how much a description adds beyond the component's own name, how much of it is spent on wiring, whether the extraction came out damaged — and it cannot tell a fluent paragraph about the wrong subject from a useful one. A person reading these tables will find more rows unusable than the weak count admits. Treat it as a floor on the problem, not a measurement of it, and read the source before concluding a capability is absent.
27
27
 
@@ -134,7 +134,7 @@ Import: `import { X } from "@working-theory/ui/blocks";` (or from the `@working-
134
134
  | `ArticlePager` | The footer prev/next pager. Given the previous and next destinations — docs pages in tree order, or blog posts in date order — renders two link tiles (a directional label + the destination title). Either side may be absent (first / last item) — the present side still anchors to its edge. Renders nothing when both sides are absent. | `import { ArticlePager } from "@working-theory/ui/blocks"` |
135
135
  | `AskAiCommandGroup` | The ⌘K palette's composable "Ask AI" group; one CommandItem per provider seeding "About &lt;entity>: &lt;query>" (or the entity promptText when the query is empty); composed by the consumer's own CommandDialog. | `import { AskAiCommandGroup } from "@working-theory/ui/blocks"` |
136
136
  | `AskAiProviderRow` | The ONE shared provider-link row every ask-AI placement composes — the AskAi section (band + ticker variant) and the Hero `askAi` opt-in all render THIS block, never a per-placement fork. Renders a bounded set of five measured providers, each a deep link seeded with an AUTHORED `promptText` — the prompt is always consumer-supplied data, never derived from page context (headline, entity name, etc.). | `import { AskAiProviderRow } from "@working-theory/ui/blocks"` |
137
- | `AssetLightbox` | The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy URL/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both its dialog and sheet forms (: one field array, walked once, shared by both forms). | `import { AssetLightbox } from "@working-theory/ui/blocks"` |
137
+ | `AssetLightbox` | The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy link/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both layouts (: one field array, walked once, shared by both). | `import { AssetLightbox } from "@working-theory/ui/blocks"` |
138
138
  | `AuthCard` | Multi-flow auth card: sign-in, sign-up, invitation, forgot, reset and sent, from one skeleton. One self-contained card renders every auth surface a site needs: signing in, signing up, a closed sign-up that requires an invitation, requesting a password reset, choosing a new password, and the check-your-email state that follows a magic link or a reset request. | `import { AuthCard } from "@working-theory/ui/blocks"` |
139
139
  | `AwardBadgePlaceholder` | The labeled placeholder an Awards badge slot renders when no image has been supplied — mirrors MediaPlaceholder's role but in an award-chevron/ laurel badge silhouette rather than a generic photo frame, since an unfilled award slot should read as "an award badge goes here," not "a photo goes here." The shape (a laurel-flanked medallion over a chevron ribbon) is a hand-drawn SVG outline — never a copied G2/Capterra/Apollo asset. | internal — compose from `packages/ui/src/blocks/AwardBadgePlaceholder.tsx` |
140
140
  | `BackgroundPattern` | Decorative SVG dot-grid / line pattern for ContactForm section. Decorative block (generalized beyond its original single consumer). Renders an absolutely-positioned decoration behind a section's content. Originally the pattern anchored behind ContactForm's form column; SpotlightPanel composes it as a full-bleed section backdrop (dimmed + blurred, behind a centered card) — same block, a second placement. | `import { BackgroundPattern } from "@working-theory/ui/blocks"` |
@@ -452,7 +452,7 @@ Import: `import { X } from "@working-theory/ui-scheduling";` (or from the `@work
452
452
 
453
453
  ---
454
454
 
455
- ## Signal-Collectors Sections (3)
455
+ ## Signal-Collectors Sections (5)
456
456
 
457
457
  Paid — delivered by the `signal-collectors` module bundle, not present in a Free scaffold. Multi-step flow and listing surfaces — the page-level bands a signal-collection route is assembled from.
458
458
 
@@ -462,11 +462,13 @@ Import: `import { X } from "@working-theory/ui-signal-collectors";` (or from the
462
462
  | --- | --- | --- |
463
463
  | `FlowStepper` | Rendering a served flow config as a real multi-step form. | `import { FlowStepper } from "@working-theory/ui-signal-collectors"` |
464
464
  | `ListingApplicationFlow` | This is the thing a consuming site mounts INSTEAD of hand-writing three proxy routes, a save-on-advance form, and a decision about where a bearer token lives. | `import { ListingApplicationFlow } from "@working-theory/ui-signal-collectors"` |
465
+ | `ListingDetail` | One accepted entry's own page: its name as the page's `&lt;h1>`, its summary, its own link, and its PROFILE — the applicant's answers to the listicle's Application fields, as label · value in the wizard's order. The profile is built and filtered server-side (`listingProfile`); this component renders what it is handed, in order, and nothing else. | `import { ListingDetail } from "@working-theory/ui-signal-collectors"` |
466
+ | `ListingShelves` | A listicle's accepted entries on its landing page, as two shelves partitioned from ONE list: `status === "active"` on one, `"closed"` on the other. `closed` is computed by the API from the window end; this component only reads it. Featured entries lead the active shelf (stable sort, so the API's order otherwise survives). | `import { ListingShelves } from "@working-theory/ui-signal-collectors"` |
465
467
  | `ProjectListingShelves` | Two shelves of ProjectListingCards partitioned from ONE list of public listing rows: `status === "active"` on one shelf, `status === "closed"` on the other. `closed` is COMPUTED by the API read (the window-end check lives server-side, / D6b) — this component never recomputes it, it only reads the field. | `import { ProjectListingShelves } from "@working-theory/ui-signal-collectors"` |
466
468
 
467
469
  ---
468
470
 
469
- ## Signal-Collectors Blocks (8)
471
+ ## Signal-Collectors Blocks (9)
470
472
 
471
473
  Paid — delivered by the `signal-collectors` module bundle, not present in a Free scaffold. Flow step, listing card, response chart and results-reveal tiles the sections above compose.
472
474
 
@@ -475,6 +477,7 @@ Import: `import { X } from "@working-theory/ui-signal-collectors";` (or from the
475
477
  | Component | What it does | How to use it |
476
478
  | --- | --- | --- |
477
479
  | `FlowSidePanel` | The per-view side panel of the flow/recommender surface. ONE CHASSIS, THREE VIEWS. | `import { FlowSidePanel } from "@working-theory/ui-signal-collectors"` |
480
+ | `ListingCard` | The tile for one accepted entry of a listicle's directory. Generic by name and by shape: it reads the directory's `ListingEntryPublic` — name, summary, featured flag, waitlist count — and none of the project-shaped columns `ProjectListingCard` renders. The whole tile is one anchor, composed from `Link` directly (the LinkCard precedent: Card renders a &lt;div>). | `import { ListingCard } from "@working-theory/ui-signal-collectors"` |
478
481
  | `ListingFullPreview` | The full-page-preview `custom` step renderer. The founder's assembled application, rendered as a page, with an edit link back to the step that owns each rendered part. Purely presentational: every entry's label and formatted value arrive pre-built on `entries` (this block does no formatting of its own), and `onEdit` is the only way it ever changes anything — it never mutates, saves, or fetches. | internal — compose from `packages-paid/ui-signal-collectors/src/blocks/ListingFullPreview.tsx` |
479
482
  | `ListingPrivacyNotice` | The per-listing privacy notice. It names the FOUNDER — not the platform — as the party collecting a visitor's personal data, and it renders ON THE FLOW (where a visitor submits answers), not on the listing page. | `import { ListingPrivacyNotice } from "@working-theory/ui-signal-collectors"` |
480
483
  | `ListingQuestionReview` | The edit-only question-review `custom` step renderer. The founder is refining a diagnostic template the SITE authored, not composing one from nothing (`projectListingQuestionsSchema`, `packages/validation/src/schemas/project-listings.ts`). | internal — compose from `packages-paid/ui-signal-collectors/src/blocks/ListingQuestionReview.tsx` |
@@ -34,6 +34,7 @@ import { LEGACY_NAV_RENDERERS } from "./legacy-nav-renderers.js";
34
34
  import { writeAuthSecretFile } from "./lib/auth-secret-file.js";
35
35
  import { diffTwoStrings, extractModuleBundle, } from "./lib/extract-module-bundle.js";
36
36
  import { normalizeModules } from "./module-architecture.js";
37
+ import { PAID_SECTIONS_PATH, writePaidSections } from "./paid-sections.js";
37
38
  import { ParseError, parseModules } from "./parse-args.js";
38
39
  import { SITE_ROSTER_PATH, moduleRosterRows, planRosterAppend, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
39
40
  import { MODULE_SURFACES, NAV_CONFIG_PATH, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
@@ -126,7 +127,19 @@ export async function applyModuleToScaffold(opts) {
126
127
  // The same composition `syncArchitectureSync` writes — a pure function of
127
128
  // the module set, so an unchanged set rewrites identical bytes.
128
129
  writeFileSync(archPath, deriveArchitecture(modules, surfaces));
130
+ // #7536 D3 — the paid-sections registry follows `apps/web/package.json`,
131
+ // which the bundle's `patches` just edited. Generated and always
132
+ // overwritten, like `architecture.yaml` above.
133
+ const registryPath = join(targetAbs, PAID_SECTIONS_PATH);
134
+ const registryBefore = existsSync(registryPath)
135
+ ? readFileSync(registryPath, "utf8")
136
+ : undefined;
137
+ writePaidSections(targetAbs);
138
+ const registryChanged = existsSync(registryPath) &&
139
+ readFileSync(registryPath, "utf8") !== registryBefore;
129
140
  const rows = [...report];
141
+ if (registryChanged)
142
+ rows.push({ path: PAID_SECTIONS_PATH, action: "written" });
130
143
  const navRow = syncNavConfig(targetAbs, current.surfaces, surfaces);
131
144
  if (navRow !== undefined)
132
145
  rows.push(navRow);
@@ -83,6 +83,14 @@ export interface PageRosterRow {
83
83
  * scaffold carries (#6940).
84
84
  */
85
85
  export interface ModuleSlice {
86
+ /**
87
+ * The paid UI package base-tree code loads through
88
+ * `apps/web/lib/paid-sections.generated.ts` (#7536). Present → one row in the
89
+ * generated registry: a bundled `import()` when the scaffold's
90
+ * `apps/web/package.json` declares the package, a stub answering `null`
91
+ * otherwise. Only a module with a BASE-tree consumer sets it.
92
+ */
93
+ paidSectionsPackage?: string;
86
94
  /** Packages this module adds to apps/web.may_import_from (its presentation surface). */
87
95
  webImports?: string[];
88
96
  /** Apps this module declares (api, mcp replace the base block; admin is new). web is BASE, never here. */
@@ -790,6 +790,13 @@ export const MODULE_SLICES = {
790
790
  // deliverable by BOTH bundles.
791
791
  "apps/api/src/services/inbox-messages.ts",
792
792
  "apps/api/src/routes/workspaces/messages.ts",
793
+ // #7535 — the Inbox submissions and subscribers reads: the same
794
+ // workspace||multi-tenant gate and the same tenant-db.ts import as
795
+ // the two lines above, so co-owned with `workspace` for the same
796
+ // reason (see that slice's entry below).
797
+ "apps/api/src/routes/workspaces/submissions.ts",
798
+ "apps/api/src/routes/workspaces/subscribers.ts",
799
+ "apps/api/src/services/inbox-submissions.ts",
793
800
  // #7399 — the two siblings #6216 left `workspace`-only. They register
794
801
  // under that same `workspace || multi-tenant` gate, so a scaffold with
795
802
  // `multi-tenant` and without `workspace` loaded them with no file behind
@@ -910,6 +917,11 @@ export const MODULE_SLICES = {
910
917
  // bundle alone.
911
918
  "apps/api/src/routes/workspaces/messages.ts",
912
919
  "apps/api/src/services/inbox-messages.ts",
920
+ // Co-owned with `multi-tenant` (#7535), for the reason the two
921
+ // message lines above carry.
922
+ "apps/api/src/routes/workspaces/submissions.ts",
923
+ "apps/api/src/routes/workspaces/subscribers.ts",
924
+ "apps/api/src/services/inbox-submissions.ts",
913
925
  // Co-owned with `multi-tenant` (#5853) — see that slice's entry.
914
926
  "apps/api/src/routes/workspaces/content.ts",
915
927
  ...ASSET_STORE_FILES,
@@ -1143,6 +1155,9 @@ export const MODULE_SLICES = {
1143
1155
  "apps/api/src/lib/public-meetings-config.ts",
1144
1156
  "apps/api/src/routes/schedule/meeting-types.ts",
1145
1157
  "apps/api/src/routes/schedule/__tests__/meeting-types.test.ts",
1158
+ // #7532 — a hub spoke's meeting types and booking, through the real
1159
+ // scheduling routes; it ships and strips with them.
1160
+ "apps/api/src/routes/schedule/__tests__/spoke.test.ts",
1146
1161
  // meetings.ts is the FIRST scheduling-gated route to use the generic
1147
1162
  // tenantDb() scope layer directly (its sibling, appointments.ts, does
1148
1163
  // its own scoped read in services/scheduling/appointments.ts instead)
@@ -1153,6 +1168,22 @@ export const MODULE_SLICES = {
1153
1168
  // records: "each tenancy slice must deliver it").
1154
1169
  "apps/api/src/lib/tenant-db.ts",
1155
1170
  "apps/api/src/services/tenant-context.ts",
1171
+ // #7533 — a scheduling connection per workspace. `connections.ts` (the
1172
+ // cipher + the one SchedulingConnection data-access boundary) and
1173
+ // `resolve.ts` (the workspace-aware provider resolution the meetings,
1174
+ // schedule and webhook routes above now call), plus each new suite,
1175
+ // individually (§ D12, property 1: a glob ships no test). The config
1176
+ // suite is listed here although `config/index.ts` itself stays base:
1177
+ // it tests a scheduling-only variable, so a keyless scaffold carries
1178
+ // neither the variable's purpose nor its test.
1179
+ "apps/api/src/services/scheduling/connections.ts",
1180
+ "apps/api/src/services/scheduling/resolve.ts",
1181
+ "apps/api/src/services/scheduling/__tests__/connections.test.ts",
1182
+ "apps/api/src/services/scheduling/__tests__/provider-resolution.test.ts",
1183
+ "apps/api/src/routes/workspaces/__tests__/meetings-connection.test.ts",
1184
+ "apps/api/src/routes/schedule/__tests__/public-connection.test.ts",
1185
+ "apps/api/src/routes/webhooks/__tests__/calcom-connection.test.ts",
1186
+ "apps/api/src/config/__tests__/scheduling-credential-key.test.ts",
1156
1187
  ],
1157
1188
  // pageContributions (spec 5478; parent § D6 shape 1) — the booker, the
1158
1189
  // per-booking-type page and the confirmation page, whose route files
@@ -1285,6 +1316,7 @@ export const MODULE_SLICES = {
1285
1316
  // cannot place an unreached file either) is unchanged and still governs
1286
1317
  // where all eleven live.
1287
1318
  "signal-collectors": {
1319
+ paidSectionsPackage: "@working-theory/ui-signal-collectors",
1288
1320
  ownedFiles: [
1289
1321
  "apps/api/src/routes/flows/index.ts",
1290
1322
  // README.md documents the flows rate-limit rationale (#4730) — not a
@@ -1590,6 +1622,7 @@ export const MODULE_SLICES = {
1590
1622
  // `lib/stripe.ts` and the webhook receiver this module owns — declared
1591
1623
  // here, not engineered around.
1592
1624
  commerce: {
1625
+ paidSectionsPackage: "@working-theory/ui-commerce",
1593
1626
  apps: {
1594
1627
  api: {
1595
1628
  path: "apps/api",
@@ -0,0 +1,23 @@
1
+ /** Scaffold-relative path of the generated registry. */
2
+ export declare const PAID_SECTIONS_PATH = "apps/web/lib/paid-sections.generated.ts";
3
+ export interface PaidSectionRow {
4
+ module: string;
5
+ package: string;
6
+ loader: string;
7
+ contract: string;
8
+ }
9
+ /** One row per slice declaring `paidSectionsPackage`, in `RECOGNIZED_MODULES` order. */
10
+ export declare function paidSectionRows(): PaidSectionRow[];
11
+ /**
12
+ * Render the registry. `present` holds the package names whose row is a
13
+ * bundled `import()`; every other row is a stub. Pure and order-independent.
14
+ * Line 1 is `// oss: false` exactly when a row imports a paid package, so the
15
+ * public strip drops the file (\`check-oss-strip.sh\` condition (e)).
16
+ */
17
+ export declare function renderPaidSections(present: ReadonlySet<string>): string;
18
+ /**
19
+ * Write the registry into a scaffold, deriving presence from the scaffold's
20
+ * own `apps/web/package.json`. Returns `false` (writing nothing) when that
21
+ * file or `apps/web/lib/` is absent — a scaffold with no web app.
22
+ */
23
+ export declare function writePaidSections(targetDir: string): boolean;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The paid-sections registry (#7536) — `apps/web/lib/paid-sections.generated.ts`,
3
+ * the one file through which base-tree code reaches a paid UI package.
4
+ *
5
+ * Base code used to load `@working-theory/ui-commerce` with a
6
+ * `webpackIgnore`d `import()`, which hands resolution to Node's ESM loader at
7
+ * request time. That loader cannot read our `dist`, so the load failed and the
8
+ * `catch` answered `false`: the feature rendered nothing in production. This
9
+ * file owns the optionality at scaffold time instead. A package the scaffold
10
+ * carries is a plain `import()` webpack bundles; one it does not carry is a
11
+ * typed stub that answers `null`.
12
+ *
13
+ * Rows come from `MODULE_SLICES` (`paidSectionsPackage`); presence comes from
14
+ * the scaffold's own `apps/web/package.json` `dependencies`, the one fact
15
+ * every layer (snapshot transform, module `patches`, `add-module`) already
16
+ * keeps true. The monorepo's committed copy is the render with every row
17
+ * present and is pinned byte-equal by `__tests__/paid-sections.test.ts`.
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { MODULE_SLICES } from "./module-architecture.js";
22
+ import { RECOGNIZED_MODULES } from "./parse-args.js";
23
+ /** Scaffold-relative path of the generated registry. */
24
+ export const PAID_SECTIONS_PATH = "apps/web/lib/paid-sections.generated.ts";
25
+ function pascalCase(shortName) {
26
+ return shortName
27
+ .split("-")
28
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
29
+ .join("");
30
+ }
31
+ /** One row per slice declaring `paidSectionsPackage`, in `RECOGNIZED_MODULES` order. */
32
+ export function paidSectionRows() {
33
+ const rows = [];
34
+ for (const name of RECOGNIZED_MODULES) {
35
+ const pkg = MODULE_SLICES[name].paidSectionsPackage;
36
+ if (pkg === undefined)
37
+ continue;
38
+ const pascal = pascalCase(pkg.slice(pkg.indexOf("/") + 1));
39
+ rows.push({
40
+ module: name,
41
+ package: pkg,
42
+ loader: `load${pascal}`,
43
+ contract: `${pascal}Contract`,
44
+ });
45
+ }
46
+ return rows;
47
+ }
48
+ const HEADER = `// paid-sections.generated.ts — the paid section packages this site carries.
49
+ //
50
+ // GENERATED by create-ailk from MODULE_SLICES (\`paidSectionsPackage\`) and
51
+ // this app's package.json dependencies (#7536). Do not edit: every scaffold
52
+ // and \`create-ailk add-module\` rewrites it. A carried package is a bundled
53
+ // dynamic import; one this site does not carry is a stub that answers null.
54
+ `;
55
+ /**
56
+ * Render the registry. `present` holds the package names whose row is a
57
+ * bundled `import()`; every other row is a stub. Pure and order-independent.
58
+ * Line 1 is `// oss: false` exactly when a row imports a paid package, so the
59
+ * public strip drops the file (\`check-oss-strip.sh\` condition (e)).
60
+ */
61
+ export function renderPaidSections(present) {
62
+ const rows = paidSectionRows();
63
+ const contracts = rows.map((row) => row.contract).sort();
64
+ const anyPresent = rows.some((row) => present.has(row.package));
65
+ const lines = [];
66
+ if (anyPresent)
67
+ lines.push("// oss: false");
68
+ lines.push(HEADER.trimEnd());
69
+ lines.push("import type {");
70
+ for (const contract of contracts)
71
+ lines.push(` ${contract},`);
72
+ lines.push('} from "./paid-section-contracts";');
73
+ for (const row of rows) {
74
+ lines.push("");
75
+ const type = `Promise<${row.contract} | null>`;
76
+ // Shaped as Prettier formats it, so the committed copy survives the
77
+ // repo's format hook byte-for-byte.
78
+ if (present.has(row.package)) {
79
+ const head = `export const ${row.loader} = (): ${type} =>`;
80
+ const body = `import("${row.package}");`;
81
+ lines.push(head.length <= 80
82
+ ? `${head}\n ${body}`
83
+ : `export const ${row.loader} =\n (): ${type} =>\n ${body}`);
84
+ }
85
+ else {
86
+ const head = `export const ${row.loader} = async (): ${type} =>`;
87
+ lines.push(`${head} null;`.length <= 80
88
+ ? `${head} null;`
89
+ : head.length <= 80
90
+ ? `${head}\n null;`
91
+ : `export const ${row.loader} =\n async (): ${type} => null;`);
92
+ }
93
+ }
94
+ return `${lines.join("\n")}\n`;
95
+ }
96
+ /**
97
+ * Write the registry into a scaffold, deriving presence from the scaffold's
98
+ * own `apps/web/package.json`. Returns `false` (writing nothing) when that
99
+ * file or `apps/web/lib/` is absent — a scaffold with no web app.
100
+ */
101
+ export function writePaidSections(targetDir) {
102
+ const pkgPath = join(targetDir, "apps/web/package.json");
103
+ if (!existsSync(pkgPath) || !existsSync(join(targetDir, "apps/web/lib"))) {
104
+ return false;
105
+ }
106
+ let pkg;
107
+ try {
108
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
109
+ }
110
+ catch (err) {
111
+ throw new Error(`cannot read ${pkgPath} to write the paid-sections registry: ${err.message}`, { cause: err });
112
+ }
113
+ const deps = pkg.dependencies ?? {};
114
+ const present = new Set(paidSectionRows()
115
+ .map((row) => row.package)
116
+ .filter((name) => Object.hasOwn(deps, name)));
117
+ writeFileSync(join(targetDir, PAID_SECTIONS_PATH), renderPaidSections(present));
118
+ return true;
119
+ }
@@ -34,6 +34,7 @@ import { assertLicenseKeyShape, writeLicenseKeyFiles, } from "./lib/license-key-
34
34
  import { assertDesignAllowed, assertReleased, deriveLevel, resolveModuleSet, resolvePaidModules, } from "./lib/module-license-gate.js";
35
35
  import { classifyModules } from "./lib/module-tier.js";
36
36
  import { normalizeModules } from "./module-architecture.js";
37
+ import { writePaidSections } from "./paid-sections.js";
37
38
  import { RECOGNIZED_SHAPES, RESERVED_SHAPES, } from "./parse-args.js";
38
39
  import { SITE_CHROME_PATH, renderSiteChrome, resolveSiteChrome, } from "./site-chrome.js";
39
40
  import { planSiteManifest } from "./site-manifest.js";
@@ -176,6 +177,10 @@ export function scaffoldSync(opts) {
176
177
  // synchronous caller) writes DEFAULT_SITE_CHROME, byte-identical to the
177
178
  // monorepo's own committed apps/web/lib/site-chrome.ts.
178
179
  writeFileSync(join(targetAbs, SITE_CHROME_PATH), renderSiteChrome(resolveSiteChrome(opts.layout, opts.designLayout)));
180
+ // The paid-sections registry (#7536), from this scaffold's own
181
+ // `apps/web/package.json` — on the keyless snapshot, the all-stub render.
182
+ // `scaffold()` writes it again once every layer has landed.
183
+ writePaidSections(targetAbs);
179
184
  // Opt-in only (#4636). Every write above this line is unconditional, so a
180
185
  // scaffold with `withCatalog` unset touches exactly the files it did before.
181
186
  if (opts.withCatalog === true)
@@ -653,6 +658,9 @@ export async function scaffold(opts) {
653
658
  }));
654
659
  }
655
660
  }
661
+ // #7536 D3 — the registry follows `apps/web/package.json`, which a module
662
+ // bundle's `patches` (and the template layer) just finished editing.
663
+ writePaidSections(result.targetDir);
656
664
  // #6107 D2 — after every layer landed, so nothing later overwrites it: a
657
665
  // served scaffold needs BETTER_AUTH_SECRET (#6044), and this is its value.
658
666
  writeAuthSecretFile(result.targetDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,8 +27,8 @@
27
27
  "tar": "^7.5.21"
28
28
  },
29
29
  "devDependencies": {
30
- "@working-theory/schema": "0.32.0",
31
- "@working-theory/templates": "0.32.0"
30
+ "@working-theory/schema": "0.34.0",
31
+ "@working-theory/templates": "0.34.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -85,7 +85,9 @@ CONTENT_ADAPTER=mdx
85
85
  # ── Single-tenant operator pin (apps/api) ────────────────────────────────────
86
86
  # REQUIRED on a build WITHOUT the multi-tenant module — for the submissions
87
87
  # review dashboard AND for the `workspace` module's settings surface.
88
- # OPTIONAL with the multi-tenant module.
88
+ # REQUIRED with the multi-tenant module too, on a deployment that has exactly
89
+ # one workspace (the seeded default) — see "With the multi-tenant module"
90
+ # below (#7523).
89
91
  #
90
92
  # On a single-tenant deployment (the default Workspace exists and is the ONLY
91
93
  # Workspace) the FIRST authenticated user to resolve a tenant context is
@@ -111,8 +113,35 @@ CONTENT_ADAPTER=mdx
111
113
  # to its owner.
112
114
  #
113
115
  # If your own dashboard returns 403, this variable is the first thing to
114
- # check. With the multi-tenant module present, resolution comes from the
115
- # session's active workspace and this stays optional, exactly as before.
116
+ # check.
117
+ #
118
+ # With the multi-tenant module (#7523): a session that has activated a
119
+ # workspace (every Admin-app request) resolves from that workspace and its
120
+ # Membership, unchanged. A session with NO active workspace — every apps/web
121
+ # request, including the /admin* and /waitlist* pages — is granted the
122
+ # default workspace only when this pin is set, the caller's VERIFIED email
123
+ # equals it, and the deployment has exactly one workspace. Unset, apps/api
124
+ # seats nobody: those requests answer 403 and no Membership is written. Every
125
+ # default-organization member other than the pinned operator reaches their
126
+ # workspace through the Admin app instead.
127
+ #
128
+ # The every-site waitlist read (kind: "all" on /v1/waitlist-experiments/*)
129
+ # needs the caller to BE this pin — a sentinel-organization owner whose
130
+ # verified email is this address. Holding an owner Membership on the default
131
+ # organization is not enough on its own.
132
+ #
133
+ # If a one-workspace multi-tenant deployment EVER ran with this unset, the
134
+ # first signed-in caller may already hold the owner Membership, and this fix
135
+ # does not revoke a Membership minted earlier. Check, on the database:
136
+ # 1. every Membership on organization
137
+ # org_00000000-0000-7000-8000-000000000001 — start with id
138
+ # member_00000000-0000-7000-8000-000000000001 (the operator Membership) —
139
+ # and every pending Invitation on it;
140
+ # 2. every Workspace other than wksp_00000000-0000-7000-8000-000000000001
141
+ # (one extra workspace also stops the real operator binding through the
142
+ # fallback);
143
+ # 3. delete whatever does not belong to the pinned operator or someone they
144
+ # invited.
116
145
  # AILK_OPERATOR_EMAIL=
117
146
  #
118
147
  # ── Site id (apps/api, apps/web) ─────────────────────────────────────────────
@@ -399,8 +428,14 @@ CONTENT_ADAPTER=mdx
399
428
  # Server-side only — read inside apps/api, never exposed to the browser.
400
429
  # Unset is a supported state: the booker degrades to the provider's own
401
430
  # booking page. Full notes in apps/api/.env.example.
431
+ # CAL_API_KEY is the deployment's account; it serves a workspace only on a
432
+ # one-workspace deployment — each workspace can connect its own (#7533).
402
433
  # CAL_API_KEY=cal_live_xxx
403
434
  # CAL_API_BASE_URL=https://api.cal.com
435
+ # The key per-workspace connections are encrypted under (#7533): exactly 32
436
+ # random bytes, `openssl rand -base64 32`. Unset = connections cannot be
437
+ # written; any other shape stops the API. Losing it unreads every connection.
438
+ # SCHEDULING_CREDENTIAL_KEY=
404
439
  # HMAC-SHA256 key POST /v1/webhooks/calcom verifies the x-cal-signature-256
405
440
  # header against (#6085). Unset makes the receiver answer 503 and accept
406
441
  # nothing — the same fail-closed posture an unset STRIPE_WEBHOOK_SECRET has.
@@ -472,12 +507,43 @@ CONTENT_ADAPTER=mdx
472
507
  # AILK_RATE_LIMIT_LEADS_PER_MINUTE=10
473
508
  # AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE=300
474
509
  #
510
+ # HUB (apps/api). The listicle directory read's per-IP limit per minute
511
+ # (#7521) — its own scope, not the leads one, because the web server renders
512
+ # it from one egress address. A whole number from 1 to 999999. Default: 120.
513
+ # AILK_RATE_LIMIT_DIRECTORY_PER_MINUTE=120
514
+ #
475
515
  # SPOKE (apps/web, server-only — never NEXT_PUBLIC_). This spoke's key: one
476
516
  # entry of the hub's AILK_VISITOR_FORWARD_KEYS. Sent only when AILK_API_URL
477
517
  # is https (or loopback). Issue a key only to a spoke whose platform
478
518
  # overwrites x-forwarded-for / x-real-ip with the real client address.
479
519
  # AILK_VISITOR_FORWARD_KEY=
480
520
  #
521
+ # Hub and spokes — a spoke's reads and writes resolve to ITS OWN workspace
522
+ # (#7532). A hub holding one workspace per spoke registers each spoke in its
523
+ # database (`pnpm --filter @working-theory/api spokes register --workspace
524
+ # <wksp_id> --name <label>`), which prints the spoke's key once. The spoke
525
+ # sends it on every server-side call, and the hub resolves meeting types, the
526
+ # three lead writes, bookings and public content reads to that spoke's
527
+ # workspace. Runbook: docs/runbooks/site-configuration.md § Hub and spokes:
528
+ # registering a spoke. Both unset = the behaviour before #7532.
529
+ #
530
+ # HUB (apps/api). `true` (or `1`) refuses every header-less call to those
531
+ # surfaces: no key, no workspace. Unset, empty, `false` or `0` = off; any
532
+ # other value stops the API at boot, and so does `true` with no DATABASE_URL.
533
+ # Do NOT turn it on while a spoke still renders the browser booker section,
534
+ # NewsletterFormInline or StepFlow — those post from the BROWSER, which holds
535
+ # no key. Register every caller first, the hub's own apps/web and apps/mcp
536
+ # included. On a hub, AILK_SITE_ID must name the PLATFORM OPERATOR's own
537
+ # workspace, never a spoke's: it is where every header-less lead and every
538
+ # booking no spoke bound lands.
539
+ # AILK_HUB_REQUIRE_SPOKE_KEY=
540
+ #
541
+ # SPOKE (apps/web and apps/mcp, server-only — never NEXT_PUBLIC_). The key the
542
+ # hub's `spokes register` printed. Sent as `x-ailk-spoke-key` on every
543
+ # server-side call to AILK_API_URL, and only when that is https (or
544
+ # loopback). Not the same value as AILK_VISITOR_FORWARD_KEY above.
545
+ # AILK_SPOKE_KEY=
546
+ #
481
547
  # NEXT_PUBLIC_ENABLED_LOCALES — do NOT set by hand. Baked at build time by
482
548
  # apps/web/next.config.mjs from project.yaml `product.locales` (falls back to
483
549
  # "en" when project.yaml is absent) and read by middleware.ts, the sitemap
@@ -56,6 +56,13 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx
56
56
  # Unset CAL_API_KEY is a supported state, not a broken one — the routes answer
57
57
  # 503 not_configured with the provider's public booking page, and the section
58
58
  # renders a link out to it rather than an error.
59
+ #
60
+ # CAL_API_KEY is the DEPLOYMENT's account. Since #7533 each workspace can hold
61
+ # its own connection (PUT /v1/workspaces/:workspaceId/meetings/connection),
62
+ # and a workspace's connection always wins. CAL_API_KEY serves a workspace
63
+ # only on a one-workspace deployment (its sole workspace the seeded default,
64
+ # as on every single-tenant scaffold); on a deployment with two or more
65
+ # workspaces, a workspace with no connection answers not_configured.
59
66
  # CAL_API_KEY=cal_live_xxx
60
67
  # Self-hosted Cal instances only; defaults to https://api.cal.com.
61
68
  # CAL_API_BASE_URL=https://api.cal.com
@@ -65,12 +72,27 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx
65
72
  # (#4288) lands as a second value here with no change to the section.
66
73
  # SCHEDULING_PROVIDER=cal
67
74
  #
75
+ # Per-workspace scheduling connections (#7533) — the AES-256-GCM key each
76
+ # workspace's provider credential and webhook secret are encrypted under.
77
+ # Exactly 32 random bytes in canonical standard base64:
78
+ # openssl rand -base64 32
79
+ # Any other value stops the API at startup. Unset means connections cannot be
80
+ # written (PUT answers 503 credential_key_not_configured) and CAL_API_KEY works
81
+ # exactly as before. LOSING OR CHANGING THIS KEY makes every stored connection
82
+ # unreadable: each workspace must then reconnect. Never log or share it.
83
+ # SCHEDULING_CREDENTIAL_KEY=
84
+ #
68
85
  # The booking webhook receiver (#6085) — a SEPARATE credential from the
69
86
  # three above: it verifies POST /v1/webhooks/calcom's x-cal-signature-256
70
87
  # header, it does not call the provider. Unset makes the receiver answer 503
71
88
  # and accept nothing (the same fail-closed posture STRIPE_WEBHOOK_SECRET
72
89
  # unset already has) — every booking is then silently lost, so this must be
73
90
  # set for the receiver to do anything.
91
+ # It verifies only the ENV account's receiver, whose bookings land in the
92
+ # AILK_SITE_ID workspace. A workspace's own connection has its own entry point
93
+ # (POST /v1/webhooks/calcom/<token>) and its own secret, both returned once by
94
+ # the connect PUT (#7533). On a hub whose spokes all have connections, unset
95
+ # this to retire the env receiver (it then answers 503).
74
96
  # CAL_WEBHOOK_SECRET=
75
97
  # GET /v1/schedule/appointments only returns rows where this deployment's
76
98
  # AILK_SITE_ID (declared below, under "Project listings — site addressing")