create-ailk 0.14.0 → 0.16.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.
- package/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory stand-in for the handful of Prisma delegates the hub sign-in
|
|
3
|
+
* flow touches (#7894): `spoke`, `oAuthClient`, `oAuthAuthorization`,
|
|
4
|
+
* `verification` and `user`. Test-only.
|
|
5
|
+
*
|
|
6
|
+
* It lets the suites drive the REAL store, service, routes and preHandler end
|
|
7
|
+
* to end — a code minted by one call is consumed (once) by the next — while
|
|
8
|
+
* the Prisma client itself, the external boundary, stays faked. Every delegate
|
|
9
|
+
* method answers only the `where` shapes the production code uses; an
|
|
10
|
+
* unexpected shape throws, so a code change that adds a query is a failing
|
|
11
|
+
* test and not a silent miss.
|
|
12
|
+
*
|
|
13
|
+
* Install it on the shared `__mocks__/prisma.ts` double:
|
|
14
|
+
*
|
|
15
|
+
* jest.mock("../../lib/prisma.js");
|
|
16
|
+
* const fake = installHubSignInFakeDb(jest.mocked(prisma));
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
import { hashSpokeKey, mintSpokeKey } from "../../lib/spoke-store.js";
|
|
21
|
+
import {
|
|
22
|
+
HUB_CALLBACK_PATH,
|
|
23
|
+
mintClientSecret,
|
|
24
|
+
sha256Hex,
|
|
25
|
+
} from "../../lib/hub-sign-in-store.js";
|
|
26
|
+
|
|
27
|
+
type Row = Record<string, unknown>;
|
|
28
|
+
type Where = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
export interface FakeUser {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
email: string;
|
|
34
|
+
emailVerified: boolean;
|
|
35
|
+
createdAt: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FakeSpoke {
|
|
39
|
+
id: string;
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
name: string;
|
|
42
|
+
credentialHash: string;
|
|
43
|
+
revokedAt: Date | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FakeClient {
|
|
47
|
+
id: string;
|
|
48
|
+
clientId: string;
|
|
49
|
+
clientSecret: string;
|
|
50
|
+
redirectUris: string[];
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FakeVerification {
|
|
55
|
+
id: string;
|
|
56
|
+
identifier: string;
|
|
57
|
+
value: string;
|
|
58
|
+
expiresAt: Date;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FakeAuthorization {
|
|
62
|
+
id: string;
|
|
63
|
+
userId: string;
|
|
64
|
+
clientId: string;
|
|
65
|
+
scopes: string[];
|
|
66
|
+
accessToken: string;
|
|
67
|
+
expiresAt: Date;
|
|
68
|
+
revokedAt: Date | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FakeHubDb {
|
|
72
|
+
users: Map<string, FakeUser>;
|
|
73
|
+
spokes: Map<string, FakeSpoke>;
|
|
74
|
+
clients: Map<string, FakeClient>;
|
|
75
|
+
verifications: FakeVerification[];
|
|
76
|
+
authorizations: FakeAuthorization[];
|
|
77
|
+
/** Every `prisma.session.*` call — the token route must make none. */
|
|
78
|
+
sessionCalls: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A seeded spoke with a registered sign-in client. */
|
|
82
|
+
export interface SeededSpoke {
|
|
83
|
+
spoke: FakeSpoke;
|
|
84
|
+
client: FakeClient;
|
|
85
|
+
/** The spoke key, in clear (the test sends it). */
|
|
86
|
+
key: string;
|
|
87
|
+
/** The client secret, in clear. */
|
|
88
|
+
secret: string;
|
|
89
|
+
redirectUri: string;
|
|
90
|
+
/** `Authorization: Basic …` for the token and revoke routes. */
|
|
91
|
+
basic: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function eq(row: object, where: Where): boolean {
|
|
95
|
+
return Object.entries(where).every(([key, expected]) => {
|
|
96
|
+
if (expected !== null && typeof expected === "object") {
|
|
97
|
+
throw new Error(`fake db: unsupported where clause on "${key}"`);
|
|
98
|
+
}
|
|
99
|
+
return (row as Record<string, unknown>)[key] === expected;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pickWhere(where: Where, allowed: string[]): void {
|
|
104
|
+
for (const key of Object.keys(where)) {
|
|
105
|
+
if (!allowed.includes(key)) {
|
|
106
|
+
throw new Error(`fake db: unexpected where key "${key}"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type DelegateMock = {
|
|
112
|
+
mockImplementation: (fn: (arg: never) => unknown) => void;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function impl(mock: unknown, fn: (arg: never) => unknown): void {
|
|
116
|
+
(mock as DelegateMock).mockImplementation(fn);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function installHubSignInFakeDb(db: {
|
|
120
|
+
spoke: { findUnique: unknown };
|
|
121
|
+
oAuthClient: { findUnique: unknown; upsert: unknown; updateMany: unknown };
|
|
122
|
+
oAuthAuthorization: {
|
|
123
|
+
findUnique: unknown;
|
|
124
|
+
upsert: unknown;
|
|
125
|
+
updateMany: unknown;
|
|
126
|
+
};
|
|
127
|
+
verification: { create: unknown; findFirst: unknown; deleteMany: unknown };
|
|
128
|
+
user: { findUnique: unknown };
|
|
129
|
+
session: { findUnique: unknown; create: unknown };
|
|
130
|
+
}): FakeHubDb {
|
|
131
|
+
const state: FakeHubDb = {
|
|
132
|
+
users: new Map(),
|
|
133
|
+
spokes: new Map(),
|
|
134
|
+
clients: new Map(),
|
|
135
|
+
verifications: [],
|
|
136
|
+
authorizations: [],
|
|
137
|
+
sessionCalls: [],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
impl(
|
|
141
|
+
db.user.findUnique,
|
|
142
|
+
async ({ where }: { where: Where }) =>
|
|
143
|
+
state.users.get(String(where.id)) ?? null,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
impl(db.spoke.findUnique, async ({ where }: { where: Where }) => {
|
|
147
|
+
pickWhere(where, ["id", "credentialHash"]);
|
|
148
|
+
for (const spoke of state.spokes.values()) {
|
|
149
|
+
if (eq(spoke, where)) return spoke;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
impl(db.oAuthClient.findUnique, async ({ where }: { where: Where }) => {
|
|
155
|
+
pickWhere(where, ["clientId"]);
|
|
156
|
+
return state.clients.get(String(where.clientId)) ?? null;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
impl(
|
|
160
|
+
db.oAuthClient.upsert,
|
|
161
|
+
async (args: {
|
|
162
|
+
where: Where;
|
|
163
|
+
create: Omit<FakeClient, "id">;
|
|
164
|
+
update: Partial<FakeClient>;
|
|
165
|
+
}) => {
|
|
166
|
+
const clientId = String(args.where.clientId);
|
|
167
|
+
const existing = state.clients.get(clientId);
|
|
168
|
+
const next: FakeClient = existing
|
|
169
|
+
? { ...existing, ...args.update }
|
|
170
|
+
: { id: `oauthc_${randomUUID()}`, ...args.create };
|
|
171
|
+
state.clients.set(clientId, next);
|
|
172
|
+
return next;
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
impl(
|
|
177
|
+
db.oAuthClient.updateMany,
|
|
178
|
+
async ({ where, data }: { where: Where; data: Row }) => {
|
|
179
|
+
pickWhere(where, ["clientId"]);
|
|
180
|
+
const client = state.clients.get(String(where.clientId));
|
|
181
|
+
if (!client) return { count: 0 };
|
|
182
|
+
Object.assign(client, data);
|
|
183
|
+
return { count: 1 };
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
impl(
|
|
188
|
+
db.verification.create,
|
|
189
|
+
async ({ data }: { data: Omit<FakeVerification, "id"> }) => {
|
|
190
|
+
const row: FakeVerification = { id: `verify_${randomUUID()}`, ...data };
|
|
191
|
+
state.verifications.push(row);
|
|
192
|
+
return row;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
impl(db.verification.findFirst, async ({ where }: { where: Where }) => {
|
|
196
|
+
pickWhere(where, ["identifier"]);
|
|
197
|
+
return (
|
|
198
|
+
state.verifications.find((v) => v.identifier === where.identifier) ?? null
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
impl(db.verification.deleteMany, async ({ where }: { where: Where }) => {
|
|
203
|
+
const before = state.verifications.length;
|
|
204
|
+
if (typeof where.id === "string") {
|
|
205
|
+
state.verifications = state.verifications.filter(
|
|
206
|
+
(v) => v.id !== where.id,
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
const startsWith = (where.identifier as { startsWith: string })
|
|
210
|
+
.startsWith;
|
|
211
|
+
const lte = (where.expiresAt as { lte: Date }).lte;
|
|
212
|
+
state.verifications = state.verifications.filter(
|
|
213
|
+
(v) => !(v.identifier.startsWith(startsWith) && v.expiresAt <= lte),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return { count: before - state.verifications.length };
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
impl(
|
|
220
|
+
db.oAuthAuthorization.upsert,
|
|
221
|
+
async (args: {
|
|
222
|
+
where: { userId_clientId: { userId: string; clientId: string } };
|
|
223
|
+
create: Omit<FakeAuthorization, "id" | "revokedAt">;
|
|
224
|
+
update: Partial<FakeAuthorization>;
|
|
225
|
+
}) => {
|
|
226
|
+
const { userId, clientId } = args.where.userId_clientId;
|
|
227
|
+
const existing = state.authorizations.find(
|
|
228
|
+
(a) => a.userId === userId && a.clientId === clientId,
|
|
229
|
+
);
|
|
230
|
+
if (existing) {
|
|
231
|
+
Object.assign(existing, args.update);
|
|
232
|
+
return existing;
|
|
233
|
+
}
|
|
234
|
+
const row: FakeAuthorization = {
|
|
235
|
+
id: `oautha_${randomUUID()}`,
|
|
236
|
+
revokedAt: null,
|
|
237
|
+
...args.create,
|
|
238
|
+
};
|
|
239
|
+
state.authorizations.push(row);
|
|
240
|
+
return row;
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
impl(
|
|
245
|
+
db.oAuthAuthorization.updateMany,
|
|
246
|
+
async ({ where, data }: { where: Where; data: Row }) => {
|
|
247
|
+
// Two production shapes: the revoke route (a client's credential by
|
|
248
|
+
// digest) and the web app's password reset (every live grant of a user).
|
|
249
|
+
const matches = state.authorizations.filter((a) => {
|
|
250
|
+
if ("accessToken" in where && a.accessToken !== where.accessToken)
|
|
251
|
+
return false;
|
|
252
|
+
if ("clientId" in where && a.clientId !== where.clientId) return false;
|
|
253
|
+
if ("userId" in where && a.userId !== where.userId) return false;
|
|
254
|
+
if ("revokedAt" in where && a.revokedAt !== where.revokedAt)
|
|
255
|
+
return false;
|
|
256
|
+
return true;
|
|
257
|
+
});
|
|
258
|
+
for (const row of matches) Object.assign(row, data);
|
|
259
|
+
return { count: matches.length };
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
impl(
|
|
264
|
+
db.oAuthAuthorization.findUnique,
|
|
265
|
+
async ({ where }: { where: Where }) => {
|
|
266
|
+
pickWhere(where, ["accessToken"]);
|
|
267
|
+
const row = state.authorizations.find(
|
|
268
|
+
(a) => a.accessToken === where.accessToken,
|
|
269
|
+
);
|
|
270
|
+
if (!row) return null;
|
|
271
|
+
const client = [...state.clients.values()].find(
|
|
272
|
+
(c) => c.id === row.clientId,
|
|
273
|
+
);
|
|
274
|
+
const user = state.users.get(row.userId);
|
|
275
|
+
if (!client || !user) return null;
|
|
276
|
+
return {
|
|
277
|
+
userId: row.userId,
|
|
278
|
+
expiresAt: row.expiresAt,
|
|
279
|
+
revokedAt: row.revokedAt,
|
|
280
|
+
client: { clientId: client.clientId, enabled: client.enabled },
|
|
281
|
+
user: { emailVerified: user.emailVerified },
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
impl(db.session.findUnique, async () => {
|
|
287
|
+
state.sessionCalls.push("findUnique");
|
|
288
|
+
return null;
|
|
289
|
+
});
|
|
290
|
+
impl(db.session.create, async () => {
|
|
291
|
+
state.sessionCalls.push("create");
|
|
292
|
+
throw new Error("fake db: a hub sign-in must never create a Session");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return state;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let counter = 0;
|
|
299
|
+
|
|
300
|
+
export function seedUser(
|
|
301
|
+
state: FakeHubDb,
|
|
302
|
+
overrides: Partial<FakeUser> = {},
|
|
303
|
+
): FakeUser {
|
|
304
|
+
counter += 1;
|
|
305
|
+
const user: FakeUser = {
|
|
306
|
+
id: `user_${counter}`,
|
|
307
|
+
name: "Pat Person",
|
|
308
|
+
email: `pat${counter}@example.com`,
|
|
309
|
+
emailVerified: true,
|
|
310
|
+
createdAt: new Date("2026-10-01T00:00:00.000Z"),
|
|
311
|
+
...overrides,
|
|
312
|
+
};
|
|
313
|
+
state.users.set(user.id, user);
|
|
314
|
+
return user;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function seedSpoke(
|
|
318
|
+
state: FakeHubDb,
|
|
319
|
+
host = "spoke.example.com",
|
|
320
|
+
): SeededSpoke {
|
|
321
|
+
counter += 1;
|
|
322
|
+
const key = mintSpokeKey();
|
|
323
|
+
const spoke: FakeSpoke = {
|
|
324
|
+
id: `spoke_${counter}`,
|
|
325
|
+
workspaceId: `wksp_${counter}`,
|
|
326
|
+
name: `Spoke ${counter}`,
|
|
327
|
+
credentialHash: hashSpokeKey(key),
|
|
328
|
+
revokedAt: null,
|
|
329
|
+
};
|
|
330
|
+
state.spokes.set(spoke.id, spoke);
|
|
331
|
+
|
|
332
|
+
const secret = mintClientSecret();
|
|
333
|
+
const redirectUri = `https://${host}${HUB_CALLBACK_PATH}`;
|
|
334
|
+
const client: FakeClient = {
|
|
335
|
+
id: `oauthc_${counter}`,
|
|
336
|
+
clientId: spoke.id,
|
|
337
|
+
clientSecret: sha256Hex(secret),
|
|
338
|
+
redirectUris: [redirectUri],
|
|
339
|
+
enabled: true,
|
|
340
|
+
};
|
|
341
|
+
state.clients.set(client.clientId, client);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
spoke,
|
|
345
|
+
client,
|
|
346
|
+
key,
|
|
347
|
+
secret,
|
|
348
|
+
redirectUri,
|
|
349
|
+
basic: `Basic ${Buffer.from(`${spoke.id}:${secret}`).toString("base64")}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A PKCE pair: the verifier a spoke generates and the S256 challenge of it. */
|
|
354
|
+
export function pkcePair(): { verifier: string; challenge: string } {
|
|
355
|
+
const verifier = Buffer.from(randomUUID() + randomUUID())
|
|
356
|
+
.toString("base64url")
|
|
357
|
+
.slice(0, 64);
|
|
358
|
+
const challenge = Buffer.from(sha256Hex(verifier), "hex").toString(
|
|
359
|
+
"base64url",
|
|
360
|
+
);
|
|
361
|
+
return { verifier, challenge };
|
|
362
|
+
}
|
|
@@ -21,10 +21,24 @@
|
|
|
21
21
|
* list
|
|
22
22
|
* Every spoke: id, name, workspace, key prefix, created, revoked. Never
|
|
23
23
|
* the digest.
|
|
24
|
+
* sign-in-client --id <spoke_id> --redirect-uri <url>
|
|
25
|
+
* Register (or rotate) the spoke's sign-in client (#7894 D3): the hub
|
|
26
|
+
* signs that spoke's visitors in, and the callback is the ONE registered
|
|
27
|
+
* URL — `https:` (or `http:` on a loopback host), path exactly
|
|
28
|
+
* `/api/hub-session/callback`, no query, fragment or userinfo. Prints the
|
|
29
|
+
* client id and secret ONCE; re-running rotates the secret. Refuses an
|
|
30
|
+
* unknown or revoked spoke.
|
|
31
|
+
* sign-in-client --id <spoke_id> --disable
|
|
32
|
+
* Switch that spoke's sign-in client off: its codes and credentials stop
|
|
33
|
+
* at once.
|
|
24
34
|
*
|
|
25
35
|
* A key rotates by registering a second spoke for the workspace, deploying
|
|
26
36
|
* its key, then revoking the first. There is no HTTP route for any of this.
|
|
27
37
|
*/
|
|
38
|
+
import {
|
|
39
|
+
disableSignInClient,
|
|
40
|
+
registerSignInClient,
|
|
41
|
+
} from "../lib/hub-sign-in-store.js";
|
|
28
42
|
import { prisma } from "../lib/prisma.js";
|
|
29
43
|
import {
|
|
30
44
|
listSpokes,
|
|
@@ -36,12 +50,15 @@ const USAGE = [
|
|
|
36
50
|
"usage: spokes register --workspace <wksp_id> --name <label>",
|
|
37
51
|
" spokes revoke --id <spoke_id>",
|
|
38
52
|
" spokes list",
|
|
53
|
+
" spokes sign-in-client --id <spoke_id> --redirect-uri <url> | --disable",
|
|
39
54
|
].join("\n");
|
|
40
55
|
|
|
41
56
|
type Flags = {
|
|
42
57
|
workspace?: string | undefined;
|
|
43
58
|
name?: string | undefined;
|
|
44
59
|
id?: string | undefined;
|
|
60
|
+
redirectUri?: string | undefined;
|
|
61
|
+
disable?: boolean | undefined;
|
|
45
62
|
};
|
|
46
63
|
|
|
47
64
|
/** Minimal `--flag value` parser — no external dependency for a 3-flag CLI. */
|
|
@@ -53,7 +70,11 @@ function parseFlags(argv: string[]): Flags {
|
|
|
53
70
|
if (flag === "--workspace") out.workspace = value;
|
|
54
71
|
else if (flag === "--name") out.name = value;
|
|
55
72
|
else if (flag === "--id") out.id = value;
|
|
56
|
-
else
|
|
73
|
+
else if (flag === "--redirect-uri") out.redirectUri = value;
|
|
74
|
+
else if (flag === "--disable") {
|
|
75
|
+
out.disable = true;
|
|
76
|
+
continue; // a bare flag: it takes no value
|
|
77
|
+
} else continue;
|
|
57
78
|
i += 1;
|
|
58
79
|
}
|
|
59
80
|
return out;
|
|
@@ -96,6 +117,45 @@ async function runRevoke(flags: Flags): Promise<void> {
|
|
|
96
117
|
console.log(`[spokes] revoked spoke="${flags.id}"`);
|
|
97
118
|
}
|
|
98
119
|
|
|
120
|
+
async function runSignInClient(flags: Flags): Promise<void> {
|
|
121
|
+
if (!flags.id || (!flags.disable && !flags.redirectUri)) {
|
|
122
|
+
console.error(`[spokes] ${USAGE}`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (flags.disable) {
|
|
127
|
+
const disabled = await disableSignInClient(flags.id);
|
|
128
|
+
if (!disabled) {
|
|
129
|
+
console.error(`[spokes] no sign-in client for spoke "${flags.id}"`);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
console.log(`[spokes] disabled the sign-in client of spoke="${flags.id}"`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const result = await registerSignInClient({
|
|
137
|
+
spokeId: flags.id,
|
|
138
|
+
redirectUri: flags.redirectUri ?? "",
|
|
139
|
+
});
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
const why = {
|
|
142
|
+
"invalid-redirect-uri":
|
|
143
|
+
"the redirect URI must be https (or http on localhost / 127.0.0.1), its path exactly /api/hub-session/callback, with no query, fragment or userinfo",
|
|
144
|
+
"spoke-not-found": `no spoke "${flags.id}"`,
|
|
145
|
+
"spoke-revoked": `spoke "${flags.id}" is revoked`,
|
|
146
|
+
}[result.reason];
|
|
147
|
+
console.error(`[spokes] ${result.reason}: ${why}`);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
console.log(`[spokes] sign-in client ready for spoke="${result.spokeId}"`);
|
|
152
|
+
console.log(`AILK_HUB_CLIENT_ID=${result.spokeId}`);
|
|
153
|
+
console.log(`AILK_HUB_CLIENT_SECRET=${result.secret}`);
|
|
154
|
+
console.log(
|
|
155
|
+
"[spokes] This secret will not be shown again. Set both as the spoke's AILK_HUB_CLIENT_ID / AILK_HUB_CLIENT_SECRET now. Re-running rotates the secret.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
99
159
|
async function runList(): Promise<void> {
|
|
100
160
|
const spokes = await listSpokes();
|
|
101
161
|
if (spokes.length === 0) {
|
|
@@ -122,6 +182,7 @@ async function main(): Promise<void> {
|
|
|
122
182
|
if (command === "register") return runRegister(flags);
|
|
123
183
|
if (command === "revoke") return runRevoke(flags);
|
|
124
184
|
if (command === "list") return runList();
|
|
185
|
+
if (command === "sign-in-client") return runSignInClient(flags);
|
|
125
186
|
console.error(`[spokes] ${USAGE}`);
|
|
126
187
|
process.exitCode = 1;
|
|
127
188
|
}
|
|
@@ -119,3 +119,78 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7, #7633 D2)', () => {
|
|
|
119
119
|
expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: mode });
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `AILK_SPOKE_FLOW_SEED` — whether a hub lets a spoke key write its own
|
|
125
|
+
* workspace's flow definitions (#7876 D3, AC-3). Off unless set: unset, empty
|
|
126
|
+
* or blank is `false`; `true`/`1` is `true`; `false`/`0` is `false` —
|
|
127
|
+
* case-insensitively, after trimming. Any other value refuses the boot with a
|
|
128
|
+
* message naming the variable, never the value. `true` warns once at boot.
|
|
129
|
+
*/
|
|
130
|
+
describe('AILK_SPOKE_FLOW_SEED (#7876 D3)', () => {
|
|
131
|
+
const originalEnv = process.env;
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
jest.resetModules();
|
|
135
|
+
process.env = { ...originalEnv };
|
|
136
|
+
delete process.env.AILK_SPOKE_FLOW_SEED;
|
|
137
|
+
delete process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
|
|
138
|
+
process.env.DATABASE_URL = 'postgresql://test:test@localhost/test';
|
|
139
|
+
jest.clearAllMocks();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
process.env = originalEnv;
|
|
144
|
+
jest.restoreAllMocks();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function seedWarnings(): unknown[][] {
|
|
148
|
+
return mockLoggerWarn.mock.calls.filter((call) =>
|
|
149
|
+
JSON.stringify(call).includes('AILK_SPOKE_FLOW_SEED'),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it.each([
|
|
154
|
+
[undefined, false],
|
|
155
|
+
['', false],
|
|
156
|
+
[' ', false],
|
|
157
|
+
['false', false],
|
|
158
|
+
['0', false],
|
|
159
|
+
['true', true],
|
|
160
|
+
['TRUE', true],
|
|
161
|
+
['1', true],
|
|
162
|
+
[' True ', true],
|
|
163
|
+
['yes', null],
|
|
164
|
+
])('parseSpokeFlowSeed(%j) is %j', (raw, expected) => {
|
|
165
|
+
const { parseSpokeFlowSeed } = require('../index');
|
|
166
|
+
expect(parseSpokeFlowSeed(raw)).toBe(expected);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('is off by default and warns nothing', () => {
|
|
170
|
+
const { config } = require('../index');
|
|
171
|
+
expect(config).toMatchObject({ AILK_SPOKE_FLOW_SEED: false });
|
|
172
|
+
expect(seedWarnings()).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('on warns once at boot', () => {
|
|
176
|
+
process.env.AILK_SPOKE_FLOW_SEED = 'true';
|
|
177
|
+
const { config } = require('../index');
|
|
178
|
+
expect(config).toMatchObject({ AILK_SPOKE_FLOW_SEED: true });
|
|
179
|
+
expect(seedWarnings()).toHaveLength(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('"yes" refuses the boot, naming the variable and never the value', () => {
|
|
183
|
+
process.env.AILK_SPOKE_FLOW_SEED = 'yes';
|
|
184
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
185
|
+
throw new Error('Process exit called');
|
|
186
|
+
});
|
|
187
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
188
|
+
|
|
189
|
+
expect(() => require('../index')).toThrow('Process exit called');
|
|
190
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
191
|
+
|
|
192
|
+
const logged = JSON.stringify([mockLoggerError.mock.calls, consoleSpy.mock.calls]);
|
|
193
|
+
expect(logged).toContain('AILK_SPOKE_FLOW_SEED must be true, 1, false, 0 or unset');
|
|
194
|
+
expect(logged).not.toContain('yes');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -122,6 +122,11 @@ export interface Config {
|
|
|
122
122
|
// string, so no reader may test it by truthiness. Parsed by
|
|
123
123
|
// parseHubRequireSpokeKey() below; read by services/public-site-resolver.ts.
|
|
124
124
|
AILK_HUB_REQUIRE_SPOKE_KEY: HubSpokeKeyMode;
|
|
125
|
+
// Whether a registered spoke key may write flow definitions into its own
|
|
126
|
+
// workspace through `PUT /v1/flows/:slug/definition` (#7876 D3). Off unless
|
|
127
|
+
// the hub's operator turns it on; warned at boot when on. Parsed by
|
|
128
|
+
// parseSpokeFlowSeed() below; read by routes/flows/index.ts.
|
|
129
|
+
AILK_SPOKE_FLOW_SEED: boolean;
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
/** `AILK_HUB_REQUIRE_SPOKE_KEY`'s three states (#7633 D2). */
|
|
@@ -267,6 +272,19 @@ export function parseHubRequireSpokeKey(raw: string | undefined): HubSpokeKeyMod
|
|
|
267
272
|
return null;
|
|
268
273
|
}
|
|
269
274
|
|
|
275
|
+
/**
|
|
276
|
+
* `AILK_SPOKE_FLOW_SEED` (#7876 D3): unset, empty or blank → `false`;
|
|
277
|
+
* `true`/`1` → `true`; `false`/`0` → `false` — case-insensitive, after
|
|
278
|
+
* trimming. `null` for any other value, which `validateConfig()` refuses at
|
|
279
|
+
* boot.
|
|
280
|
+
*/
|
|
281
|
+
export function parseSpokeFlowSeed(raw: string | undefined): boolean | null {
|
|
282
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
283
|
+
if (value === '' || value === 'false' || value === '0') return false;
|
|
284
|
+
if (value === 'true' || value === '1') return true;
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
270
288
|
/** Enabled modules whose state lives in the database (see validateConfig). */
|
|
271
289
|
const DATABASE_MODULES = [
|
|
272
290
|
'commerce',
|
|
@@ -374,6 +392,22 @@ function validateConfig(): Config {
|
|
|
374
392
|
);
|
|
375
393
|
}
|
|
376
394
|
|
|
395
|
+
// Flow seeding is a write a spoke key could not do before (#7876 D3): a
|
|
396
|
+
// typo must never read as either state, and turning it on is never silent.
|
|
397
|
+
// The message names the variable, never its value.
|
|
398
|
+
const spokeFlowSeed = parseSpokeFlowSeed(process.env.AILK_SPOKE_FLOW_SEED);
|
|
399
|
+
if (spokeFlowSeed === null) {
|
|
400
|
+
const message = '❌ AILK_SPOKE_FLOW_SEED must be true, 1, false, 0 or unset';
|
|
401
|
+
logger.error(message);
|
|
402
|
+
console.error(message);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
if (spokeFlowSeed) {
|
|
406
|
+
logger.warn(
|
|
407
|
+
"AILK_SPOKE_FLOW_SEED=true — a registered spoke key may write its own workspace's flow definitions",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
377
411
|
return {
|
|
378
412
|
NODE_ENV: nodeEnv,
|
|
379
413
|
PORT: parseInt(process.env.PORT || '3002', 10),
|
|
@@ -446,6 +480,7 @@ function validateConfig(): Config {
|
|
|
446
480
|
SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
|
|
447
481
|
}),
|
|
448
482
|
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
|
|
483
|
+
AILK_SPOKE_FLOW_SEED: spokeFlowSeed,
|
|
449
484
|
};
|
|
450
485
|
}
|
|
451
486
|
|