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.
- package/component-catalog.json +35 -2
- package/component-catalog.md +9 -6
- package/dist/add-module.js +13 -0
- package/dist/module-architecture.d.ts +8 -0
- package/dist/module-architecture.js +33 -0
- package/dist/paid-sections.d.ts +23 -0
- package/dist/paid-sections.js +119 -0
- package/dist/programmatic.js +8 -0
- package/package.json +3 -3
- package/templates/.env.example +69 -3
- package/templates/apps/api/.env.example +22 -0
- package/templates/apps/api/CLAUDE.md +19 -7
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/bin/spokes.ts +137 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
- package/templates/apps/api/src/config/index.ts +97 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
- package/templates/apps/api/src/lib/spoke-store.ts +131 -0
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
- package/templates/apps/api/src/routes/leads/index.ts +33 -3
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
- package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
- package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
- package/templates/apps/api/src/server.ts +48 -0
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
- package/templates/apps/api/src/services/default-tenant.ts +51 -28
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
- package/templates/apps/api/src/services/lead-routing.ts +13 -1
- package/templates/apps/api/src/services/project-listings.ts +209 -18
- package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
- package/templates/apps/api/src/services/slack-notify.ts +54 -3
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/layout.tsx +4 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/jest.config.cjs +1 -1
- package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
- package/templates/apps/web/lib/listings.tsx +472 -0
- package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
- package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
- package/templates/apps/web/messages/en.json +10 -0
- package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
- package/templates/database/CHANGELOG.md +34 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
- package/templates/database/auth/schema.prisma +31 -0
- package/templates/database/config/schema.prisma +26 -0
- package/templates/database/inbox/schema.prisma +21 -0
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
- package/templates/database/package.json +3 -3
- package/versions.json +34 -34
|
@@ -1,92 +1,54 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
import { loadUiCommerce } from "../../lib/paid-sections.generated";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (`payment-receipt`)
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* and
|
|
26
|
-
*
|
|
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
|
|
30
|
-
* package
|
|
31
|
-
* `payment-receipt` section anyway — `SectionRenderer`'s dispatch table
|
|
32
|
-
*
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|