@beignet/cli 0.0.26 → 0.0.27

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 (75) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/choices.d.ts +8 -0
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +14 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.js +2 -2
  8. package/dist/db.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +109 -13
  11. package/dist/index.js.map +1 -1
  12. package/dist/inspect.js +51 -3
  13. package/dist/inspect.js.map +1 -1
  14. package/dist/make/inbox.d.ts +63 -0
  15. package/dist/make/inbox.d.ts.map +1 -0
  16. package/dist/make/inbox.js +1747 -0
  17. package/dist/make/inbox.js.map +1 -0
  18. package/dist/make/payments.d.ts +61 -0
  19. package/dist/make/payments.d.ts.map +1 -0
  20. package/dist/make/payments.js +1900 -0
  21. package/dist/make/payments.js.map +1 -0
  22. package/dist/make/shared.d.ts +575 -0
  23. package/dist/make/shared.d.ts.map +1 -0
  24. package/dist/make/shared.js +2166 -0
  25. package/dist/make/shared.js.map +1 -0
  26. package/dist/make/tenancy.d.ts +101 -0
  27. package/dist/make/tenancy.d.ts.map +1 -0
  28. package/dist/make/tenancy.js +4409 -0
  29. package/dist/make/tenancy.js.map +1 -0
  30. package/dist/make.d.ts +8 -44
  31. package/dist/make.d.ts.map +1 -1
  32. package/dist/make.js +71 -3659
  33. package/dist/make.js.map +1 -1
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +44 -3
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/provider-add.d.ts +19 -0
  38. package/dist/provider-add.d.ts.map +1 -0
  39. package/dist/provider-add.js +691 -0
  40. package/dist/provider-add.js.map +1 -0
  41. package/dist/registry-edits.d.ts +9 -0
  42. package/dist/registry-edits.d.ts.map +1 -1
  43. package/dist/registry-edits.js +32 -0
  44. package/dist/registry-edits.js.map +1 -1
  45. package/dist/task.d.ts +2 -0
  46. package/dist/task.d.ts.map +1 -1
  47. package/dist/task.js +2 -0
  48. package/dist/task.js.map +1 -1
  49. package/dist/templates/agents.js +2 -2
  50. package/dist/templates/server.d.ts.map +1 -1
  51. package/dist/templates/server.js +44 -37
  52. package/dist/templates/server.js.map +1 -1
  53. package/dist/templates/shared.d.ts +5 -0
  54. package/dist/templates/shared.d.ts.map +1 -1
  55. package/dist/templates/shared.js +5 -0
  56. package/dist/templates/shared.js.map +1 -1
  57. package/package.json +2 -2
  58. package/skills/app-structure/SKILL.md +1 -1
  59. package/src/choices.ts +28 -0
  60. package/src/db.ts +2 -2
  61. package/src/index.ts +170 -13
  62. package/src/inspect.ts +68 -2
  63. package/src/make/inbox.ts +2015 -0
  64. package/src/make/payments.ts +2182 -0
  65. package/src/make/shared.ts +3550 -0
  66. package/src/make/tenancy.ts +4809 -0
  67. package/src/make.ts +380 -5289
  68. package/src/mcp.ts +65 -2
  69. package/src/provider-add.ts +926 -0
  70. package/src/registry-edits.ts +47 -0
  71. package/src/task.ts +8 -10
  72. package/src/templates/agents.ts +2 -2
  73. package/src/templates/server.ts +44 -37
  74. package/src/templates/shared.ts +5 -0
  75. package/src/test-helpers/generated-app.ts +168 -0
@@ -0,0 +1,4409 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { loadBeignetConfig, resolveConfig, } from "../config.js";
4
+ import { appendToArrayExpression, arrayInitializerInfo, } from "../registry-edits.js";
5
+ import { serverFiles } from "../templates/server.js";
6
+ import { addNamedImport, addNamedTypeImport, aliasModule, appendCreateGatePolicy, appendDeferredPortKey, appendGatePolicyMember, appendSharedErrorEntries, appendTupleTypeMember, assertStandardApp, createRepositoriesReturnBodySource, detectResourceDatabase, detectResourcePersistence, detectUiShell, drizzleDialects, drizzleRepositoriesPath, drizzleSchemaIndexPath, fileExists, hasTopLevelObjectProperty, infrastructureDir, insertCreateRepositoriesProperty, insertTypeProperty, missingCapabilityBuilderFiles, notificationPortWirings, planGeneratedFiles, readOptionalFile, relativeModule, resourceNames, resourceSharedErrorsPath, seedEntrypointPath, typeBodySource, typeHasTopLevelProperty, uniqueStrings, updateDatabaseProviderPick, updateDatabaseResetTables, updateDrizzleSchemaIndexExports, updateOpenApiRouteContracts, updatePackageJson, updatePackageScripts, updateServerRoutes, updateTransactionPortsPick, wirePortProviders, writePlannedGeneratedFile, } from "./shared.js";
7
+ const workspaceDeferredPortKeys = ["workspaces", "members", "invites"];
8
+ const workspacePortEntries = [
9
+ { key: "workspaces", type: "WorkspaceRepository" },
10
+ { key: "members", type: "WorkspaceMemberRepository" },
11
+ { key: "invites", type: "WorkspaceInviteRepository" },
12
+ ];
13
+ const workspaceRepositoryFactories = [
14
+ { key: "workspaces", factory: "createDrizzleWorkspaceRepository" },
15
+ { key: "members", factory: "createDrizzleWorkspaceMemberRepository" },
16
+ { key: "invites", factory: "createDrizzleWorkspaceInviteRepository" },
17
+ ];
18
+ const workspaceContractNames = [
19
+ "listMyWorkspaces",
20
+ "createWorkspace",
21
+ "getCurrentWorkspace",
22
+ "updateWorkspace",
23
+ "switchWorkspace",
24
+ "listMembers",
25
+ "updateMemberRole",
26
+ "removeMember",
27
+ "listInvites",
28
+ "createInvite",
29
+ "revokeInvite",
30
+ "acceptInvite",
31
+ ];
32
+ export async function makeTenancy(options) {
33
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
34
+ const config = options.config
35
+ ? resolveConfig(options.config)
36
+ : await loadBeignetConfig(targetDir);
37
+ const force = Boolean(options.force);
38
+ const dryRun = Boolean(options.dryRun);
39
+ await assertStandardApp(targetDir, config);
40
+ const persistence = await detectResourcePersistence(targetDir, config);
41
+ if (persistence !== "drizzle") {
42
+ throw new Error("beignet make tenancy expects a Drizzle-backed Beignet app because workspace and membership state must be durable. Add a database provider first, then run make tenancy again.");
43
+ }
44
+ const sharedErrorsFile = resourceSharedErrorsPath(config);
45
+ if (!(await fileExists(path.join(targetDir, sharedErrorsFile)))) {
46
+ throw new Error(`beignet make tenancy expects the shared error catalog ${sharedErrorsFile}. Restore the generated shared errors file, then run make tenancy again.`);
47
+ }
48
+ const database = await detectResourceDatabase(targetDir, config);
49
+ const hasShell = await detectUiShell(targetDir, config);
50
+ const workspaceNames = resourceNames("workspaces");
51
+ const generatedFiles = [
52
+ ...(await missingCapabilityBuilderFiles(targetDir, "notifications", config)),
53
+ ...tenancyFiles(config, database),
54
+ ...(hasShell ? tenancyUiFiles(config) : []),
55
+ ];
56
+ // Plan every write before performing any: replaced starter files first
57
+ // (the D5 replace-or-abort mechanic), then generated files, then all
58
+ // string-anchor edits with dryRun: true. An anchor miss or divergent file
59
+ // aborts before the generator touches the app.
60
+ const replacementPlans = [
61
+ await planReplacedStarterFile(targetDir, { path: tenantHelperPath(config), content: tenancyTenantHelperFile() }, serverFiles.tenantHelper, { force, manual: tenantHelperManualInstructions(config) }),
62
+ await planReplacedStarterFile(targetDir, { path: serverContextPath(config), content: tenancyServerContextFile() }, serverFiles.serverContext, { force, manual: serverContextManualInstructions(config) }),
63
+ ];
64
+ const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
65
+ force,
66
+ });
67
+ const plannedUpdates = await updateTenancyWiring(targetDir, workspaceNames, config, { dryRun: true, hasShell });
68
+ const plannedProviderUpdates = (await wirePortProviders(targetDir, config, notificationPortWirings("make tenancy"), { dryRun: true })).updatedFiles;
69
+ const plannedSeedChanges = await registerTenancySeeds(targetDir, config, {
70
+ dryRun: true,
71
+ });
72
+ const allPlannedFiles = [...replacementPlans, ...plannedFiles];
73
+ const createdFiles = allPlannedFiles
74
+ .filter((file) => file.result === "created")
75
+ .map((file) => file.path);
76
+ const updatedFiles = allPlannedFiles
77
+ .filter((file) => file.result === "updated")
78
+ .map((file) => file.path);
79
+ const skippedFiles = allPlannedFiles
80
+ .filter((file) => file.result === "skipped")
81
+ .map((file) => file.path);
82
+ if (dryRun) {
83
+ updatedFiles.push(...plannedUpdates, ...plannedProviderUpdates, ...plannedSeedChanges.updatedFiles);
84
+ }
85
+ else {
86
+ for (const file of allPlannedFiles) {
87
+ await writePlannedGeneratedFile(targetDir, file);
88
+ }
89
+ updatedFiles.push(...(await updateTenancyWiring(targetDir, workspaceNames, config, {
90
+ dryRun: false,
91
+ hasShell,
92
+ })));
93
+ updatedFiles.push(...(await wirePortProviders(targetDir, config, notificationPortWirings("make tenancy"), { dryRun: false })).updatedFiles);
94
+ const seedChanges = await registerTenancySeeds(targetDir, config, {
95
+ dryRun: false,
96
+ });
97
+ updatedFiles.push(...seedChanges.updatedFiles);
98
+ }
99
+ return {
100
+ schemaVersion: 1,
101
+ name: "tenancy",
102
+ targetDir,
103
+ dryRun,
104
+ files: uniqueStrings([
105
+ ...generatedFiles.map((file) => file.path),
106
+ tenantHelperPath(config),
107
+ serverContextPath(config),
108
+ ]),
109
+ createdFiles: uniqueStrings(createdFiles),
110
+ updatedFiles: uniqueStrings(updatedFiles),
111
+ skippedFiles: uniqueStrings(skippedFiles),
112
+ };
113
+ }
114
+ /**
115
+ * Post-generate guidance lines for `beignet make tenancy`. The CLI command
116
+ * wraps these in its standard next-steps formatting.
117
+ */
118
+ export function makeTenancyNextStepLines() {
119
+ return [
120
+ "Run beignet db generate and beignet db migrate so the workspaces, workspace_members, and workspace_invites tables exist.",
121
+ "Requests resolve the active workspace from memberships and the beignet-workspace cookie; users without a workspace have no tenant, so point them at Settings > Workspace to create one.",
122
+ "Run the db:seed script for a demo workspace with a sign-in-able demo admin (demo-admin@example.com).",
123
+ "Invite emails send through the in-memory mailer in development; swap server/providers.ts to a real mail provider such as @beignet/provider-mail-resend for delivery.",
124
+ "Membership is resolved with one indexed query per authenticated request; add caching around resolveRequestWorkspace if request volume warrants it.",
125
+ "Run your app's test, lint, and typecheck commands, then beignet lint and beignet doctor.",
126
+ ];
127
+ }
128
+ export function tenantHelperPath(config) {
129
+ return path.posix.join(path.posix.dirname(config.paths.useCaseBuilder), "tenant.ts");
130
+ }
131
+ export function serverContextPath(config) {
132
+ return path.posix.join(path.posix.dirname(config.paths.server), "context.ts");
133
+ }
134
+ function tenantHelperManualInstructions(config) {
135
+ return `${tenantHelperPath(config)} has been customized, so make tenancy cannot replace it safely. Re-run with --force to overwrite it, or update it manually: export WORKSPACE_COOKIE_NAME = "beignet-workspace", replace resolveRequestTenant with an async resolveRequestWorkspace({ auth, req, ports }) that loads ports.members.listByUser(auth.user.id), picks the membership matching the cookie (falling back to the first), and returns { tenant: createTenant(workspaceId), membership }, and keep resolveServiceWorkspace(tenantId) for service contexts.`;
136
+ }
137
+ function serverContextManualInstructions(config) {
138
+ return `${serverContextPath(config)} has been customized, so make tenancy cannot replace it safely. Re-run with --force to overwrite it, or update it manually: in the request context call const { tenant, membership } = await resolveRequestWorkspace({ auth, req, ports }) from ${tenantHelperPath(config)}, spread the tenant when present, and set membership on the returned context (and membership: null in the service context).`;
139
+ }
140
+ /**
141
+ * Replace-or-abort planning for starter-owned files (D5).
142
+ *
143
+ * The current content is byte-compared against the pristine starter template
144
+ * and against this generator's own output. A match with the generator output
145
+ * plans a skip (idempotent re-run); a match with the pristine starter (or a
146
+ * missing file) plans the replacement; anything else aborts with manual
147
+ * instructions before any file is written. `--force` overrides.
148
+ */
149
+ export async function planReplacedStarterFile(targetDir, file, pristineContent, options) {
150
+ const existing = await readOptionalFile(path.join(targetDir, file.path));
151
+ if (existing === file.content) {
152
+ return { ...file, result: "skipped" };
153
+ }
154
+ if (existing === undefined) {
155
+ return { ...file, result: "created" };
156
+ }
157
+ if (existing === pristineContent || options.force) {
158
+ return { ...file, result: "updated" };
159
+ }
160
+ throw new Error(options.manual);
161
+ }
162
+ export async function updateTenancyWiring(targetDir, workspaceNames, config, options) {
163
+ const updated = new Set();
164
+ const editOptions = { dryRun: options.dryRun };
165
+ if (await updatePackageJson(targetDir, editOptions)) {
166
+ updated.add("package.json");
167
+ }
168
+ if (await updateAppContextMembership(targetDir, config, editOptions)) {
169
+ updated.add(config.paths.appContext);
170
+ }
171
+ if (await updateWorkspacePortsIndex(targetDir, config, editOptions)) {
172
+ updated.add(config.paths.ports);
173
+ }
174
+ if (await updateWorkspaceInfrastructurePorts(targetDir, config, editOptions)) {
175
+ updated.add(config.paths.infrastructurePorts);
176
+ }
177
+ if (await updateDrizzleSchemaIndexExports(targetDir, config, {
178
+ exports: ["workspaceInvites", "workspaceMembers", "workspaces"],
179
+ module: "./workspaces",
180
+ }, editOptions)) {
181
+ updated.add(drizzleSchemaIndexPath(config));
182
+ }
183
+ if (await updateWorkspaceDrizzleRepositories(targetDir, config, editOptions)) {
184
+ updated.add(drizzleRepositoriesPath(config));
185
+ }
186
+ if (await updateDatabaseProviderPick(targetDir, config, workspaceDeferredPortKeys, editOptions)) {
187
+ updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
188
+ }
189
+ if (await updateTransactionPortsPick(targetDir, config, workspaceDeferredPortKeys, "make tenancy", editOptions)) {
190
+ updated.add(path.join(infrastructureDir(config), "db/transaction-ports.ts"));
191
+ }
192
+ if (await updateDatabaseResetTables(targetDir, config, ["workspace_invites", "workspace_members", "workspaces"], editOptions)) {
193
+ updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
194
+ }
195
+ if (await updateWorkspaceSharedErrors(targetDir, config, editOptions)) {
196
+ updated.add(resourceSharedErrorsPath(config));
197
+ }
198
+ if (await updateOpenApiRouteContracts(targetDir, config, {
199
+ contractNames: workspaceContractNames,
200
+ contractsModule: aliasModule(path.join(config.paths.features, "workspaces/contracts.ts")),
201
+ }, editOptions)) {
202
+ updated.add(config.paths.openapiRoute);
203
+ }
204
+ const routesFile = await updateServerRoutes(targetDir, workspaceNames, config, editOptions);
205
+ if (routesFile)
206
+ updated.add(routesFile);
207
+ if (options.hasShell) {
208
+ if (await updateSettingsNavigation(targetDir, editOptions)) {
209
+ updated.add("components/settings-nav.tsx");
210
+ }
211
+ }
212
+ return [...updated];
213
+ }
214
+ async function registerTenancySeeds(targetDir, config, options) {
215
+ const changes = { updatedFiles: [] };
216
+ if (await updatePackageScripts(targetDir, { "db:seed": `bun ${seedEntrypointPath(config)}` }, options)) {
217
+ changes.updatedFiles.push("package.json");
218
+ }
219
+ return changes;
220
+ }
221
+ export async function updateAppContextMembership(targetDir, config, options) {
222
+ const filePath = path.join(targetDir, config.paths.appContext);
223
+ const original = await readFile(filePath, "utf8");
224
+ let next = addNamedTypeImport(original, "WorkspaceMembershipContext", aliasModule(path.join(config.paths.features, "workspaces/policy.ts")));
225
+ if (!typeHasTopLevelProperty(next, "AppContext", "membership")) {
226
+ next = insertTypeProperty(next, "AppContext", "membership?: WorkspaceMembershipContext | null;", `Could not find AppContext in ${config.paths.appContext}. Add membership?: WorkspaceMembershipContext | null; manually, or restore the generated app context before running make tenancy.`);
227
+ }
228
+ if (next === original)
229
+ return false;
230
+ if (!options.dryRun)
231
+ await writeFile(filePath, next);
232
+ return true;
233
+ }
234
+ export async function updateWorkspacePortsIndex(targetDir, config, options) {
235
+ const filePath = path.join(targetDir, config.paths.ports);
236
+ const original = await readFile(filePath, "utf8");
237
+ let next = original;
238
+ const portsModule = relativeModule(config.paths.ports, path.join(config.paths.features, "workspaces/ports.ts"));
239
+ const policyModule = relativeModule(config.paths.ports, path.join(config.paths.features, "workspaces/policy.ts"));
240
+ for (const entry of workspacePortEntries) {
241
+ next = addNamedTypeImport(next, entry.type, portsModule);
242
+ }
243
+ next = addNamedTypeImport(next, "workspacePolicy", policyModule);
244
+ for (const entry of workspacePortEntries) {
245
+ const property = `${entry.key}: ${entry.type};`;
246
+ if (!typeHasTopLevelProperty(next, "AppPorts", entry.key)) {
247
+ next = insertTypeProperty(next, "AppPorts", property, `Could not find AppPorts in ${config.paths.ports}. Add ${property} manually, or restore the generated ports file before running make tenancy.`);
248
+ }
249
+ if (typeBodySource(next, "AppTransactionPorts") !== undefined &&
250
+ !typeHasTopLevelProperty(next, "AppTransactionPorts", entry.key)) {
251
+ next = insertTypeProperty(next, "AppTransactionPorts", property, `Could not find AppTransactionPorts in ${config.paths.ports}. Add ${property} manually, or restore the generated ports file before running make tenancy.`);
252
+ }
253
+ }
254
+ next = appendTupleTypeMember(next, "AppGate", "typeof workspacePolicy");
255
+ next = appendGatePolicyMember(next, "typeof workspacePolicy");
256
+ if (next === original)
257
+ return false;
258
+ if (!options.dryRun)
259
+ await writeFile(filePath, next);
260
+ return true;
261
+ }
262
+ export async function updateWorkspaceInfrastructurePorts(targetDir, config, options) {
263
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
264
+ const original = await readFile(filePath, "utf8");
265
+ let next = original;
266
+ for (const portKey of workspaceDeferredPortKeys) {
267
+ const appended = appendDeferredPortKey(next, portKey);
268
+ if (appended === next && !new RegExp(`["']${portKey}["']`).test(next)) {
269
+ throw new Error(`Could not find the deferred port list in ${config.paths.infrastructurePorts}. Add "${portKey}" manually, or restore the generated infrastructure ports file before running make tenancy.`);
270
+ }
271
+ next = appended;
272
+ }
273
+ const policyModule = relativeModule(config.paths.infrastructurePorts, path.join(config.paths.features, "workspaces/policy.ts"));
274
+ next = addNamedImport(next, "workspacePolicy", policyModule);
275
+ next = appendCreateGatePolicy(next, "workspacePolicy", `Could not find createGate({ policies: [...] }) in ${config.paths.infrastructurePorts}. Add workspacePolicy to the gate policies manually, or restore the generated infrastructure ports file before running make tenancy.`);
276
+ if (next === original)
277
+ return false;
278
+ if (!options.dryRun)
279
+ await writeFile(filePath, next);
280
+ return true;
281
+ }
282
+ export async function updateWorkspaceDrizzleRepositories(targetDir, config, options) {
283
+ const file = drizzleRepositoriesPath(config);
284
+ const filePath = path.join(targetDir, file);
285
+ if (!(await fileExists(filePath)))
286
+ return false;
287
+ const original = await readFile(filePath, "utf8");
288
+ let next = original;
289
+ const repositoryModule = relativeModule(file, path.join(infrastructureDir(config), "workspaces/drizzle-workspace-repository.ts"));
290
+ for (const entry of workspaceRepositoryFactories) {
291
+ next = addNamedImport(next, entry.factory, repositoryModule);
292
+ }
293
+ for (const entry of workspaceRepositoryFactories) {
294
+ const body = createRepositoriesReturnBodySource(next);
295
+ const property = `${entry.key}: ${entry.factory}(db),`;
296
+ if (body === undefined) {
297
+ throw new Error(`Could not find return { in createRepositories in ${file}. Add ${property} manually, or restore the generated repositories file before running make tenancy.`);
298
+ }
299
+ if (!hasTopLevelObjectProperty(body, entry.key)) {
300
+ next = insertCreateRepositoriesProperty(next, property, `Could not find return { in createRepositories in ${file}. Add ${property} manually, or restore the generated repositories file before running make tenancy.`);
301
+ }
302
+ }
303
+ if (next === original)
304
+ return false;
305
+ if (!options.dryRun)
306
+ await writeFile(filePath, next);
307
+ return true;
308
+ }
309
+ export async function updateWorkspaceSharedErrors(targetDir, config, options) {
310
+ return appendSharedErrorEntries(targetDir, config, [
311
+ {
312
+ name: "WorkspaceNotFound",
313
+ entry: `WorkspaceNotFound: {
314
+ \t\tcode: "WORKSPACE_NOT_FOUND",
315
+ \t\tstatus: 404,
316
+ \t\tmessage: "Workspace not found",
317
+ \t},`,
318
+ },
319
+ {
320
+ name: "WorkspaceSlugTaken",
321
+ entry: `WorkspaceSlugTaken: {
322
+ \t\tcode: "WORKSPACE_SLUG_TAKEN",
323
+ \t\tstatus: 409,
324
+ \t\tmessage: "A workspace with this slug already exists",
325
+ \t},`,
326
+ },
327
+ {
328
+ name: "WorkspaceRequired",
329
+ entry: `WorkspaceRequired: {
330
+ \t\tcode: "WORKSPACE_REQUIRED",
331
+ \t\tstatus: 400,
332
+ \t\tmessage: "An active workspace is required",
333
+ \t},`,
334
+ },
335
+ {
336
+ name: "NotAWorkspaceMember",
337
+ entry: `NotAWorkspaceMember: {
338
+ \t\tcode: "NOT_A_WORKSPACE_MEMBER",
339
+ \t\tstatus: 403,
340
+ \t\tmessage: "You are not a member of this workspace",
341
+ \t},`,
342
+ },
343
+ {
344
+ name: "LastAdminRemoval",
345
+ entry: `LastAdminRemoval: {
346
+ \t\tcode: "LAST_ADMIN_REMOVAL",
347
+ \t\tstatus: 409,
348
+ \t\tmessage: "A workspace must keep at least one admin",
349
+ \t},`,
350
+ },
351
+ {
352
+ name: "InviteNotFound",
353
+ entry: `InviteNotFound: {
354
+ \t\tcode: "INVITE_NOT_FOUND",
355
+ \t\tstatus: 404,
356
+ \t\tmessage: "Invite not found",
357
+ \t},`,
358
+ },
359
+ {
360
+ name: "InviteExpired",
361
+ entry: `InviteExpired: {
362
+ \t\tcode: "INVITE_EXPIRED",
363
+ \t\tstatus: 410,
364
+ \t\tmessage: "Invite has expired",
365
+ \t},`,
366
+ },
367
+ {
368
+ name: "InviteAlreadyAccepted",
369
+ entry: `InviteAlreadyAccepted: {
370
+ \t\tcode: "INVITE_ALREADY_ACCEPTED",
371
+ \t\tstatus: 409,
372
+ \t\tmessage: "Invite has already been accepted",
373
+ \t},`,
374
+ },
375
+ {
376
+ name: "IdempotencyConflict",
377
+ entry: `IdempotencyConflict: {
378
+ \t\tcode: "IDEMPOTENCY_CONFLICT",
379
+ \t\tstatus: 409,
380
+ \t\tmessage: "Idempotency key was already used with a different request",
381
+ \t},`,
382
+ },
383
+ {
384
+ name: "IdempotencyInProgress",
385
+ entry: `IdempotencyInProgress: {
386
+ \t\tcode: "IDEMPOTENCY_IN_PROGRESS",
387
+ \t\tstatus: 409,
388
+ \t\tmessage: "Idempotency key is already being processed",
389
+ \t},`,
390
+ },
391
+ ], "make tenancy", options);
392
+ }
393
+ export async function updateSettingsNavigation(targetDir, options) {
394
+ const file = "components/settings-nav.tsx";
395
+ const filePath = path.join(targetDir, file);
396
+ const original = await readOptionalFile(filePath);
397
+ const manual = `Add { href: "/settings/workspace", label: "Workspace" } and { href: "/settings/members", label: "Members" } to the settingsLinks array in ${file} manually, or restore the generated settings navigation before running make tenancy.`;
398
+ if (original === undefined) {
399
+ throw new Error(`Could not find ${file}. ${manual}`);
400
+ }
401
+ const entries = [
402
+ { href: "/settings/workspace", label: "Workspace" },
403
+ { href: "/settings/members", label: "Members" },
404
+ ].filter((entry) => !original.includes(`"${entry.href}"`));
405
+ if (entries.length === 0)
406
+ return false;
407
+ const links = arrayInitializerInfo(original, "settingsLinks");
408
+ if (!links) {
409
+ throw new Error(`Could not find the settingsLinks array in ${file}. ${manual}`);
410
+ }
411
+ const nextArray = appendToArrayExpression(links.text, entries.map((entry) => `{ href: "${entry.href}", label: "${entry.label}" }`));
412
+ const next = `${original.slice(0, links.start)}${nextArray}${original.slice(links.end)}`;
413
+ if (next === original)
414
+ return false;
415
+ if (!options.dryRun)
416
+ await writeFile(filePath, next);
417
+ return true;
418
+ }
419
+ export function tenancyFiles(config, database) {
420
+ const featureDir = (file) => path.join(config.paths.features, "workspaces", file);
421
+ return [
422
+ { path: featureDir("schemas.ts"), content: tenancySchemasFile() },
423
+ { path: featureDir("contracts.ts"), content: tenancyContractsFile() },
424
+ { path: featureDir("policy.ts"), content: tenancyPolicyFile() },
425
+ { path: featureDir("ports.ts"), content: tenancyPortsFile() },
426
+ {
427
+ path: featureDir("use-cases/current-workspace.ts"),
428
+ content: currentWorkspaceHelperFile(config),
429
+ },
430
+ {
431
+ path: featureDir("use-cases/create-workspace.ts"),
432
+ content: createWorkspaceUseCaseFile(),
433
+ },
434
+ {
435
+ path: featureDir("use-cases/get-current-workspace.ts"),
436
+ content: getCurrentWorkspaceUseCaseFile(),
437
+ },
438
+ {
439
+ path: featureDir("use-cases/list-my-workspaces.ts"),
440
+ content: listMyWorkspacesUseCaseFile(),
441
+ },
442
+ {
443
+ path: featureDir("use-cases/switch-workspace.ts"),
444
+ content: switchWorkspaceUseCaseFile(),
445
+ },
446
+ {
447
+ path: featureDir("use-cases/update-workspace.ts"),
448
+ content: updateWorkspaceUseCaseFile(),
449
+ },
450
+ {
451
+ path: featureDir("use-cases/list-members.ts"),
452
+ content: listMembersUseCaseFile(),
453
+ },
454
+ {
455
+ path: featureDir("use-cases/update-member-role.ts"),
456
+ content: updateMemberRoleUseCaseFile(),
457
+ },
458
+ {
459
+ path: featureDir("use-cases/remove-member.ts"),
460
+ content: removeMemberUseCaseFile(),
461
+ },
462
+ {
463
+ path: featureDir("use-cases/list-invites.ts"),
464
+ content: listInvitesUseCaseFile(),
465
+ },
466
+ {
467
+ path: featureDir("use-cases/create-invite.ts"),
468
+ content: createInviteUseCaseFile(),
469
+ },
470
+ {
471
+ path: featureDir("use-cases/revoke-invite.ts"),
472
+ content: revokeInviteUseCaseFile(),
473
+ },
474
+ {
475
+ path: featureDir("use-cases/accept-invite.ts"),
476
+ content: acceptInviteUseCaseFile(),
477
+ },
478
+ {
479
+ path: featureDir("use-cases/index.ts"),
480
+ content: tenancyUseCasesIndexFile(),
481
+ },
482
+ {
483
+ path: featureDir("notifications/index.ts"),
484
+ content: tenancyNotificationsFile(),
485
+ },
486
+ { path: featureDir("routes.ts"), content: tenancyRoutesFile(config) },
487
+ { path: featureDir("seeds/index.ts"), content: tenancySeedsFile(config) },
488
+ {
489
+ path: seedEntrypointPath(config),
490
+ content: tenancyServerSeedFile(config),
491
+ },
492
+ {
493
+ path: featureDir("tests/factories/context.ts"),
494
+ content: tenancyContextFactoryFile(config),
495
+ },
496
+ {
497
+ path: featureDir("tests/factories/workspace.ts"),
498
+ content: workspaceFactoryFile(config),
499
+ },
500
+ {
501
+ path: featureDir("tests/factories/workspace-member.ts"),
502
+ content: workspaceMemberFactoryFile(config),
503
+ },
504
+ {
505
+ path: featureDir("tests/factories/workspace-invite.ts"),
506
+ content: workspaceInviteFactoryFile(config),
507
+ },
508
+ {
509
+ path: featureDir("tests/membership.test.ts"),
510
+ content: membershipTestFile(config),
511
+ },
512
+ {
513
+ path: featureDir("tests/invites.test.ts"),
514
+ content: invitesTestFile(),
515
+ },
516
+ {
517
+ path: featureDir("tests/policy.test.ts"),
518
+ content: policyTestFile(),
519
+ },
520
+ {
521
+ path: path.join(infrastructureDir(config), "workspaces/drizzle-workspace-repository.ts"),
522
+ content: workspaceDrizzleRepositoryFile(config, database),
523
+ },
524
+ {
525
+ path: path.join(infrastructureDir(config), "db/schema/workspaces.ts"),
526
+ content: workspaceDrizzleSchemaFile(database),
527
+ },
528
+ ];
529
+ }
530
+ export function tenancyUiFiles(config) {
531
+ const featureDir = (file) => path.join(config.paths.features, "workspaces", file);
532
+ return [
533
+ {
534
+ path: featureDir("client/queries.ts"),
535
+ content: workspaceClientQueriesFile(),
536
+ },
537
+ {
538
+ path: featureDir("components/workspace-switcher.tsx"),
539
+ content: workspaceSwitcherComponentFile(),
540
+ },
541
+ {
542
+ path: featureDir("components/workspace-settings-form.tsx"),
543
+ content: workspaceSettingsFormComponentFile(),
544
+ },
545
+ {
546
+ path: featureDir("components/invite-form.tsx"),
547
+ content: inviteFormComponentFile(),
548
+ },
549
+ {
550
+ path: featureDir("components/members-settings.tsx"),
551
+ content: membersSettingsComponentFile(),
552
+ },
553
+ {
554
+ path: featureDir("components/invite-accept.tsx"),
555
+ content: inviteAcceptComponentFile(),
556
+ },
557
+ {
558
+ path: featureDir("components/index.ts"),
559
+ content: workspaceComponentsIndexFile(),
560
+ },
561
+ {
562
+ path: "app/(app)/settings/workspace/page.tsx",
563
+ content: settingsWorkspacePageFile(),
564
+ },
565
+ {
566
+ path: "app/(app)/settings/members/page.tsx",
567
+ content: settingsMembersPageFile(),
568
+ },
569
+ {
570
+ path: "app/invites/[token]/page.tsx",
571
+ content: inviteTokenPageFile(),
572
+ },
573
+ ];
574
+ }
575
+ export function tenancySchemasFile() {
576
+ return `import { z } from "zod";
577
+
578
+ export const workspaceRoleSchema = z.enum(["admin", "member"]);
579
+ export const inviteStatusSchema = z.enum([
580
+ \t"pending",
581
+ \t"accepted",
582
+ \t"revoked",
583
+ \t"expired",
584
+ ]);
585
+
586
+ export const workspaceNameSchema = z.string().min(1).max(100);
587
+ export const workspaceSlugSchema = z
588
+ \t.string()
589
+ \t.min(1)
590
+ \t.max(60)
591
+ \t.regex(
592
+ \t\t/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
593
+ \t\t"Slugs are lowercase letters, numbers, and dashes.",
594
+ \t);
595
+
596
+ export const workspaceSchema = z.object({
597
+ \tid: z.string().min(1),
598
+ \tname: workspaceNameSchema,
599
+ \tslug: workspaceSlugSchema,
600
+ \tcreatedAt: z.string(),
601
+ \tupdatedAt: z.string(),
602
+ });
603
+
604
+ export const workspaceMemberSchema = z.object({
605
+ \tid: z.string().min(1),
606
+ \tworkspaceId: z.string().min(1),
607
+ \tuserId: z.string().min(1),
608
+ \trole: workspaceRoleSchema,
609
+ \tjoinedAt: z.string(),
610
+ });
611
+
612
+ export const workspaceMemberWithUserSchema = workspaceMemberSchema.extend({
613
+ \tuser: z.object({
614
+ \t\tid: z.string().min(1),
615
+ \t\tname: z.string(),
616
+ \t\temail: z.string().nullable(),
617
+ \t\timage: z.string().nullable(),
618
+ \t}),
619
+ });
620
+
621
+ export const workspaceInviteSchema = z.object({
622
+ \tid: z.string().min(1),
623
+ \tworkspaceId: z.string().min(1),
624
+ \temail: z.string().email(),
625
+ \trole: workspaceRoleSchema,
626
+ \ttoken: z.string().min(1),
627
+ \tinvitedByUserId: z.string().min(1),
628
+ \tstatus: inviteStatusSchema,
629
+ \texpiresAt: z.string(),
630
+ \tacceptedAt: z.string().nullable(),
631
+ \tcreatedAt: z.string(),
632
+ });
633
+
634
+ export const workspaceListItemSchema = workspaceSchema.extend({
635
+ \trole: workspaceRoleSchema,
636
+ });
637
+
638
+ export const listMyWorkspacesOutputSchema = z.object({
639
+ \titems: z.array(workspaceListItemSchema),
640
+ });
641
+
642
+ export const createWorkspaceInputSchema = z.object({
643
+ \tname: workspaceNameSchema,
644
+ \tslug: workspaceSlugSchema,
645
+ });
646
+
647
+ export const updateWorkspaceInputSchema = z
648
+ \t.object({
649
+ \t\tname: workspaceNameSchema.optional(),
650
+ \t\tslug: workspaceSlugSchema.optional(),
651
+ \t})
652
+ \t.refine((value) => value.name !== undefined || value.slug !== undefined, {
653
+ \t\tmessage: "Provide a name or slug to update.",
654
+ \t});
655
+
656
+ export const membershipContextSchema = z.object({
657
+ \tworkspaceId: z.string().min(1),
658
+ \trole: workspaceRoleSchema,
659
+ });
660
+
661
+ export const currentWorkspaceOutputSchema = z.object({
662
+ \tworkspace: workspaceSchema.nullable(),
663
+ \tmembership: membershipContextSchema.nullable(),
664
+ });
665
+
666
+ export const switchWorkspaceInputSchema = z.object({
667
+ \tworkspaceId: z.string().min(1),
668
+ });
669
+
670
+ export const switchWorkspaceOutputSchema = z.object({
671
+ \tworkspaceId: z.string().min(1),
672
+ });
673
+
674
+ export const listMembersOutputSchema = z.object({
675
+ \titems: z.array(workspaceMemberWithUserSchema),
676
+ });
677
+
678
+ export const memberUserIdParamsSchema = z.object({
679
+ \tuserId: z.string().min(1),
680
+ });
681
+
682
+ export const updateMemberRoleInputSchema = z.object({
683
+ \trole: workspaceRoleSchema,
684
+ });
685
+
686
+ export const listInvitesOutputSchema = z.object({
687
+ \titems: z.array(workspaceInviteSchema),
688
+ });
689
+
690
+ export const createInviteInputSchema = z.object({
691
+ \temail: z.string().email(),
692
+ \trole: workspaceRoleSchema.default("member"),
693
+ });
694
+
695
+ export const inviteIdParamsSchema = z.object({
696
+ \tinviteId: z.string().min(1),
697
+ });
698
+
699
+ export const inviteTokenParamsSchema = z.object({
700
+ \ttoken: z.string().min(1),
701
+ });
702
+
703
+ export const acceptInviteOutputSchema = z.object({
704
+ \tworkspace: workspaceSchema,
705
+ \tmembership: workspaceMemberSchema,
706
+ });
707
+
708
+ export type WorkspaceRole = z.infer<typeof workspaceRoleSchema>;
709
+ export type InviteStatus = z.infer<typeof inviteStatusSchema>;
710
+ export type Workspace = z.infer<typeof workspaceSchema>;
711
+ export type WorkspaceMember = z.infer<typeof workspaceMemberSchema>;
712
+ export type WorkspaceMemberWithUser = z.infer<
713
+ \ttypeof workspaceMemberWithUserSchema
714
+ >;
715
+ export type WorkspaceInvite = z.infer<typeof workspaceInviteSchema>;
716
+ export type WorkspaceListItem = z.infer<typeof workspaceListItemSchema>;
717
+ export type CreateWorkspaceInput = z.infer<typeof createWorkspaceInputSchema>;
718
+ export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceInputSchema>;
719
+ export type CurrentWorkspaceOutput = z.infer<
720
+ \ttypeof currentWorkspaceOutputSchema
721
+ >;
722
+ export type CreateInviteInput = z.infer<typeof createInviteInputSchema>;
723
+ `;
724
+ }
725
+ export function tenancyContractsFile() {
726
+ return `import { defineContractGroup } from "@beignet/core/contracts";
727
+ import { z } from "zod";
728
+ import { errors } from "@/features/shared/errors";
729
+ import {
730
+ \tacceptInviteOutputSchema,
731
+ \tcreateInviteInputSchema,
732
+ \tcreateWorkspaceInputSchema,
733
+ \tcurrentWorkspaceOutputSchema,
734
+ \tinviteIdParamsSchema,
735
+ \tinviteTokenParamsSchema,
736
+ \tlistInvitesOutputSchema,
737
+ \tlistMembersOutputSchema,
738
+ \tlistMyWorkspacesOutputSchema,
739
+ \tmemberUserIdParamsSchema,
740
+ \tswitchWorkspaceInputSchema,
741
+ \tswitchWorkspaceOutputSchema,
742
+ \tupdateMemberRoleInputSchema,
743
+ \tupdateWorkspaceInputSchema,
744
+ \tworkspaceInviteSchema,
745
+ \tworkspaceMemberSchema,
746
+ \tworkspaceSchema,
747
+ } from "@/features/workspaces/schemas";
748
+
749
+ const ErrorResponseSchema = z.object({
750
+ \tcode: z.string(),
751
+ \tmessage: z.string(),
752
+ \trequestId: z.string().optional(),
753
+ });
754
+
755
+ const workspaces = defineContractGroup()
756
+ \t.namespace("workspaces")
757
+ \t.prefix("/api/workspaces")
758
+ \t.errors({ Unauthorized: errors.Unauthorized })
759
+ \t.responses({
760
+ \t\t500: ErrorResponseSchema,
761
+ \t});
762
+
763
+ const idempotencyHeadersSchema = z.object({
764
+ \t"idempotency-key": z.string().min(1),
765
+ });
766
+
767
+ const idempotentWorkspaces = workspaces.headers(idempotencyHeadersSchema);
768
+
769
+ const idempotencyMeta = {
770
+ \trequired: true as const,
771
+ \theader: "idempotency-key" as const,
772
+ \tscope: "actor" as const,
773
+ \tttlSec: 86_400,
774
+ };
775
+
776
+ const idempotencyErrors = {
777
+ \tIdempotencyConflict: errors.IdempotencyConflict,
778
+ \tIdempotencyInProgress: errors.IdempotencyInProgress,
779
+ };
780
+
781
+ export const listMyWorkspaces = workspaces
782
+ \t.get("/")
783
+ \t.meta({ auth: "required" })
784
+ \t.responses({
785
+ \t\t200: listMyWorkspacesOutputSchema,
786
+ \t})
787
+ \t.openapi({ summary: "List my workspaces", tags: ["workspaces"] });
788
+
789
+ export const createWorkspace = idempotentWorkspaces
790
+ \t.post("/")
791
+ \t.body(createWorkspaceInputSchema)
792
+ \t.meta({ auth: "required", idempotency: idempotencyMeta })
793
+ \t.errors({
794
+ \t\tWorkspaceSlugTaken: errors.WorkspaceSlugTaken,
795
+ \t\t...idempotencyErrors,
796
+ \t})
797
+ \t.responses({
798
+ \t\t201: workspaceSchema,
799
+ \t})
800
+ \t.openapi({ summary: "Create a workspace", tags: ["workspaces"] });
801
+
802
+ export const getCurrentWorkspace = workspaces
803
+ \t.get("/current")
804
+ \t.meta({ auth: "required" })
805
+ \t.responses({
806
+ \t\t200: currentWorkspaceOutputSchema,
807
+ \t})
808
+ \t.openapi({ summary: "Get the active workspace", tags: ["workspaces"] });
809
+
810
+ export const updateWorkspace = workspaces
811
+ \t.put("/current")
812
+ \t.body(updateWorkspaceInputSchema)
813
+ \t.meta({ auth: "required" })
814
+ \t.errors({
815
+ \t\tForbidden: errors.Forbidden,
816
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
817
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
818
+ \t\tWorkspaceSlugTaken: errors.WorkspaceSlugTaken,
819
+ \t})
820
+ \t.responses({
821
+ \t\t200: workspaceSchema,
822
+ \t})
823
+ \t.openapi({ summary: "Update the active workspace", tags: ["workspaces"] });
824
+
825
+ export const switchWorkspace = workspaces
826
+ \t.post("/switch")
827
+ \t.body(switchWorkspaceInputSchema)
828
+ \t.meta({ auth: "required" })
829
+ \t.errors({
830
+ \t\tNotAWorkspaceMember: errors.NotAWorkspaceMember,
831
+ \t})
832
+ \t.responses({
833
+ \t\t200: switchWorkspaceOutputSchema,
834
+ \t})
835
+ \t.openapi({ summary: "Switch the active workspace", tags: ["workspaces"] });
836
+
837
+ export const listMembers = workspaces
838
+ \t.get("/current/members")
839
+ \t.meta({ auth: "required" })
840
+ \t.errors({
841
+ \t\tForbidden: errors.Forbidden,
842
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
843
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
844
+ \t})
845
+ \t.responses({
846
+ \t\t200: listMembersOutputSchema,
847
+ \t})
848
+ \t.openapi({ summary: "List workspace members", tags: ["workspaces"] });
849
+
850
+ export const updateMemberRole = workspaces
851
+ \t.put("/current/members/:userId")
852
+ \t.pathParams(memberUserIdParamsSchema)
853
+ \t.body(updateMemberRoleInputSchema)
854
+ \t.meta({ auth: "required" })
855
+ \t.errors({
856
+ \t\tForbidden: errors.Forbidden,
857
+ \t\tLastAdminRemoval: errors.LastAdminRemoval,
858
+ \t\tNotAWorkspaceMember: errors.NotAWorkspaceMember,
859
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
860
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
861
+ \t})
862
+ \t.responses({
863
+ \t\t200: workspaceMemberSchema,
864
+ \t})
865
+ \t.openapi({ summary: "Change a member's role", tags: ["workspaces"] });
866
+
867
+ export const removeMember = workspaces
868
+ \t.delete("/current/members/:userId")
869
+ \t.pathParams(memberUserIdParamsSchema)
870
+ \t.meta({ auth: "required" })
871
+ \t.errors({
872
+ \t\tForbidden: errors.Forbidden,
873
+ \t\tLastAdminRemoval: errors.LastAdminRemoval,
874
+ \t\tNotAWorkspaceMember: errors.NotAWorkspaceMember,
875
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
876
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
877
+ \t})
878
+ \t.responses({
879
+ \t\t204: null,
880
+ \t})
881
+ \t.openapi({ summary: "Remove a workspace member", tags: ["workspaces"] });
882
+
883
+ export const listInvites = workspaces
884
+ \t.get("/current/invites")
885
+ \t.meta({ auth: "required" })
886
+ \t.errors({
887
+ \t\tForbidden: errors.Forbidden,
888
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
889
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
890
+ \t})
891
+ \t.responses({
892
+ \t\t200: listInvitesOutputSchema,
893
+ \t})
894
+ \t.openapi({ summary: "List workspace invites", tags: ["workspaces"] });
895
+
896
+ export const createInvite = idempotentWorkspaces
897
+ \t.post("/current/invites")
898
+ \t.body(createInviteInputSchema)
899
+ \t.meta({ auth: "required", idempotency: idempotencyMeta })
900
+ \t.errors({
901
+ \t\tForbidden: errors.Forbidden,
902
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
903
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
904
+ \t\t...idempotencyErrors,
905
+ \t})
906
+ \t.responses({
907
+ \t\t201: workspaceInviteSchema,
908
+ \t})
909
+ \t.openapi({ summary: "Invite someone to the workspace", tags: ["workspaces"] });
910
+
911
+ export const revokeInvite = workspaces
912
+ \t.delete("/current/invites/:inviteId")
913
+ \t.pathParams(inviteIdParamsSchema)
914
+ \t.meta({ auth: "required" })
915
+ \t.errors({
916
+ \t\tForbidden: errors.Forbidden,
917
+ \t\tInviteAlreadyAccepted: errors.InviteAlreadyAccepted,
918
+ \t\tInviteNotFound: errors.InviteNotFound,
919
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
920
+ \t\tWorkspaceRequired: errors.WorkspaceRequired,
921
+ \t})
922
+ \t.responses({
923
+ \t\t200: workspaceInviteSchema,
924
+ \t})
925
+ \t.openapi({ summary: "Revoke a workspace invite", tags: ["workspaces"] });
926
+
927
+ export const acceptInvite = workspaces
928
+ \t.post("/invites/:token/accept")
929
+ \t.pathParams(inviteTokenParamsSchema)
930
+ \t.meta({ auth: "required" })
931
+ \t.errors({
932
+ \t\tInviteAlreadyAccepted: errors.InviteAlreadyAccepted,
933
+ \t\tInviteExpired: errors.InviteExpired,
934
+ \t\tInviteNotFound: errors.InviteNotFound,
935
+ \t\tWorkspaceNotFound: errors.WorkspaceNotFound,
936
+ \t})
937
+ \t.responses({
938
+ \t\t200: acceptInviteOutputSchema,
939
+ \t})
940
+ \t.openapi({ summary: "Accept a workspace invite", tags: ["workspaces"] });
941
+ `;
942
+ }
943
+ export function tenancyPolicyFile() {
944
+ return `import {
945
+ \ttype ActivityActor,
946
+ \ttype ActivityTenant,
947
+ \tdefinePolicy,
948
+ \tdeny,
949
+ } from "@beignet/core/ports";
950
+ import type { Workspace, WorkspaceRole } from "@/features/workspaces/schemas";
951
+
952
+ /**
953
+ * Active workspace membership carried on the request context.
954
+ */
955
+ export type WorkspaceMembershipContext = {
956
+ \tworkspaceId: string;
957
+ \trole: WorkspaceRole;
958
+ };
959
+
960
+ /**
961
+ * Authorization context for workspace policies. \`tenant.id\` is the active
962
+ * workspace id; \`membership\` is the actor's membership in that workspace, or
963
+ * null when the actor has none.
964
+ */
965
+ export type AuthorizationContext = {
966
+ \tactor: ActivityActor;
967
+ \ttenant?: ActivityTenant;
968
+ \tmembership?: WorkspaceMembershipContext | null;
969
+ };
970
+
971
+ export function membershipFor(
972
+ \tctx: AuthorizationContext,
973
+ \tworkspaceId: string,
974
+ ): WorkspaceMembershipContext | null {
975
+ \tif (!ctx.membership) return null;
976
+ \treturn ctx.membership.workspaceId === workspaceId ? ctx.membership : null;
977
+ }
978
+
979
+ export function isWorkspaceMember(
980
+ \tctx: AuthorizationContext,
981
+ \tworkspaceId: string,
982
+ ): boolean {
983
+ \treturn membershipFor(ctx, workspaceId) !== null;
984
+ }
985
+
986
+ export function isWorkspaceAdmin(
987
+ \tctx: AuthorizationContext,
988
+ \tworkspaceId: string,
989
+ ): boolean {
990
+ \treturn membershipFor(ctx, workspaceId)?.role === "admin";
991
+ }
992
+
993
+ function denyNotMember(workspaceId: string) {
994
+ \treturn deny({
995
+ \t\treason: "You are not a member of this workspace.",
996
+ \t\tcode: "NOT_A_WORKSPACE_MEMBER",
997
+ \t\tdetails: { workspaceId },
998
+ \t});
999
+ }
1000
+
1001
+ function denyNotAdmin(workspaceId: string) {
1002
+ \treturn deny({
1003
+ \t\treason: "Only workspace admins can do this.",
1004
+ \t\tcode: "WORKSPACE_ADMIN_REQUIRED",
1005
+ \t\tdetails: { workspaceId },
1006
+ \t});
1007
+ }
1008
+
1009
+ export const workspacePolicy = definePolicy({
1010
+ \t"workspaces.update": (ctx: AuthorizationContext, workspace: Workspace) => {
1011
+ \t\tif (!isWorkspaceMember(ctx, workspace.id))
1012
+ \t\t\treturn denyNotMember(workspace.id);
1013
+ \t\treturn isWorkspaceAdmin(ctx, workspace.id) || denyNotAdmin(workspace.id);
1014
+ \t},
1015
+ \t"workspaces.members.view": (
1016
+ \t\tctx: AuthorizationContext,
1017
+ \t\tworkspace: Workspace,
1018
+ \t) => {
1019
+ \t\treturn isWorkspaceMember(ctx, workspace.id) || denyNotMember(workspace.id);
1020
+ \t},
1021
+ \t"workspaces.members.manage": (
1022
+ \t\tctx: AuthorizationContext,
1023
+ \t\tworkspace: Workspace,
1024
+ \t) => {
1025
+ \t\tif (!isWorkspaceMember(ctx, workspace.id))
1026
+ \t\t\treturn denyNotMember(workspace.id);
1027
+ \t\treturn isWorkspaceAdmin(ctx, workspace.id) || denyNotAdmin(workspace.id);
1028
+ \t},
1029
+ });
1030
+ `;
1031
+ }
1032
+ export function tenancyPortsFile() {
1033
+ return `import type {
1034
+ \tInviteStatus,
1035
+ \tWorkspace,
1036
+ \tWorkspaceInvite,
1037
+ \tWorkspaceMember,
1038
+ \tWorkspaceMemberWithUser,
1039
+ \tWorkspaceRole,
1040
+ } from "@/features/workspaces/schemas";
1041
+
1042
+ export type {
1043
+ \tInviteStatus,
1044
+ \tWorkspace,
1045
+ \tWorkspaceInvite,
1046
+ \tWorkspaceMember,
1047
+ \tWorkspaceMemberWithUser,
1048
+ \tWorkspaceRole,
1049
+ };
1050
+
1051
+ export type WorkspaceMembershipLookup = {
1052
+ \tworkspaceId: string;
1053
+ \tuserId: string;
1054
+ };
1055
+
1056
+ export interface WorkspaceRepository {
1057
+ \tfindById(id: string): Promise<Workspace | null>;
1058
+ \tfindBySlug(slug: string): Promise<Workspace | null>;
1059
+ \tfindManyByIds(ids: string[]): Promise<Workspace[]>;
1060
+ \tcreate(data: { name: string; slug: string }): Promise<Workspace>;
1061
+ \tupdate(
1062
+ \t\tid: string,
1063
+ \t\tdata: { name?: string; slug?: string },
1064
+ \t): Promise<Workspace | null>;
1065
+ }
1066
+
1067
+ export interface WorkspaceMemberRepository {
1068
+ \tfindMembership(
1069
+ \t\tlookup: WorkspaceMembershipLookup,
1070
+ \t): Promise<WorkspaceMember | null>;
1071
+ \tlistByUser(userId: string): Promise<WorkspaceMember[]>;
1072
+ \tlistByWorkspace(workspaceId: string): Promise<WorkspaceMemberWithUser[]>;
1073
+ \tcreate(data: {
1074
+ \t\tworkspaceId: string;
1075
+ \t\tuserId: string;
1076
+ \t\trole: WorkspaceRole;
1077
+ \t}): Promise<WorkspaceMember>;
1078
+ \tupdateRole(
1079
+ \t\tlookup: WorkspaceMembershipLookup,
1080
+ \t\trole: WorkspaceRole,
1081
+ \t): Promise<WorkspaceMember | null>;
1082
+ \tremove(lookup: WorkspaceMembershipLookup): Promise<boolean>;
1083
+ \tcountAdmins(workspaceId: string): Promise<number>;
1084
+ }
1085
+
1086
+ export interface WorkspaceInviteRepository {
1087
+ \tfindByToken(token: string): Promise<WorkspaceInvite | null>;
1088
+ \tlistByWorkspace(
1089
+ \t\tworkspaceId: string,
1090
+ \t\tstatus?: InviteStatus,
1091
+ \t): Promise<WorkspaceInvite[]>;
1092
+ \tcreate(data: {
1093
+ \t\tworkspaceId: string;
1094
+ \t\temail: string;
1095
+ \t\trole: WorkspaceRole;
1096
+ \t\ttoken: string;
1097
+ \t\tinvitedByUserId: string;
1098
+ \t\texpiresAt: string;
1099
+ \t}): Promise<WorkspaceInvite>;
1100
+ \tupdateStatus(
1101
+ \t\tid: string,
1102
+ \t\tstatus: InviteStatus,
1103
+ \t\tacceptedAt?: string,
1104
+ \t): Promise<WorkspaceInvite | null>;
1105
+ }
1106
+ `;
1107
+ }
1108
+ export function currentWorkspaceHelperFile(config) {
1109
+ return `import { requireTenantId } from "@beignet/core/ports";
1110
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1111
+ import { appError } from "@/features/shared/errors";
1112
+ import type { Workspace } from "@/features/workspaces/ports";
1113
+
1114
+ /**
1115
+ * Return the active workspace id from the request tenant or throw the
1116
+ * app-owned \`WorkspaceRequired\` error.
1117
+ */
1118
+ export function requireWorkspaceId(ctx: AppContext): string {
1119
+ \treturn requireTenantId(ctx, {
1120
+ \t\terror: () => appError("WorkspaceRequired"),
1121
+ \t});
1122
+ }
1123
+
1124
+ /**
1125
+ * Load the active workspace or throw. Shared by every use case that operates
1126
+ * on "the current workspace" resolved from the request tenant.
1127
+ */
1128
+ export async function requireCurrentWorkspace(
1129
+ \tctx: AppContext,
1130
+ ): Promise<Workspace> {
1131
+ \tconst workspaceId = requireWorkspaceId(ctx);
1132
+ \tconst workspace = await ctx.ports.workspaces.findById(workspaceId);
1133
+
1134
+ \tif (!workspace) {
1135
+ \t\tthrow appError("WorkspaceNotFound", { details: { workspaceId } });
1136
+ \t}
1137
+
1138
+ \treturn workspace;
1139
+ }
1140
+ `;
1141
+ }
1142
+ export function createWorkspaceUseCaseFile() {
1143
+ return `import { requireUserId } from "@beignet/core/ports";
1144
+ import { appError } from "@/features/shared/errors";
1145
+ import {
1146
+ \tcreateWorkspaceInputSchema,
1147
+ \tworkspaceSchema,
1148
+ } from "@/features/workspaces/schemas";
1149
+ import { useCase } from "@/lib/use-case";
1150
+
1151
+ export const createWorkspaceUseCase = useCase
1152
+ \t.command("workspaces.create")
1153
+ \t.input(createWorkspaceInputSchema)
1154
+ \t.output(workspaceSchema)
1155
+ \t.run(async ({ ctx, input }) => {
1156
+ \t\tconst userId = requireUserId(ctx);
1157
+
1158
+ \t\tconst existing = await ctx.ports.workspaces.findBySlug(input.slug);
1159
+ \t\tif (existing) {
1160
+ \t\t\tthrow appError("WorkspaceSlugTaken", { details: { slug: input.slug } });
1161
+ \t\t}
1162
+
1163
+ \t\tconst workspace = await ctx.ports.uow.transaction(async (tx) => {
1164
+ \t\t\tconst created = await tx.workspaces.create({
1165
+ \t\t\t\tname: input.name,
1166
+ \t\t\t\tslug: input.slug,
1167
+ \t\t\t});
1168
+
1169
+ \t\t\tawait tx.members.create({
1170
+ \t\t\t\tworkspaceId: created.id,
1171
+ \t\t\t\tuserId,
1172
+ \t\t\t\trole: "admin",
1173
+ \t\t\t});
1174
+
1175
+ \t\t\treturn created;
1176
+ \t\t});
1177
+
1178
+ \t\tctx.ports.logger.info("Workspace created", {
1179
+ \t\t\tworkspaceId: workspace.id,
1180
+ \t\t\tslug: workspace.slug,
1181
+ \t\t});
1182
+
1183
+ \t\treturn workspace;
1184
+ \t});
1185
+ `;
1186
+ }
1187
+ export function getCurrentWorkspaceUseCaseFile() {
1188
+ return `import { requireUserId } from "@beignet/core/ports";
1189
+ import { z } from "zod";
1190
+ import { currentWorkspaceOutputSchema } from "@/features/workspaces/schemas";
1191
+ import { useCase } from "@/lib/use-case";
1192
+
1193
+ /**
1194
+ * Active workspace and membership for the current request. A user with zero
1195
+ * memberships gets a null workspace so the UI can route to workspace
1196
+ * creation.
1197
+ */
1198
+ export const getCurrentWorkspaceUseCase = useCase
1199
+ \t.query("workspaces.getCurrent")
1200
+ \t.input(z.object({}))
1201
+ \t.output(currentWorkspaceOutputSchema)
1202
+ \t.run(async ({ ctx }) => {
1203
+ \t\trequireUserId(ctx);
1204
+
1205
+ \t\tconst workspaceId = ctx.tenant?.id;
1206
+ \t\tif (!workspaceId) {
1207
+ \t\t\treturn { workspace: null, membership: null };
1208
+ \t\t}
1209
+
1210
+ \t\tconst workspace = await ctx.ports.workspaces.findById(workspaceId);
1211
+ \t\tif (!workspace) {
1212
+ \t\t\treturn { workspace: null, membership: null };
1213
+ \t\t}
1214
+
1215
+ \t\treturn { workspace, membership: ctx.membership ?? null };
1216
+ \t});
1217
+ `;
1218
+ }
1219
+ export function listMyWorkspacesUseCaseFile() {
1220
+ return `import { requireUserId } from "@beignet/core/ports";
1221
+ import { z } from "zod";
1222
+ import { listMyWorkspacesOutputSchema } from "@/features/workspaces/schemas";
1223
+ import { useCase } from "@/lib/use-case";
1224
+
1225
+ export const listMyWorkspacesUseCase = useCase
1226
+ \t.query("workspaces.listMine")
1227
+ \t.input(z.object({}))
1228
+ \t.output(listMyWorkspacesOutputSchema)
1229
+ \t.run(async ({ ctx }) => {
1230
+ \t\tconst userId = requireUserId(ctx);
1231
+ \t\tconst memberships = await ctx.ports.members.listByUser(userId);
1232
+
1233
+ \t\tif (memberships.length === 0) {
1234
+ \t\t\treturn { items: [] };
1235
+ \t\t}
1236
+
1237
+ \t\tconst workspaces = await ctx.ports.workspaces.findManyByIds(
1238
+ \t\t\tmemberships.map((membership) => membership.workspaceId),
1239
+ \t\t);
1240
+ \t\tconst workspacesById = new Map(
1241
+ \t\t\tworkspaces.map((workspace) => [workspace.id, workspace]),
1242
+ \t\t);
1243
+
1244
+ \t\treturn {
1245
+ \t\t\titems: memberships.flatMap((membership) => {
1246
+ \t\t\t\tconst workspace = workspacesById.get(membership.workspaceId);
1247
+ \t\t\t\treturn workspace ? [{ ...workspace, role: membership.role }] : [];
1248
+ \t\t\t}),
1249
+ \t\t};
1250
+ \t});
1251
+ `;
1252
+ }
1253
+ export function switchWorkspaceUseCaseFile() {
1254
+ return `import { requireUserId } from "@beignet/core/ports";
1255
+ import { appError } from "@/features/shared/errors";
1256
+ import {
1257
+ \tswitchWorkspaceInputSchema,
1258
+ \tswitchWorkspaceOutputSchema,
1259
+ } from "@/features/workspaces/schemas";
1260
+ import { useCase } from "@/lib/use-case";
1261
+
1262
+ /**
1263
+ * Validates that the actor may switch to the requested workspace. The route
1264
+ * owns the response and sets the \`beignet-workspace\` cookie from this result.
1265
+ */
1266
+ export const switchWorkspaceUseCase = useCase
1267
+ \t.command("workspaces.switch")
1268
+ \t.input(switchWorkspaceInputSchema)
1269
+ \t.output(switchWorkspaceOutputSchema)
1270
+ \t.run(async ({ ctx, input }) => {
1271
+ \t\tconst userId = requireUserId(ctx);
1272
+ \t\tconst membership = await ctx.ports.members.findMembership({
1273
+ \t\t\tworkspaceId: input.workspaceId,
1274
+ \t\t\tuserId,
1275
+ \t\t});
1276
+
1277
+ \t\tif (!membership) {
1278
+ \t\t\tthrow appError("NotAWorkspaceMember", {
1279
+ \t\t\t\tdetails: { workspaceId: input.workspaceId },
1280
+ \t\t\t});
1281
+ \t\t}
1282
+
1283
+ \t\treturn { workspaceId: input.workspaceId };
1284
+ \t});
1285
+ `;
1286
+ }
1287
+ export function updateWorkspaceUseCaseFile() {
1288
+ return `import { requireUserId } from "@beignet/core/ports";
1289
+ import { appError } from "@/features/shared/errors";
1290
+ import {
1291
+ \tupdateWorkspaceInputSchema,
1292
+ \tworkspaceSchema,
1293
+ } from "@/features/workspaces/schemas";
1294
+ import { useCase } from "@/lib/use-case";
1295
+ import { requireCurrentWorkspace } from "./current-workspace";
1296
+
1297
+ export const updateWorkspaceUseCase = useCase
1298
+ \t.command("workspaces.update")
1299
+ \t.input(updateWorkspaceInputSchema)
1300
+ \t.output(workspaceSchema)
1301
+ \t.run(async ({ ctx, input }) => {
1302
+ \t\trequireUserId(ctx);
1303
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1304
+ \t\tawait ctx.gate.authorize("workspaces.update", workspace);
1305
+
1306
+ \t\tif (input.slug && input.slug !== workspace.slug) {
1307
+ \t\t\tconst taken = await ctx.ports.workspaces.findBySlug(input.slug);
1308
+ \t\t\tif (taken) {
1309
+ \t\t\t\tthrow appError("WorkspaceSlugTaken", { details: { slug: input.slug } });
1310
+ \t\t\t}
1311
+ \t\t}
1312
+
1313
+ \t\tconst updated = await ctx.ports.workspaces.update(workspace.id, {
1314
+ \t\t\t...(input.name !== undefined ? { name: input.name } : {}),
1315
+ \t\t\t...(input.slug !== undefined ? { slug: input.slug } : {}),
1316
+ \t\t});
1317
+
1318
+ \t\tif (!updated) {
1319
+ \t\t\tthrow appError("WorkspaceNotFound", {
1320
+ \t\t\t\tdetails: { workspaceId: workspace.id },
1321
+ \t\t\t});
1322
+ \t\t}
1323
+
1324
+ \t\treturn updated;
1325
+ \t});
1326
+ `;
1327
+ }
1328
+ export function listMembersUseCaseFile() {
1329
+ return `import { requireUserId } from "@beignet/core/ports";
1330
+ import { z } from "zod";
1331
+ import { listMembersOutputSchema } from "@/features/workspaces/schemas";
1332
+ import { useCase } from "@/lib/use-case";
1333
+ import { requireCurrentWorkspace } from "./current-workspace";
1334
+
1335
+ export const listMembersUseCase = useCase
1336
+ \t.query("workspaces.members.list")
1337
+ \t.input(z.object({}))
1338
+ \t.output(listMembersOutputSchema)
1339
+ \t.run(async ({ ctx }) => {
1340
+ \t\trequireUserId(ctx);
1341
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1342
+ \t\tawait ctx.gate.authorize("workspaces.members.view", workspace);
1343
+
1344
+ \t\treturn {
1345
+ \t\t\titems: await ctx.ports.members.listByWorkspace(workspace.id),
1346
+ \t\t};
1347
+ \t});
1348
+ `;
1349
+ }
1350
+ export function updateMemberRoleUseCaseFile() {
1351
+ return `import { requireUserId } from "@beignet/core/ports";
1352
+ import { z } from "zod";
1353
+ import { appError } from "@/features/shared/errors";
1354
+ import {
1355
+ \tupdateMemberRoleInputSchema,
1356
+ \tworkspaceMemberSchema,
1357
+ } from "@/features/workspaces/schemas";
1358
+ import { useCase } from "@/lib/use-case";
1359
+ import { requireCurrentWorkspace } from "./current-workspace";
1360
+
1361
+ const inputSchema = updateMemberRoleInputSchema.extend({
1362
+ \tuserId: z.string().min(1),
1363
+ });
1364
+
1365
+ export const updateMemberRoleUseCase = useCase
1366
+ \t.command("workspaces.members.updateRole")
1367
+ \t.input(inputSchema)
1368
+ \t.output(workspaceMemberSchema)
1369
+ \t.run(async ({ ctx, input }) => {
1370
+ \t\trequireUserId(ctx);
1371
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1372
+ \t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
1373
+
1374
+ \t\tconst lookup = { workspaceId: workspace.id, userId: input.userId };
1375
+ \t\tconst target = await ctx.ports.members.findMembership(lookup);
1376
+ \t\tif (!target) {
1377
+ \t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
1378
+ \t\t}
1379
+
1380
+ \t\tif (target.role === "admin" && input.role !== "admin") {
1381
+ \t\t\tconst adminCount = await ctx.ports.members.countAdmins(workspace.id);
1382
+ \t\t\tif (adminCount <= 1) {
1383
+ \t\t\t\tthrow appError("LastAdminRemoval", { details: lookup });
1384
+ \t\t\t}
1385
+ \t\t}
1386
+
1387
+ \t\tconst updated = await ctx.ports.members.updateRole(lookup, input.role);
1388
+ \t\tif (!updated) {
1389
+ \t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
1390
+ \t\t}
1391
+
1392
+ \t\treturn updated;
1393
+ \t});
1394
+ `;
1395
+ }
1396
+ export function removeMemberUseCaseFile() {
1397
+ return `import { requireUserId } from "@beignet/core/ports";
1398
+ import { z } from "zod";
1399
+ import { appError } from "@/features/shared/errors";
1400
+ import { memberUserIdParamsSchema } from "@/features/workspaces/schemas";
1401
+ import { useCase } from "@/lib/use-case";
1402
+ import { requireCurrentWorkspace } from "./current-workspace";
1403
+
1404
+ export const removeMemberUseCase = useCase
1405
+ \t.command("workspaces.members.remove")
1406
+ \t.input(memberUserIdParamsSchema)
1407
+ \t.output(z.void())
1408
+ \t.run(async ({ ctx, input }) => {
1409
+ \t\trequireUserId(ctx);
1410
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1411
+ \t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
1412
+
1413
+ \t\tconst lookup = { workspaceId: workspace.id, userId: input.userId };
1414
+ \t\tconst target = await ctx.ports.members.findMembership(lookup);
1415
+ \t\tif (!target) {
1416
+ \t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
1417
+ \t\t}
1418
+
1419
+ \t\tif (target.role === "admin") {
1420
+ \t\t\tconst adminCount = await ctx.ports.members.countAdmins(workspace.id);
1421
+ \t\t\tif (adminCount <= 1) {
1422
+ \t\t\t\tthrow appError("LastAdminRemoval", { details: lookup });
1423
+ \t\t\t}
1424
+ \t\t}
1425
+
1426
+ \t\tawait ctx.ports.uow.transaction(async (tx) => {
1427
+ \t\t\tconst removed = await tx.members.remove(lookup);
1428
+ \t\t\tif (!removed) {
1429
+ \t\t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
1430
+ \t\t\t}
1431
+ \t\t});
1432
+ \t});
1433
+ `;
1434
+ }
1435
+ export function listInvitesUseCaseFile() {
1436
+ return `import { requireUserId } from "@beignet/core/ports";
1437
+ import { z } from "zod";
1438
+ import { listInvitesOutputSchema } from "@/features/workspaces/schemas";
1439
+ import { useCase } from "@/lib/use-case";
1440
+ import { requireCurrentWorkspace } from "./current-workspace";
1441
+
1442
+ export const listInvitesUseCase = useCase
1443
+ \t.query("workspaces.invites.list")
1444
+ \t.input(z.object({}))
1445
+ \t.output(listInvitesOutputSchema)
1446
+ \t.run(async ({ ctx }) => {
1447
+ \t\trequireUserId(ctx);
1448
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1449
+ \t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
1450
+
1451
+ \t\treturn {
1452
+ \t\t\titems: await ctx.ports.invites.listByWorkspace(workspace.id),
1453
+ \t\t};
1454
+ \t});
1455
+ `;
1456
+ }
1457
+ export function createInviteUseCaseFile() {
1458
+ return `import { requireUser } from "@beignet/core/ports";
1459
+ import { WorkspaceInviteNotification } from "@/features/workspaces/notifications";
1460
+ import {
1461
+ \tcreateInviteInputSchema,
1462
+ \tworkspaceInviteSchema,
1463
+ } from "@/features/workspaces/schemas";
1464
+ import { useCase } from "@/lib/use-case";
1465
+ import { requireCurrentWorkspace } from "./current-workspace";
1466
+
1467
+ const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
1468
+
1469
+ export const createInviteUseCase = useCase
1470
+ \t.command("workspaces.invites.create")
1471
+ \t.input(createInviteInputSchema)
1472
+ \t.output(workspaceInviteSchema)
1473
+ \t.run(async ({ ctx, input }) => {
1474
+ \t\tconst user = requireUser(ctx);
1475
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1476
+ \t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
1477
+
1478
+ \t\tconst token = crypto.randomUUID();
1479
+ \t\tconst expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
1480
+
1481
+ \t\tconst invite = await ctx.ports.invites.create({
1482
+ \t\t\tworkspaceId: workspace.id,
1483
+ \t\t\temail: input.email,
1484
+ \t\t\trole: input.role,
1485
+ \t\t\ttoken,
1486
+ \t\t\tinvitedByUserId: user.id,
1487
+ \t\t\texpiresAt,
1488
+ \t\t});
1489
+
1490
+ \t\tawait ctx.ports.notifications.send(
1491
+ \t\t\tWorkspaceInviteNotification,
1492
+ \t\t\t{
1493
+ \t\t\t\tinviteId: invite.id,
1494
+ \t\t\t\tworkspaceId: workspace.id,
1495
+ \t\t\t\tworkspaceName: workspace.name,
1496
+ \t\t\t\temail: invite.email,
1497
+ \t\t\t\trole: invite.role,
1498
+ \t\t\t\ttoken: invite.token,
1499
+ \t\t\t\tinvitedByName: user.name ?? undefined,
1500
+ \t\t\t},
1501
+ \t\t\t{
1502
+ \t\t\t\trequestId: ctx.requestId,
1503
+ \t\t\t\ttraceId: ctx.traceId,
1504
+ \t\t\t},
1505
+ \t\t);
1506
+
1507
+ \t\treturn invite;
1508
+ \t});
1509
+ `;
1510
+ }
1511
+ export function revokeInviteUseCaseFile() {
1512
+ return `import { requireUserId } from "@beignet/core/ports";
1513
+ import { appError } from "@/features/shared/errors";
1514
+ import {
1515
+ \tinviteIdParamsSchema,
1516
+ \tworkspaceInviteSchema,
1517
+ } from "@/features/workspaces/schemas";
1518
+ import { useCase } from "@/lib/use-case";
1519
+ import { requireCurrentWorkspace } from "./current-workspace";
1520
+
1521
+ export const revokeInviteUseCase = useCase
1522
+ \t.command("workspaces.invites.revoke")
1523
+ \t.input(inviteIdParamsSchema)
1524
+ \t.output(workspaceInviteSchema)
1525
+ \t.run(async ({ ctx, input }) => {
1526
+ \t\trequireUserId(ctx);
1527
+ \t\tconst workspace = await requireCurrentWorkspace(ctx);
1528
+ \t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
1529
+
1530
+ \t\tconst invites = await ctx.ports.invites.listByWorkspace(workspace.id);
1531
+ \t\tconst invite = invites.find((candidate) => candidate.id === input.inviteId);
1532
+ \t\tif (!invite) {
1533
+ \t\t\tthrow appError("InviteNotFound", {
1534
+ \t\t\t\tdetails: { inviteId: input.inviteId },
1535
+ \t\t\t});
1536
+ \t\t}
1537
+ \t\tif (invite.status === "accepted") {
1538
+ \t\t\tthrow appError("InviteAlreadyAccepted", {
1539
+ \t\t\t\tdetails: { inviteId: invite.id },
1540
+ \t\t\t});
1541
+ \t\t}
1542
+
1543
+ \t\tconst revoked = await ctx.ports.invites.updateStatus(invite.id, "revoked");
1544
+ \t\tif (!revoked) {
1545
+ \t\t\tthrow appError("InviteNotFound", { details: { inviteId: invite.id } });
1546
+ \t\t}
1547
+
1548
+ \t\treturn revoked;
1549
+ \t});
1550
+ `;
1551
+ }
1552
+ export function acceptInviteUseCaseFile() {
1553
+ return `import { requireUser } from "@beignet/core/ports";
1554
+ import { appError } from "@/features/shared/errors";
1555
+ import {
1556
+ \tacceptInviteOutputSchema,
1557
+ \tinviteTokenParamsSchema,
1558
+ } from "@/features/workspaces/schemas";
1559
+ import { useCase } from "@/lib/use-case";
1560
+
1561
+ /**
1562
+ * Accept a workspace invite by token. The token is the capability: any
1563
+ * authenticated user holding a pending, unexpired token may join.
1564
+ */
1565
+ export const acceptInviteUseCase = useCase
1566
+ \t.command("workspaces.invites.accept")
1567
+ \t.input(inviteTokenParamsSchema)
1568
+ \t.output(acceptInviteOutputSchema)
1569
+ \t.run(async ({ ctx, input }) => {
1570
+ \t\tconst user = requireUser(ctx);
1571
+
1572
+ \t\tconst invite = await ctx.ports.invites.findByToken(input.token);
1573
+ \t\tif (!invite) {
1574
+ \t\t\tthrow appError("InviteNotFound");
1575
+ \t\t}
1576
+ \t\tif (invite.status === "accepted") {
1577
+ \t\t\tthrow appError("InviteAlreadyAccepted", {
1578
+ \t\t\t\tdetails: { inviteId: invite.id },
1579
+ \t\t\t});
1580
+ \t\t}
1581
+ \t\tif (invite.status !== "pending") {
1582
+ \t\t\tthrow appError("InviteNotFound", { details: { inviteId: invite.id } });
1583
+ \t\t}
1584
+
1585
+ \t\tconst now = new Date();
1586
+ \t\tif (Date.parse(invite.expiresAt) <= now.getTime()) {
1587
+ \t\t\tawait ctx.ports.invites.updateStatus(invite.id, "expired");
1588
+ \t\t\tthrow appError("InviteExpired", { details: { inviteId: invite.id } });
1589
+ \t\t}
1590
+
1591
+ \t\tconst workspace = await ctx.ports.workspaces.findById(invite.workspaceId);
1592
+ \t\tif (!workspace) {
1593
+ \t\t\tthrow appError("WorkspaceNotFound", {
1594
+ \t\t\t\tdetails: { workspaceId: invite.workspaceId },
1595
+ \t\t\t});
1596
+ \t\t}
1597
+
1598
+ \t\tconst existingMembership = await ctx.ports.members.findMembership({
1599
+ \t\t\tworkspaceId: workspace.id,
1600
+ \t\t\tuserId: user.id,
1601
+ \t\t});
1602
+
1603
+ \t\tconst membership = await ctx.ports.uow.transaction(async (tx) => {
1604
+ \t\t\tconst member =
1605
+ \t\t\t\texistingMembership ??
1606
+ \t\t\t\t(await tx.members.create({
1607
+ \t\t\t\t\tworkspaceId: workspace.id,
1608
+ \t\t\t\t\tuserId: user.id,
1609
+ \t\t\t\t\trole: invite.role,
1610
+ \t\t\t\t}));
1611
+
1612
+ \t\t\tconst accepted = await tx.invites.updateStatus(
1613
+ \t\t\t\tinvite.id,
1614
+ \t\t\t\t"accepted",
1615
+ \t\t\t\tnow.toISOString(),
1616
+ \t\t\t);
1617
+ \t\t\tif (!accepted) {
1618
+ \t\t\t\tthrow appError("InviteNotFound", { details: { inviteId: invite.id } });
1619
+ \t\t\t}
1620
+
1621
+ \t\t\treturn member;
1622
+ \t\t});
1623
+
1624
+ \t\treturn { workspace, membership };
1625
+ \t});
1626
+ `;
1627
+ }
1628
+ export function tenancyUseCasesIndexFile() {
1629
+ return `export { acceptInviteUseCase } from "./accept-invite";
1630
+ export { createInviteUseCase } from "./create-invite";
1631
+ export { createWorkspaceUseCase } from "./create-workspace";
1632
+ export {
1633
+ \trequireCurrentWorkspace,
1634
+ \trequireWorkspaceId,
1635
+ } from "./current-workspace";
1636
+ export { getCurrentWorkspaceUseCase } from "./get-current-workspace";
1637
+ export { listInvitesUseCase } from "./list-invites";
1638
+ export { listMembersUseCase } from "./list-members";
1639
+ export { listMyWorkspacesUseCase } from "./list-my-workspaces";
1640
+ export { removeMemberUseCase } from "./remove-member";
1641
+ export { revokeInviteUseCase } from "./revoke-invite";
1642
+ export { switchWorkspaceUseCase } from "./switch-workspace";
1643
+ export { updateMemberRoleUseCase } from "./update-member-role";
1644
+ export { updateWorkspaceUseCase } from "./update-workspace";
1645
+ `;
1646
+ }
1647
+ export function tenancyNotificationsFile() {
1648
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
1649
+ import { z } from "zod";
1650
+ import { env } from "@/lib/env";
1651
+ import { defineNotification } from "@/lib/notifications";
1652
+
1653
+ const invitePayloadSchema = z.object({
1654
+ \tinviteId: z.string().min(1),
1655
+ \tworkspaceId: z.string().min(1),
1656
+ \tworkspaceName: z.string().min(1),
1657
+ \temail: z.string().email(),
1658
+ \trole: z.enum(["admin", "member"]),
1659
+ \ttoken: z.string().min(1),
1660
+ \tinvitedByName: z.string().optional(),
1661
+ });
1662
+
1663
+ /**
1664
+ * Invitation email sent to the invited address with the accept link.
1665
+ */
1666
+ export const WorkspaceInviteNotification = defineNotification(
1667
+ \t"workspaces.invite",
1668
+ \t{
1669
+ \t\tpayload: invitePayloadSchema,
1670
+ \t\tdescription: "Workspace invitation email with the accept link.",
1671
+ \t\tchannels: {
1672
+ \t\t\temail: defineMailNotificationChannel(({ payload }) => {
1673
+ \t\t\t\tconst acceptUrl = \`\${env.APP_URL}/invites/\${encodeURIComponent(payload.token)}\`;
1674
+ \t\t\t\tconst inviter = payload.invitedByName ?? "A workspace admin";
1675
+
1676
+ \t\t\t\treturn {
1677
+ \t\t\t\t\tto: payload.email,
1678
+ \t\t\t\t\tsubject: \`You're invited to \${payload.workspaceName}\`,
1679
+ \t\t\t\t\ttext: [
1680
+ \t\t\t\t\t\t\`\${inviter} invited you to join \${payload.workspaceName} as a \${payload.role}.\`,
1681
+ \t\t\t\t\t\t"",
1682
+ \t\t\t\t\t\t\`Accept the invite: \${acceptUrl}\`,
1683
+ \t\t\t\t\t].join("\\n"),
1684
+ \t\t\t\t};
1685
+ \t\t\t}),
1686
+ \t\t},
1687
+ \t},
1688
+ );
1689
+ `;
1690
+ }
1691
+ export function tenancyRoutesFile(config) {
1692
+ const routeFilePath = path.join(config.paths.features, "workspaces/routes.ts");
1693
+ return `import { defineRouteGroup } from "@beignet/next";
1694
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
1695
+ import * as workspaceContracts from "@/features/workspaces/contracts";
1696
+ import * as workspaceUseCases from "@/features/workspaces/use-cases";
1697
+ import { WORKSPACE_COOKIE_NAME } from "@/lib/tenant";
1698
+
1699
+ const WORKSPACE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
1700
+
1701
+ function workspaceCookie(workspaceId: string): string {
1702
+ \treturn [
1703
+ \t\t\`\${WORKSPACE_COOKIE_NAME}=\${encodeURIComponent(workspaceId)}\`,
1704
+ \t\t"Path=/",
1705
+ \t\t"SameSite=Lax",
1706
+ \t\t"HttpOnly",
1707
+ \t\t\`Max-Age=\${WORKSPACE_COOKIE_MAX_AGE_SECONDS}\`,
1708
+ \t].join("; ");
1709
+ }
1710
+
1711
+ export const workspaceRoutes = defineRouteGroup<AppContext>()({
1712
+ \tname: "workspaces",
1713
+ \troutes: [
1714
+ \t\t{
1715
+ \t\t\tcontract: workspaceContracts.listMyWorkspaces,
1716
+ \t\t\tuseCase: workspaceUseCases.listMyWorkspacesUseCase,
1717
+ \t\t},
1718
+ \t\t{
1719
+ \t\t\tcontract: workspaceContracts.createWorkspace,
1720
+ \t\t\tuseCase: workspaceUseCases.createWorkspaceUseCase,
1721
+ \t\t},
1722
+ \t\t{
1723
+ \t\t\tcontract: workspaceContracts.getCurrentWorkspace,
1724
+ \t\t\tuseCase: workspaceUseCases.getCurrentWorkspaceUseCase,
1725
+ \t\t},
1726
+ \t\t{
1727
+ \t\t\tcontract: workspaceContracts.updateWorkspace,
1728
+ \t\t\tuseCase: workspaceUseCases.updateWorkspaceUseCase,
1729
+ \t\t},
1730
+ \t\t// Full \`handle\` escape hatch: the route owns the response so it can set
1731
+ \t\t// the active-workspace cookie that \`resolveRequestWorkspace\` reads.
1732
+ \t\t{
1733
+ \t\t\tcontract: workspaceContracts.switchWorkspace,
1734
+ \t\t\thandle: async ({ ctx, body }) => {
1735
+ \t\t\t\tconst result = await workspaceUseCases.switchWorkspaceUseCase.run({
1736
+ \t\t\t\t\tctx,
1737
+ \t\t\t\t\tinput: body,
1738
+ \t\t\t\t});
1739
+
1740
+ \t\t\t\treturn {
1741
+ \t\t\t\t\tstatus: 200,
1742
+ \t\t\t\t\theaders: {
1743
+ \t\t\t\t\t\t"set-cookie": workspaceCookie(result.workspaceId),
1744
+ \t\t\t\t\t},
1745
+ \t\t\t\t\tbody: result,
1746
+ \t\t\t\t};
1747
+ \t\t\t},
1748
+ \t\t},
1749
+ \t\t{
1750
+ \t\t\tcontract: workspaceContracts.listMembers,
1751
+ \t\t\tuseCase: workspaceUseCases.listMembersUseCase,
1752
+ \t\t},
1753
+ \t\t{
1754
+ \t\t\tcontract: workspaceContracts.updateMemberRole,
1755
+ \t\t\tuseCase: workspaceUseCases.updateMemberRoleUseCase,
1756
+ \t\t},
1757
+ \t\t{
1758
+ \t\t\tcontract: workspaceContracts.removeMember,
1759
+ \t\t\tuseCase: workspaceUseCases.removeMemberUseCase,
1760
+ \t\t},
1761
+ \t\t{
1762
+ \t\t\tcontract: workspaceContracts.listInvites,
1763
+ \t\t\tuseCase: workspaceUseCases.listInvitesUseCase,
1764
+ \t\t},
1765
+ \t\t{
1766
+ \t\t\tcontract: workspaceContracts.createInvite,
1767
+ \t\t\tuseCase: workspaceUseCases.createInviteUseCase,
1768
+ \t\t},
1769
+ \t\t{
1770
+ \t\t\tcontract: workspaceContracts.revokeInvite,
1771
+ \t\t\tuseCase: workspaceUseCases.revokeInviteUseCase,
1772
+ \t\t},
1773
+ \t\t{
1774
+ \t\t\tcontract: workspaceContracts.acceptInvite,
1775
+ \t\t\tuseCase: workspaceUseCases.acceptInviteUseCase,
1776
+ \t\t},
1777
+ \t],
1778
+ });
1779
+ `;
1780
+ }
1781
+ export function tenancySeedsFile(config) {
1782
+ return `import { defineSeed, type SeedDef } from "@beignet/core/testing";
1783
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1784
+
1785
+ export const DEMO_WORKSPACE_SLUG = "demo";
1786
+ export const DEMO_ADMIN_EMAIL = "demo-admin@example.com";
1787
+ export const DEMO_ADMIN_PASSWORD = "demo-password-1234";
1788
+
1789
+ /**
1790
+ * Context the workspace seed runs with: the app service context plus the
1791
+ * Better Auth user id for the demo admin. The composing seed script
1792
+ * (server/seed.ts) creates the user through Better Auth and passes its id,
1793
+ * because user ids are not reachable through app ports.
1794
+ */
1795
+ export type WorkspaceSeedContext = AppContext & {
1796
+ \tdemoAdminUserId: string;
1797
+ };
1798
+
1799
+ /**
1800
+ * Demo workspace with the demo admin as its only member. Idempotent: skips
1801
+ * when the demo workspace slug already exists.
1802
+ */
1803
+ export const WorkspaceDemoSeed = defineSeed<
1804
+ \t"workspaces.demo",
1805
+ \tWorkspaceSeedContext
1806
+ >("workspaces.demo", {
1807
+ \tdescription: "Demo workspace with the demo admin as its only member.",
1808
+ \tasync run(ctx) {
1809
+ \t\tif (await ctx.ports.workspaces.findBySlug(DEMO_WORKSPACE_SLUG)) {
1810
+ \t\t\treturn;
1811
+ \t\t}
1812
+
1813
+ \t\tconst workspace = await ctx.ports.workspaces.create({
1814
+ \t\t\tname: "Demo Workspace",
1815
+ \t\t\tslug: DEMO_WORKSPACE_SLUG,
1816
+ \t\t});
1817
+ \t\tawait ctx.ports.members.create({
1818
+ \t\t\tworkspaceId: workspace.id,
1819
+ \t\t\tuserId: ctx.demoAdminUserId,
1820
+ \t\t\trole: "admin",
1821
+ \t\t});
1822
+ \t},
1823
+ });
1824
+
1825
+ export const workspaceSeeds = [
1826
+ \tWorkspaceDemoSeed,
1827
+ ] as const satisfies readonly SeedDef<WorkspaceSeedContext>[];
1828
+ `;
1829
+ }
1830
+ export function tenancyServerSeedFile(config) {
1831
+ const seedFilePath = seedEntrypointPath(config);
1832
+ return `import { createServiceActor } from "@beignet/core/ports";
1833
+ import { runSeeds } from "@beignet/core/testing";
1834
+ import {
1835
+ \tDEMO_ADMIN_EMAIL,
1836
+ \tDEMO_ADMIN_PASSWORD,
1837
+ \ttype WorkspaceSeedContext,
1838
+ \tworkspaceSeeds,
1839
+ } from "@/features/workspaces/seeds";
1840
+ import { auth } from "@/lib/better-auth";
1841
+ import { getServer } from "${relativeModule(seedFilePath, config.paths.server)}";
1842
+
1843
+ /**
1844
+ * Demo dataset composition. The demo admin is created through Better Auth so
1845
+ * it owns password hashing and the account rows; the workspace seed receives
1846
+ * the resolved user id through its seed context. Idempotent: rerunning
1847
+ * against a seeded database changes nothing. Other generators and
1848
+ * \`beignet make seed\` append their feature seed registries to this list;
1849
+ * plain \`AppContext\` seeds run fine with the extended workspace context.
1850
+ */
1851
+ const seeds = [...workspaceSeeds] as const;
1852
+
1853
+ async function ensureDemoAdminUserId(): Promise<string> {
1854
+ \ttry {
1855
+ \t\tconst signedUp = await auth.api.signUpEmail({
1856
+ \t\t\tbody: {
1857
+ \t\t\t\temail: DEMO_ADMIN_EMAIL,
1858
+ \t\t\t\tname: "Demo Admin",
1859
+ \t\t\t\tpassword: DEMO_ADMIN_PASSWORD,
1860
+ \t\t\t},
1861
+ \t\t});
1862
+ \t\treturn signedUp.user.id;
1863
+ \t} catch {
1864
+ \t\t// The demo admin already exists; sign in with the demo credentials to
1865
+ \t\t// resolve its id.
1866
+ \t\tconst signedIn = await auth.api.signInEmail({
1867
+ \t\t\tbody: {
1868
+ \t\t\t\temail: DEMO_ADMIN_EMAIL,
1869
+ \t\t\t\tpassword: DEMO_ADMIN_PASSWORD,
1870
+ \t\t\t},
1871
+ \t\t});
1872
+ \t\treturn signedIn.user.id;
1873
+ \t}
1874
+ }
1875
+
1876
+ const demoAdminUserId = await ensureDemoAdminUserId();
1877
+ const server = await getServer();
1878
+
1879
+ try {
1880
+ \tawait server.runServiceContext(
1881
+ \t\t{ actor: createServiceActor("beignet-seed") },
1882
+ \t\tasync (ctx) => {
1883
+ \t\t\tawait runSeeds<WorkspaceSeedContext>({
1884
+ \t\t\t\tseeds,
1885
+ \t\t\t\tctx: { ...ctx, demoAdminUserId },
1886
+ \t\t\t});
1887
+ \t\t},
1888
+ \t);
1889
+
1890
+ \tconsole.log("Database seeded.");
1891
+ \tconsole.log(
1892
+ \t\t\`Demo admin: \${DEMO_ADMIN_EMAIL} (password "\${DEMO_ADMIN_PASSWORD}")\`,
1893
+ \t);
1894
+ } finally {
1895
+ \tawait server.stop();
1896
+ }
1897
+ `;
1898
+ }
1899
+ export function tenancyContextFactoryFile(config) {
1900
+ return `import { createTenant } from "@beignet/core/ports";
1901
+ import {
1902
+ \tcreateTestAnonymousActor,
1903
+ \tcreateTestUserActor,
1904
+ } from "@beignet/core/ports/testing";
1905
+ import {
1906
+ \tcreateTestContextFactory,
1907
+ \tcreateTestPorts,
1908
+ \ttype TestPortsFixture,
1909
+ } from "@beignet/core/testing";
1910
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1911
+ import type { WorkspaceMembershipContext } from "@/features/workspaces/policy";
1912
+ import type {
1913
+ \tWorkspace,
1914
+ \tWorkspaceInvite,
1915
+ \tWorkspaceInviteRepository,
1916
+ \tWorkspaceMember,
1917
+ \tWorkspaceMemberRepository,
1918
+ \tWorkspaceMemberWithUser,
1919
+ \tWorkspaceRepository,
1920
+ } from "@/features/workspaces/ports";
1921
+ import { appPorts } from "@/infra/app-ports";
1922
+ import type { AppTransactionPorts } from "@/ports";
1923
+
1924
+ export type MemoryWorkspacePorts = {
1925
+ \tworkspaces: WorkspaceRepository;
1926
+ \tmembers: WorkspaceMemberRepository;
1927
+ \tinvites: WorkspaceInviteRepository;
1928
+ };
1929
+
1930
+ /**
1931
+ * In-memory implementations of the three workspace repository ports, shared
1932
+ * by the workspace tests and usable from any test that needs tenancy state.
1933
+ */
1934
+ export function createMemoryWorkspacePorts(): MemoryWorkspacePorts {
1935
+ \tconst workspaces = new Map<string, Workspace>();
1936
+ \tconst members = new Map<string, WorkspaceMember>();
1937
+ \tconst invites = new Map<string, WorkspaceInvite>();
1938
+ \tlet joinSequence = 0;
1939
+
1940
+ \tconst memberKey = (lookup: { workspaceId: string; userId: string }) =>
1941
+ \t\t\`\${lookup.workspaceId}:\${lookup.userId}\`;
1942
+
1943
+ \treturn {
1944
+ \t\tworkspaces: {
1945
+ \t\t\tasync findById(id) {
1946
+ \t\t\t\treturn workspaces.get(id) ?? null;
1947
+ \t\t\t},
1948
+ \t\t\tasync findBySlug(slug) {
1949
+ \t\t\t\treturn (
1950
+ \t\t\t\t\t[...workspaces.values()].find(
1951
+ \t\t\t\t\t\t(workspace) => workspace.slug === slug,
1952
+ \t\t\t\t\t) ?? null
1953
+ \t\t\t\t);
1954
+ \t\t\t},
1955
+ \t\t\tasync findManyByIds(ids) {
1956
+ \t\t\t\treturn ids.flatMap((id) => {
1957
+ \t\t\t\t\tconst workspace = workspaces.get(id);
1958
+ \t\t\t\t\treturn workspace ? [workspace] : [];
1959
+ \t\t\t\t});
1960
+ \t\t\t},
1961
+ \t\t\tasync create(data) {
1962
+ \t\t\t\tconst now = new Date().toISOString();
1963
+ \t\t\t\tconst workspace: Workspace = {
1964
+ \t\t\t\t\tid: crypto.randomUUID(),
1965
+ \t\t\t\t\tname: data.name,
1966
+ \t\t\t\t\tslug: data.slug,
1967
+ \t\t\t\t\tcreatedAt: now,
1968
+ \t\t\t\t\tupdatedAt: now,
1969
+ \t\t\t\t};
1970
+ \t\t\t\tworkspaces.set(workspace.id, workspace);
1971
+ \t\t\t\treturn workspace;
1972
+ \t\t\t},
1973
+ \t\t\tasync update(id, data) {
1974
+ \t\t\t\tconst existing = workspaces.get(id);
1975
+ \t\t\t\tif (!existing) return null;
1976
+ \t\t\t\tconst next: Workspace = {
1977
+ \t\t\t\t\t...existing,
1978
+ \t\t\t\t\t...(data.name !== undefined ? { name: data.name } : {}),
1979
+ \t\t\t\t\t...(data.slug !== undefined ? { slug: data.slug } : {}),
1980
+ \t\t\t\t\tupdatedAt: new Date().toISOString(),
1981
+ \t\t\t\t};
1982
+ \t\t\t\tworkspaces.set(id, next);
1983
+ \t\t\t\treturn next;
1984
+ \t\t\t},
1985
+ \t\t},
1986
+ \t\tmembers: {
1987
+ \t\t\tasync findMembership(lookup) {
1988
+ \t\t\t\treturn members.get(memberKey(lookup)) ?? null;
1989
+ \t\t\t},
1990
+ \t\t\tasync listByUser(userId) {
1991
+ \t\t\t\treturn [...members.values()]
1992
+ \t\t\t\t\t.filter((member) => member.userId === userId)
1993
+ \t\t\t\t\t.sort((left, right) => left.joinedAt.localeCompare(right.joinedAt));
1994
+ \t\t\t},
1995
+ \t\t\tasync listByWorkspace(workspaceId) {
1996
+ \t\t\t\treturn [...members.values()]
1997
+ \t\t\t\t\t.filter((member) => member.workspaceId === workspaceId)
1998
+ \t\t\t\t\t.sort((left, right) => left.joinedAt.localeCompare(right.joinedAt))
1999
+ \t\t\t\t\t.map(
2000
+ \t\t\t\t\t\t(member): WorkspaceMemberWithUser => ({
2001
+ \t\t\t\t\t\t\t...member,
2002
+ \t\t\t\t\t\t\tuser: {
2003
+ \t\t\t\t\t\t\t\tid: member.userId,
2004
+ \t\t\t\t\t\t\t\tname: member.userId,
2005
+ \t\t\t\t\t\t\t\temail: \`\${member.userId}@example.com\`,
2006
+ \t\t\t\t\t\t\t\timage: null,
2007
+ \t\t\t\t\t\t\t},
2008
+ \t\t\t\t\t\t}),
2009
+ \t\t\t\t\t);
2010
+ \t\t\t},
2011
+ \t\t\tasync create(data) {
2012
+ \t\t\t\tjoinSequence += 1;
2013
+ \t\t\t\tconst member: WorkspaceMember = {
2014
+ \t\t\t\t\tid: crypto.randomUUID(),
2015
+ \t\t\t\t\tworkspaceId: data.workspaceId,
2016
+ \t\t\t\t\tuserId: data.userId,
2017
+ \t\t\t\t\trole: data.role,
2018
+ \t\t\t\t\tjoinedAt: new Date(joinSequence).toISOString(),
2019
+ \t\t\t\t};
2020
+ \t\t\t\tmembers.set(memberKey(data), member);
2021
+ \t\t\t\treturn member;
2022
+ \t\t\t},
2023
+ \t\t\tasync updateRole(lookup, role) {
2024
+ \t\t\t\tconst existing = members.get(memberKey(lookup));
2025
+ \t\t\t\tif (!existing) return null;
2026
+ \t\t\t\tconst next = { ...existing, role };
2027
+ \t\t\t\tmembers.set(memberKey(lookup), next);
2028
+ \t\t\t\treturn next;
2029
+ \t\t\t},
2030
+ \t\t\tasync remove(lookup) {
2031
+ \t\t\t\treturn members.delete(memberKey(lookup));
2032
+ \t\t\t},
2033
+ \t\t\tasync countAdmins(workspaceId) {
2034
+ \t\t\t\treturn [...members.values()].filter(
2035
+ \t\t\t\t\t(member) =>
2036
+ \t\t\t\t\t\tmember.workspaceId === workspaceId && member.role === "admin",
2037
+ \t\t\t\t).length;
2038
+ \t\t\t},
2039
+ \t\t},
2040
+ \t\tinvites: {
2041
+ \t\t\tasync findByToken(token) {
2042
+ \t\t\t\treturn (
2043
+ \t\t\t\t\t[...invites.values()].find((invite) => invite.token === token) ??
2044
+ \t\t\t\t\tnull
2045
+ \t\t\t\t);
2046
+ \t\t\t},
2047
+ \t\t\tasync listByWorkspace(workspaceId, status) {
2048
+ \t\t\t\treturn [...invites.values()]
2049
+ \t\t\t\t\t.filter(
2050
+ \t\t\t\t\t\t(invite) =>
2051
+ \t\t\t\t\t\t\tinvite.workspaceId === workspaceId &&
2052
+ \t\t\t\t\t\t\t(!status || invite.status === status),
2053
+ \t\t\t\t\t)
2054
+ \t\t\t\t\t.sort((left, right) =>
2055
+ \t\t\t\t\t\tright.createdAt.localeCompare(left.createdAt),
2056
+ \t\t\t\t\t);
2057
+ \t\t\t},
2058
+ \t\t\tasync create(data) {
2059
+ \t\t\t\tconst invite: WorkspaceInvite = {
2060
+ \t\t\t\t\tid: crypto.randomUUID(),
2061
+ \t\t\t\t\tworkspaceId: data.workspaceId,
2062
+ \t\t\t\t\temail: data.email,
2063
+ \t\t\t\t\trole: data.role,
2064
+ \t\t\t\t\ttoken: data.token,
2065
+ \t\t\t\t\tinvitedByUserId: data.invitedByUserId,
2066
+ \t\t\t\t\tstatus: "pending",
2067
+ \t\t\t\t\texpiresAt: data.expiresAt,
2068
+ \t\t\t\t\tacceptedAt: null,
2069
+ \t\t\t\t\tcreatedAt: new Date().toISOString(),
2070
+ \t\t\t\t};
2071
+ \t\t\t\tinvites.set(invite.id, invite);
2072
+ \t\t\t\treturn invite;
2073
+ \t\t\t},
2074
+ \t\t\tasync updateStatus(id, status, acceptedAt) {
2075
+ \t\t\t\tconst existing = invites.get(id);
2076
+ \t\t\t\tif (!existing) return null;
2077
+ \t\t\t\tconst next: WorkspaceInvite = {
2078
+ \t\t\t\t\t...existing,
2079
+ \t\t\t\t\tstatus,
2080
+ \t\t\t\t\tacceptedAt: acceptedAt ?? null,
2081
+ \t\t\t\t};
2082
+ \t\t\t\tinvites.set(id, next);
2083
+ \t\t\t\treturn next;
2084
+ \t\t\t},
2085
+ \t\t},
2086
+ \t};
2087
+ }
2088
+
2089
+ export type WorkspaceContextOptions = {
2090
+ \t/** Omit to create an anonymous context. */
2091
+ \tuserId?: string;
2092
+ \tuserName?: string;
2093
+ \tuserEmail?: string;
2094
+ \t/** Active workspace (ctx.tenant.id). */
2095
+ \tworkspaceId?: string;
2096
+ \t/** Membership in the active workspace; defaults to null. */
2097
+ \tmembership?: WorkspaceMembershipContext | null;
2098
+ };
2099
+
2100
+ export type WorkspaceTestFixture = TestPortsFixture<
2101
+ \tAppContext["ports"],
2102
+ \tAppTransactionPorts
2103
+ > & {
2104
+ \trepositories: MemoryWorkspacePorts;
2105
+ \tcreateContext: (options?: WorkspaceContextOptions) => AppContext;
2106
+ };
2107
+
2108
+ /**
2109
+ * Use-case fixture: memory workspace repositories, the real app gate, and
2110
+ * memory ports for everything else.
2111
+ */
2112
+ export function createWorkspaceFixture(): WorkspaceTestFixture {
2113
+ \tconst repositories = createMemoryWorkspacePorts();
2114
+ \tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
2115
+ \t\tbase: appPorts,
2116
+ \t\toverrides: {
2117
+ \t\t\tgate: appPorts.gate,
2118
+ \t\t\t...repositories,
2119
+ \t\t},
2120
+ \t});
2121
+
2122
+ \tconst createContext = (
2123
+ \t\toptions: WorkspaceContextOptions = {},
2124
+ \t): AppContext => {
2125
+ \t\tconst actor = options.userId
2126
+ \t\t\t? createTestUserActor(options.userId, {
2127
+ \t\t\t\t\tdisplayName: options.userName,
2128
+ \t\t\t\t})
2129
+ \t\t\t: createTestAnonymousActor();
2130
+ \t\tconst factory = createTestContextFactory<AppContext, AppContext["ports"]>({
2131
+ \t\t\tports: fixture.ports,
2132
+ \t\t\tactor,
2133
+ \t\t\ttenant: options.workspaceId ? createTenant(options.workspaceId) : null,
2134
+ \t\t\tauth: options.userId
2135
+ \t\t\t\t? {
2136
+ \t\t\t\t\t\tuser: {
2137
+ \t\t\t\t\t\t\tid: options.userId,
2138
+ \t\t\t\t\t\t\tname: options.userName ?? options.userId,
2139
+ \t\t\t\t\t\t\temail: options.userEmail ?? \`\${options.userId}@example.com\`,
2140
+ \t\t\t\t\t\t},
2141
+ \t\t\t\t\t\tsession: { id: \`session_\${options.userId}\` },
2142
+ \t\t\t\t\t}
2143
+ \t\t\t\t: null,
2144
+ \t\t\trequestId: "test-request",
2145
+ \t\t\ttraceId: "test-trace",
2146
+ \t\t\textra: { membership: options.membership ?? null },
2147
+ \t\t});
2148
+
2149
+ \t\treturn factory();
2150
+ \t};
2151
+
2152
+ \treturn {
2153
+ \t\t...fixture,
2154
+ \t\trepositories,
2155
+ \t\tcreateContext,
2156
+ \t};
2157
+ }
2158
+ `;
2159
+ }
2160
+ export function workspaceFactoryFile(config) {
2161
+ return `import { createFactory } from "@beignet/core/testing";
2162
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2163
+ import type { Workspace } from "@/features/workspaces/schemas";
2164
+
2165
+ /**
2166
+ * Builds full workspace entities for policy tests and persists name + slug
2167
+ * through the workspace repository port.
2168
+ */
2169
+ export const workspaceFactory = createFactory<
2170
+ \t"workspaces.workspace",
2171
+ \tWorkspace,
2172
+ \tAppContext,
2173
+ \tWorkspace
2174
+ >("workspaces.workspace", {
2175
+ \tdefaults: ({ sequence, clock }): Workspace => ({
2176
+ \t\tid: \`00000000-0000-4000-8000-\${String(sequence).padStart(12, "0")}\`,
2177
+ \t\tname: \`Workspace \${sequence}\`,
2178
+ \t\tslug: \`workspace-\${sequence}\`,
2179
+ \t\tcreatedAt: clock.now().toISOString(),
2180
+ \t\tupdatedAt: clock.now().toISOString(),
2181
+ \t}),
2182
+ \tpersist: (ctx, value) =>
2183
+ \t\tctx.ports.workspaces.create({ name: value.name, slug: value.slug }),
2184
+ });
2185
+ `;
2186
+ }
2187
+ export function workspaceMemberFactoryFile(config) {
2188
+ return `import { createFactory } from "@beignet/core/testing";
2189
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2190
+ import type {
2191
+ \tWorkspaceMember,
2192
+ \tWorkspaceRole,
2193
+ } from "@/features/workspaces/schemas";
2194
+
2195
+ type WorkspaceMemberSeed = {
2196
+ \tworkspaceId: string;
2197
+ \tuserId: string;
2198
+ \trole: WorkspaceRole;
2199
+ };
2200
+
2201
+ export const workspaceMemberFactory = createFactory<
2202
+ \t"workspaces.member",
2203
+ \tWorkspaceMemberSeed,
2204
+ \tAppContext,
2205
+ \tWorkspaceMember
2206
+ >("workspaces.member", {
2207
+ \tdefaults: ({ sequence }): WorkspaceMemberSeed => ({
2208
+ \t\tworkspaceId: "workspace-1",
2209
+ \t\tuserId: \`user-\${sequence}\`,
2210
+ \t\trole: "member",
2211
+ \t}),
2212
+ \tpersist: (ctx, value) => ctx.ports.members.create(value),
2213
+ });
2214
+ `;
2215
+ }
2216
+ export function workspaceInviteFactoryFile(config) {
2217
+ return `import { createFactory } from "@beignet/core/testing";
2218
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2219
+ import type {
2220
+ \tWorkspaceInvite,
2221
+ \tWorkspaceRole,
2222
+ } from "@/features/workspaces/schemas";
2223
+
2224
+ type WorkspaceInviteSeed = {
2225
+ \tworkspaceId: string;
2226
+ \temail: string;
2227
+ \trole: WorkspaceRole;
2228
+ \ttoken: string;
2229
+ \tinvitedByUserId: string;
2230
+ \texpiresAt: string;
2231
+ };
2232
+
2233
+ export const workspaceInviteFactory = createFactory<
2234
+ \t"workspaces.invite",
2235
+ \tWorkspaceInviteSeed,
2236
+ \tAppContext,
2237
+ \tWorkspaceInvite
2238
+ >("workspaces.invite", {
2239
+ \tdefaults: ({ sequence, clock }): WorkspaceInviteSeed => ({
2240
+ \t\tworkspaceId: "workspace-1",
2241
+ \t\temail: \`invitee-\${sequence}@example.com\`,
2242
+ \t\trole: "member",
2243
+ \t\ttoken: \`invite-token-\${sequence}\`,
2244
+ \t\tinvitedByUserId: "user-1",
2245
+ \t\texpiresAt: new Date(
2246
+ \t\t\tclock.now().getTime() + 14 * 24 * 60 * 60 * 1000,
2247
+ \t\t).toISOString(),
2248
+ \t}),
2249
+ \tpersist: (ctx, value) => ctx.ports.invites.create(value),
2250
+ });
2251
+ `;
2252
+ }
2253
+ export function membershipTestFile(config) {
2254
+ const tenantModule = aliasModule(tenantHelperPath(config));
2255
+ return `import { describe, expect, test } from "bun:test";
2256
+ import type { AuthSession } from "@/ports/auth";
2257
+ import {
2258
+ \tcreateWorkspaceUseCase,
2259
+ \tlistMyWorkspacesUseCase,
2260
+ \tremoveMemberUseCase,
2261
+ \tswitchWorkspaceUseCase,
2262
+ \tupdateMemberRoleUseCase,
2263
+ } from "@/features/workspaces/use-cases";
2264
+ import { resolveRequestWorkspace, WORKSPACE_COOKIE_NAME } from "${tenantModule}";
2265
+ import { createWorkspaceFixture } from "./factories/context";
2266
+ import { workspaceFactory } from "./factories/workspace";
2267
+ import { workspaceMemberFactory } from "./factories/workspace-member";
2268
+
2269
+ function authFor(userId: string): AuthSession {
2270
+ \treturn {
2271
+ \t\tuser: { id: userId, name: userId, email: \`\${userId}@example.com\` },
2272
+ \t\tsession: { id: \`session_\${userId}\` },
2273
+ \t};
2274
+ }
2275
+
2276
+ describe("createWorkspaceUseCase", () => {
2277
+ \ttest("creates the workspace with the creator as admin", async () => {
2278
+ \t\tconst fixture = createWorkspaceFixture();
2279
+ \t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
2280
+
2281
+ \t\tconst workspace = await createWorkspaceUseCase.run({
2282
+ \t\t\tctx,
2283
+ \t\t\tinput: { name: "Acme", slug: "acme" },
2284
+ \t\t});
2285
+
2286
+ \t\texpect(workspace).toMatchObject({ name: "Acme", slug: "acme" });
2287
+ \t\tawait expect(
2288
+ \t\t\tfixture.repositories.members.findMembership({
2289
+ \t\t\t\tworkspaceId: workspace.id,
2290
+ \t\t\t\tuserId: "user-amelia",
2291
+ \t\t\t}),
2292
+ \t\t).resolves.toMatchObject({ role: "admin" });
2293
+ \t});
2294
+
2295
+ \ttest("rejects duplicate slugs", async () => {
2296
+ \t\tconst fixture = createWorkspaceFixture();
2297
+ \t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
2298
+ \t\tawait createWorkspaceUseCase.run({
2299
+ \t\t\tctx,
2300
+ \t\t\tinput: { name: "Acme", slug: "acme" },
2301
+ \t\t});
2302
+
2303
+ \t\tawait expect(
2304
+ \t\t\tcreateWorkspaceUseCase.run({
2305
+ \t\t\t\tctx,
2306
+ \t\t\t\tinput: { name: "Acme Again", slug: "acme" },
2307
+ \t\t\t}),
2308
+ \t\t).rejects.toMatchObject({ code: "WORKSPACE_SLUG_TAKEN", status: 409 });
2309
+ \t});
2310
+
2311
+ \ttest("requires an authenticated user", async () => {
2312
+ \t\tconst fixture = createWorkspaceFixture();
2313
+ \t\tconst ctx = fixture.createContext();
2314
+
2315
+ \t\tawait expect(
2316
+ \t\t\tcreateWorkspaceUseCase.run({
2317
+ \t\t\t\tctx,
2318
+ \t\t\t\tinput: { name: "Acme", slug: "acme" },
2319
+ \t\t\t}),
2320
+ \t\t).rejects.toMatchObject({ code: "UNAUTHORIZED" });
2321
+ \t});
2322
+ });
2323
+
2324
+ describe("listMyWorkspacesUseCase", () => {
2325
+ \ttest("lists the actor's workspaces with membership roles", async () => {
2326
+ \t\tconst fixture = createWorkspaceFixture();
2327
+ \t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
2328
+ \t\tconst first = await workspaceFactory.create(ctx);
2329
+ \t\tconst second = await workspaceFactory.create(ctx);
2330
+ \t\tawait workspaceMemberFactory.create(ctx, {
2331
+ \t\t\tworkspaceId: first.id,
2332
+ \t\t\tuserId: "user-amelia",
2333
+ \t\t\trole: "admin",
2334
+ \t\t});
2335
+ \t\tawait workspaceMemberFactory.create(ctx, {
2336
+ \t\t\tworkspaceId: second.id,
2337
+ \t\t\tuserId: "user-amelia",
2338
+ \t\t\trole: "member",
2339
+ \t\t});
2340
+ \t\tawait workspaceMemberFactory.create(ctx, {
2341
+ \t\t\tworkspaceId: second.id,
2342
+ \t\t\tuserId: "user-other",
2343
+ \t\t\trole: "admin",
2344
+ \t\t});
2345
+
2346
+ \t\tconst listed = await listMyWorkspacesUseCase.run({ ctx, input: {} });
2347
+
2348
+ \t\texpect(listed.items).toHaveLength(2);
2349
+ \t\texpect(listed.items).toEqual(
2350
+ \t\t\texpect.arrayContaining([
2351
+ \t\t\t\texpect.objectContaining({ id: first.id, role: "admin" }),
2352
+ \t\t\t\texpect.objectContaining({ id: second.id, role: "member" }),
2353
+ \t\t\t]),
2354
+ \t\t);
2355
+ \t});
2356
+ });
2357
+
2358
+ describe("switchWorkspaceUseCase", () => {
2359
+ \ttest("allows members and rejects non-members", async () => {
2360
+ \t\tconst fixture = createWorkspaceFixture();
2361
+ \t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
2362
+ \t\tconst workspace = await workspaceFactory.create(ctx);
2363
+ \t\tawait workspaceMemberFactory.create(ctx, {
2364
+ \t\t\tworkspaceId: workspace.id,
2365
+ \t\t\tuserId: "user-amelia",
2366
+ \t\t\trole: "member",
2367
+ \t\t});
2368
+
2369
+ \t\tawait expect(
2370
+ \t\t\tswitchWorkspaceUseCase.run({
2371
+ \t\t\t\tctx,
2372
+ \t\t\t\tinput: { workspaceId: workspace.id },
2373
+ \t\t\t}),
2374
+ \t\t).resolves.toEqual({ workspaceId: workspace.id });
2375
+
2376
+ \t\tconst intruderCtx = fixture.createContext({ userId: "user-intruder" });
2377
+ \t\tawait expect(
2378
+ \t\t\tswitchWorkspaceUseCase.run({
2379
+ \t\t\t\tctx: intruderCtx,
2380
+ \t\t\t\tinput: { workspaceId: workspace.id },
2381
+ \t\t\t}),
2382
+ \t\t).rejects.toMatchObject({ code: "NOT_A_WORKSPACE_MEMBER", status: 403 });
2383
+ \t});
2384
+ });
2385
+
2386
+ describe("resolveRequestWorkspace", () => {
2387
+ \ttest("resolves the active workspace from memberships and the cookie", async () => {
2388
+ \t\tconst fixture = createWorkspaceFixture();
2389
+ \t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
2390
+ \t\tconst first = await workspaceFactory.create(ctx);
2391
+ \t\tconst second = await workspaceFactory.create(ctx);
2392
+ \t\tawait workspaceMemberFactory.create(ctx, {
2393
+ \t\t\tworkspaceId: first.id,
2394
+ \t\t\tuserId: "user-amelia",
2395
+ \t\t\trole: "admin",
2396
+ \t\t});
2397
+ \t\tawait workspaceMemberFactory.create(ctx, {
2398
+ \t\t\tworkspaceId: second.id,
2399
+ \t\t\tuserId: "user-amelia",
2400
+ \t\t\trole: "member",
2401
+ \t\t});
2402
+
2403
+ \t\tconst withoutCookie = await resolveRequestWorkspace({
2404
+ \t\t\tauth: authFor("user-amelia"),
2405
+ \t\t\treq: { headers: new Headers() },
2406
+ \t\t\tports: fixture.ports,
2407
+ \t\t});
2408
+ \t\texpect(withoutCookie.tenant?.id).toBe(first.id);
2409
+ \t\texpect(withoutCookie.membership).toMatchObject({
2410
+ \t\t\tworkspaceId: first.id,
2411
+ \t\t\trole: "admin",
2412
+ \t\t});
2413
+
2414
+ \t\tconst withCookie = await resolveRequestWorkspace({
2415
+ \t\t\tauth: authFor("user-amelia"),
2416
+ \t\t\treq: {
2417
+ \t\t\t\theaders: new Headers({
2418
+ \t\t\t\t\tcookie: \`\${WORKSPACE_COOKIE_NAME}=\${encodeURIComponent(second.id)}\`,
2419
+ \t\t\t\t}),
2420
+ \t\t\t},
2421
+ \t\t\tports: fixture.ports,
2422
+ \t\t});
2423
+ \t\texpect(withCookie.tenant?.id).toBe(second.id);
2424
+ \t\texpect(withCookie.membership).toMatchObject({
2425
+ \t\t\tworkspaceId: second.id,
2426
+ \t\t\trole: "member",
2427
+ \t\t});
2428
+ \t});
2429
+
2430
+ \ttest("resolves no workspace for anonymous or membership-less users", async () => {
2431
+ \t\tconst fixture = createWorkspaceFixture();
2432
+
2433
+ \t\tawait expect(
2434
+ \t\t\tresolveRequestWorkspace({
2435
+ \t\t\t\tauth: null,
2436
+ \t\t\t\treq: { headers: new Headers() },
2437
+ \t\t\t\tports: fixture.ports,
2438
+ \t\t\t}),
2439
+ \t\t).resolves.toEqual({ tenant: undefined, membership: null });
2440
+
2441
+ \t\tawait expect(
2442
+ \t\t\tresolveRequestWorkspace({
2443
+ \t\t\t\tauth: authFor("user-lonely"),
2444
+ \t\t\t\treq: { headers: new Headers() },
2445
+ \t\t\t\tports: fixture.ports,
2446
+ \t\t\t}),
2447
+ \t\t).resolves.toEqual({ tenant: undefined, membership: null });
2448
+ \t});
2449
+ });
2450
+
2451
+ describe("member management", () => {
2452
+ \tasync function seedWorkspace(fixture: ReturnType<typeof createWorkspaceFixture>) {
2453
+ \t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2454
+ \t\tconst workspace = await workspaceFactory.create(seedCtx);
2455
+ \t\tawait workspaceMemberFactory.create(seedCtx, {
2456
+ \t\t\tworkspaceId: workspace.id,
2457
+ \t\t\tuserId: "user-admin",
2458
+ \t\t\trole: "admin",
2459
+ \t\t});
2460
+ \t\tawait workspaceMemberFactory.create(seedCtx, {
2461
+ \t\t\tworkspaceId: workspace.id,
2462
+ \t\t\tuserId: "user-member",
2463
+ \t\t\trole: "member",
2464
+ \t\t});
2465
+ \t\treturn workspace;
2466
+ \t}
2467
+
2468
+ \ttest("admins can promote members", async () => {
2469
+ \t\tconst fixture = createWorkspaceFixture();
2470
+ \t\tconst workspace = await seedWorkspace(fixture);
2471
+ \t\tconst ctx = fixture.createContext({
2472
+ \t\t\tuserId: "user-admin",
2473
+ \t\t\tworkspaceId: workspace.id,
2474
+ \t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
2475
+ \t\t});
2476
+
2477
+ \t\tconst updated = await updateMemberRoleUseCase.run({
2478
+ \t\t\tctx,
2479
+ \t\t\tinput: { userId: "user-member", role: "admin" },
2480
+ \t\t});
2481
+
2482
+ \t\texpect(updated.role).toBe("admin");
2483
+ \t\tawait expect(
2484
+ \t\t\tfixture.repositories.members.countAdmins(workspace.id),
2485
+ \t\t).resolves.toBe(2);
2486
+ \t});
2487
+
2488
+ \ttest("refuses to demote or remove the last admin", async () => {
2489
+ \t\tconst fixture = createWorkspaceFixture();
2490
+ \t\tconst workspace = await seedWorkspace(fixture);
2491
+ \t\tconst ctx = fixture.createContext({
2492
+ \t\t\tuserId: "user-admin",
2493
+ \t\t\tworkspaceId: workspace.id,
2494
+ \t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
2495
+ \t\t});
2496
+
2497
+ \t\tawait expect(
2498
+ \t\t\tupdateMemberRoleUseCase.run({
2499
+ \t\t\t\tctx,
2500
+ \t\t\t\tinput: { userId: "user-admin", role: "member" },
2501
+ \t\t\t}),
2502
+ \t\t).rejects.toMatchObject({ code: "LAST_ADMIN_REMOVAL", status: 409 });
2503
+ \t\tawait expect(
2504
+ \t\t\tremoveMemberUseCase.run({ ctx, input: { userId: "user-admin" } }),
2505
+ \t\t).rejects.toMatchObject({ code: "LAST_ADMIN_REMOVAL", status: 409 });
2506
+ \t});
2507
+
2508
+ \ttest("members cannot manage roles or remove members", async () => {
2509
+ \t\tconst fixture = createWorkspaceFixture();
2510
+ \t\tconst workspace = await seedWorkspace(fixture);
2511
+ \t\tconst ctx = fixture.createContext({
2512
+ \t\t\tuserId: "user-member",
2513
+ \t\t\tworkspaceId: workspace.id,
2514
+ \t\t\tmembership: { workspaceId: workspace.id, role: "member" },
2515
+ \t\t});
2516
+
2517
+ \t\tawait expect(
2518
+ \t\t\tupdateMemberRoleUseCase.run({
2519
+ \t\t\t\tctx,
2520
+ \t\t\t\tinput: { userId: "user-admin", role: "member" },
2521
+ \t\t\t}),
2522
+ \t\t).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
2523
+ \t\tawait expect(
2524
+ \t\t\tremoveMemberUseCase.run({ ctx, input: { userId: "user-admin" } }),
2525
+ \t\t).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
2526
+ \t});
2527
+
2528
+ \ttest("removes members and rejects unknown targets", async () => {
2529
+ \t\tconst fixture = createWorkspaceFixture();
2530
+ \t\tconst workspace = await seedWorkspace(fixture);
2531
+ \t\tconst ctx = fixture.createContext({
2532
+ \t\t\tuserId: "user-admin",
2533
+ \t\t\tworkspaceId: workspace.id,
2534
+ \t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
2535
+ \t\t});
2536
+
2537
+ \t\tawait removeMemberUseCase.run({ ctx, input: { userId: "user-member" } });
2538
+ \t\tawait expect(
2539
+ \t\t\tfixture.repositories.members.findMembership({
2540
+ \t\t\t\tworkspaceId: workspace.id,
2541
+ \t\t\t\tuserId: "user-member",
2542
+ \t\t\t}),
2543
+ \t\t).resolves.toBeNull();
2544
+
2545
+ \t\tawait expect(
2546
+ \t\t\tremoveMemberUseCase.run({ ctx, input: { userId: "user-ghost" } }),
2547
+ \t\t).rejects.toMatchObject({ code: "NOT_A_WORKSPACE_MEMBER" });
2548
+ \t});
2549
+ });
2550
+ `;
2551
+ }
2552
+ export function invitesTestFile() {
2553
+ return `import { describe, expect, test } from "bun:test";
2554
+ import { createInvite } from "@/features/workspaces/contracts";
2555
+ import {
2556
+ \tacceptInviteUseCase,
2557
+ \tcreateInviteUseCase,
2558
+ \trevokeInviteUseCase,
2559
+ } from "@/features/workspaces/use-cases";
2560
+ import {
2561
+ \tcreateWorkspaceFixture,
2562
+ \ttype WorkspaceTestFixture,
2563
+ } from "./factories/context";
2564
+ import { workspaceFactory } from "./factories/workspace";
2565
+ import { workspaceInviteFactory } from "./factories/workspace-invite";
2566
+ import { workspaceMemberFactory } from "./factories/workspace-member";
2567
+
2568
+ async function seedWorkspace(fixture: WorkspaceTestFixture) {
2569
+ \tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2570
+ \tconst workspace = await workspaceFactory.create(seedCtx, {
2571
+ \t\tname: "Acme",
2572
+ \t\tslug: "acme",
2573
+ \t});
2574
+ \tawait workspaceMemberFactory.create(seedCtx, {
2575
+ \t\tworkspaceId: workspace.id,
2576
+ \t\tuserId: "user-admin",
2577
+ \t\trole: "admin",
2578
+ \t});
2579
+ \treturn workspace;
2580
+ }
2581
+
2582
+ describe("createInviteUseCase", () => {
2583
+ \ttest("creates a pending invite and sends the invite email", async () => {
2584
+ \t\tconst fixture = createWorkspaceFixture();
2585
+ \t\tconst workspace = await seedWorkspace(fixture);
2586
+ \t\tconst ctx = fixture.createContext({
2587
+ \t\t\tuserId: "user-admin",
2588
+ \t\t\tuserName: "Amelia",
2589
+ \t\t\tworkspaceId: workspace.id,
2590
+ \t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
2591
+ \t\t});
2592
+
2593
+ \t\tconst invite = await createInviteUseCase.run({
2594
+ \t\t\tctx,
2595
+ \t\t\tinput: { email: "taylor@example.com", role: "member" },
2596
+ \t\t});
2597
+
2598
+ \t\texpect(invite).toMatchObject({
2599
+ \t\t\tworkspaceId: workspace.id,
2600
+ \t\t\temail: "taylor@example.com",
2601
+ \t\t\trole: "member",
2602
+ \t\t\tstatus: "pending",
2603
+ \t\t\tinvitedByUserId: "user-admin",
2604
+ \t\t});
2605
+ \t\texpect(invite.token.length).toBeGreaterThan(0);
2606
+
2607
+ \t\texpect(fixture.notifications.deliveries).toMatchObject([
2608
+ \t\t\t{
2609
+ \t\t\t\tnotificationName: "workspaces.invite",
2610
+ \t\t\t\tpayload: {
2611
+ \t\t\t\t\tinviteId: invite.id,
2612
+ \t\t\t\t\tworkspaceName: "Acme",
2613
+ \t\t\t\t\temail: "taylor@example.com",
2614
+ \t\t\t\t\ttoken: invite.token,
2615
+ \t\t\t\t},
2616
+ \t\t\t},
2617
+ \t\t]);
2618
+ \t});
2619
+
2620
+ \ttest("denies non-admin members", async () => {
2621
+ \t\tconst fixture = createWorkspaceFixture();
2622
+ \t\tconst workspace = await seedWorkspace(fixture);
2623
+ \t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2624
+ \t\tawait workspaceMemberFactory.create(seedCtx, {
2625
+ \t\t\tworkspaceId: workspace.id,
2626
+ \t\t\tuserId: "user-member",
2627
+ \t\t\trole: "member",
2628
+ \t\t});
2629
+ \t\tconst ctx = fixture.createContext({
2630
+ \t\t\tuserId: "user-member",
2631
+ \t\t\tworkspaceId: workspace.id,
2632
+ \t\t\tmembership: { workspaceId: workspace.id, role: "member" },
2633
+ \t\t});
2634
+
2635
+ \t\tawait expect(
2636
+ \t\t\tcreateInviteUseCase.run({
2637
+ \t\t\t\tctx,
2638
+ \t\t\t\tinput: { email: "x@example.com", role: "member" },
2639
+ \t\t\t}),
2640
+ \t\t).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
2641
+ \t\texpect(fixture.notifications.deliveries).toHaveLength(0);
2642
+ \t});
2643
+
2644
+ \ttest("declares required idempotency on the contract", () => {
2645
+ \t\texpect(createInvite.metadata.idempotency).toMatchObject({
2646
+ \t\t\trequired: true,
2647
+ \t\t\tscope: "actor",
2648
+ \t\t});
2649
+ \t});
2650
+ });
2651
+
2652
+ describe("acceptInviteUseCase", () => {
2653
+ \ttest("creates the membership and marks the invite accepted", async () => {
2654
+ \t\tconst fixture = createWorkspaceFixture();
2655
+ \t\tconst workspace = await seedWorkspace(fixture);
2656
+ \t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2657
+ \t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
2658
+ \t\t\tworkspaceId: workspace.id,
2659
+ \t\t\temail: "priya@example.com",
2660
+ \t\t\tinvitedByUserId: "user-admin",
2661
+ \t\t});
2662
+
2663
+ \t\tconst ctx = fixture.createContext({
2664
+ \t\t\tuserId: "user-priya",
2665
+ \t\t\tuserName: "Priya",
2666
+ \t\t});
2667
+ \t\tconst result = await acceptInviteUseCase.run({
2668
+ \t\t\tctx,
2669
+ \t\t\tinput: { token: invite.token },
2670
+ \t\t});
2671
+
2672
+ \t\texpect(result.workspace.id).toBe(workspace.id);
2673
+ \t\texpect(result.membership).toMatchObject({
2674
+ \t\t\tworkspaceId: workspace.id,
2675
+ \t\t\tuserId: "user-priya",
2676
+ \t\t\trole: "member",
2677
+ \t\t});
2678
+
2679
+ \t\tconst reloaded = await fixture.repositories.invites.findByToken(
2680
+ \t\t\tinvite.token,
2681
+ \t\t);
2682
+ \t\texpect(reloaded?.status).toBe("accepted");
2683
+ \t\texpect(reloaded?.acceptedAt).not.toBeNull();
2684
+ \t});
2685
+
2686
+ \ttest("rejects an already accepted invite", async () => {
2687
+ \t\tconst fixture = createWorkspaceFixture();
2688
+ \t\tconst workspace = await seedWorkspace(fixture);
2689
+ \t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2690
+ \t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
2691
+ \t\t\tworkspaceId: workspace.id,
2692
+ \t\t\tinvitedByUserId: "user-admin",
2693
+ \t\t});
2694
+
2695
+ \t\tconst ctx = fixture.createContext({ userId: "user-priya" });
2696
+ \t\tawait acceptInviteUseCase.run({ ctx, input: { token: invite.token } });
2697
+
2698
+ \t\tawait expect(
2699
+ \t\t\tacceptInviteUseCase.run({ ctx, input: { token: invite.token } }),
2700
+ \t\t).rejects.toMatchObject({ code: "INVITE_ALREADY_ACCEPTED", status: 409 });
2701
+ \t});
2702
+
2703
+ \ttest("expires stale invites and rejects them", async () => {
2704
+ \t\tconst fixture = createWorkspaceFixture();
2705
+ \t\tconst workspace = await seedWorkspace(fixture);
2706
+ \t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2707
+ \t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
2708
+ \t\t\tworkspaceId: workspace.id,
2709
+ \t\t\tinvitedByUserId: "user-admin",
2710
+ \t\t\texpiresAt: new Date(Date.now() - 1000).toISOString(),
2711
+ \t\t});
2712
+
2713
+ \t\tconst ctx = fixture.createContext({ userId: "user-priya" });
2714
+ \t\tawait expect(
2715
+ \t\t\tacceptInviteUseCase.run({ ctx, input: { token: invite.token } }),
2716
+ \t\t).rejects.toMatchObject({ code: "INVITE_EXPIRED", status: 410 });
2717
+
2718
+ \t\tconst reloaded = await fixture.repositories.invites.findByToken(
2719
+ \t\t\tinvite.token,
2720
+ \t\t);
2721
+ \t\texpect(reloaded?.status).toBe("expired");
2722
+ \t});
2723
+
2724
+ \ttest("rejects unknown tokens", async () => {
2725
+ \t\tconst fixture = createWorkspaceFixture();
2726
+ \t\tconst ctx = fixture.createContext({ userId: "user-priya" });
2727
+
2728
+ \t\tawait expect(
2729
+ \t\t\tacceptInviteUseCase.run({ ctx, input: { token: "nope" } }),
2730
+ \t\t).rejects.toMatchObject({ code: "INVITE_NOT_FOUND", status: 404 });
2731
+ \t});
2732
+ });
2733
+
2734
+ describe("revokeInviteUseCase", () => {
2735
+ \ttest("revokes pending invites and rejects unknown invites", async () => {
2736
+ \t\tconst fixture = createWorkspaceFixture();
2737
+ \t\tconst workspace = await seedWorkspace(fixture);
2738
+ \t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
2739
+ \t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
2740
+ \t\t\tworkspaceId: workspace.id,
2741
+ \t\t\tinvitedByUserId: "user-admin",
2742
+ \t\t});
2743
+ \t\tconst ctx = fixture.createContext({
2744
+ \t\t\tuserId: "user-admin",
2745
+ \t\t\tworkspaceId: workspace.id,
2746
+ \t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
2747
+ \t\t});
2748
+
2749
+ \t\tconst revoked = await revokeInviteUseCase.run({
2750
+ \t\t\tctx,
2751
+ \t\t\tinput: { inviteId: invite.id },
2752
+ \t\t});
2753
+ \t\texpect(revoked.status).toBe("revoked");
2754
+
2755
+ \t\tawait expect(
2756
+ \t\t\trevokeInviteUseCase.run({ ctx, input: { inviteId: "missing" } }),
2757
+ \t\t).rejects.toMatchObject({ code: "INVITE_NOT_FOUND" });
2758
+ \t});
2759
+ });
2760
+ `;
2761
+ }
2762
+ export function policyTestFile() {
2763
+ return `import { describe, expect, it } from "bun:test";
2764
+ import { createPolicyTester } from "@beignet/core/ports/testing";
2765
+ import { workspacePolicy } from "@/features/workspaces/policy";
2766
+ import { workspaceFactory } from "./factories/workspace";
2767
+
2768
+ const abilities = [
2769
+ \t"workspaces.update",
2770
+ \t"workspaces.members.view",
2771
+ \t"workspaces.members.manage",
2772
+ ] as const;
2773
+
2774
+ describe("workspacePolicy", () => {
2775
+ \tit("documents the admin/member/anonymous matrix per ability", async () => {
2776
+ \t\tconst tester = createPolicyTester({ policies: [workspacePolicy] });
2777
+ \t\tconst workspace = workspaceFactory.build({ id: "ws-1" });
2778
+
2779
+ \t\tconst adminCtx = {
2780
+ \t\t\tactor: { type: "user" as const, id: "user-admin" },
2781
+ \t\t\tmembership: { workspaceId: "ws-1", role: "admin" as const },
2782
+ \t\t};
2783
+ \t\tconst memberCtx = {
2784
+ \t\t\tactor: { type: "user" as const, id: "user-member" },
2785
+ \t\t\tmembership: { workspaceId: "ws-1", role: "member" as const },
2786
+ \t\t};
2787
+ \t\tconst otherWorkspaceCtx = {
2788
+ \t\t\tactor: { type: "user" as const, id: "user-other" },
2789
+ \t\t\tmembership: { workspaceId: "ws-2", role: "admin" as const },
2790
+ \t\t};
2791
+ \t\tconst anonymousCtx = {
2792
+ \t\t\tactor: { type: "anonymous" as const },
2793
+ \t\t\tmembership: null,
2794
+ \t\t};
2795
+
2796
+ \t\tawait expect(
2797
+ \t\t\ttester.assertMatrix([
2798
+ \t\t\t\t// Admin-only abilities.
2799
+ \t\t\t\t...(
2800
+ \t\t\t\t\t["workspaces.update", "workspaces.members.manage"] as const
2801
+ \t\t\t\t).flatMap((ability) => [
2802
+ \t\t\t\t\t{
2803
+ \t\t\t\t\t\tname: \`admins are allowed \${ability}\`,
2804
+ \t\t\t\t\t\tctx: adminCtx,
2805
+ \t\t\t\t\t\tability,
2806
+ \t\t\t\t\t\tsubject: workspace,
2807
+ \t\t\t\t\t\texpected: "allow" as const,
2808
+ \t\t\t\t\t},
2809
+ \t\t\t\t\t{
2810
+ \t\t\t\t\t\tname: \`members are denied \${ability}\`,
2811
+ \t\t\t\t\t\tctx: memberCtx,
2812
+ \t\t\t\t\t\tability,
2813
+ \t\t\t\t\t\tsubject: workspace,
2814
+ \t\t\t\t\t\texpected: "deny" as const,
2815
+ \t\t\t\t\t\tcode: "WORKSPACE_ADMIN_REQUIRED",
2816
+ \t\t\t\t\t},
2817
+ \t\t\t\t]),
2818
+ \t\t\t\t// Any member may view members.
2819
+ \t\t\t\t{
2820
+ \t\t\t\t\tname: "members can view members",
2821
+ \t\t\t\t\tctx: memberCtx,
2822
+ \t\t\t\t\tability: "workspaces.members.view",
2823
+ \t\t\t\t\tsubject: workspace,
2824
+ \t\t\t\t\texpected: "allow",
2825
+ \t\t\t\t},
2826
+ \t\t\t\t// Cross-workspace admins and anonymous visitors are non-members.
2827
+ \t\t\t\t...abilities.flatMap((ability) => [
2828
+ \t\t\t\t\t{
2829
+ \t\t\t\t\t\tname: \`another workspace's admin is denied \${ability}\`,
2830
+ \t\t\t\t\t\tctx: otherWorkspaceCtx,
2831
+ \t\t\t\t\t\tability,
2832
+ \t\t\t\t\t\tsubject: workspace,
2833
+ \t\t\t\t\t\texpected: "deny" as const,
2834
+ \t\t\t\t\t\tcode: "NOT_A_WORKSPACE_MEMBER",
2835
+ \t\t\t\t\t},
2836
+ \t\t\t\t\t{
2837
+ \t\t\t\t\t\tname: \`anonymous visitors are denied \${ability}\`,
2838
+ \t\t\t\t\t\tctx: anonymousCtx,
2839
+ \t\t\t\t\t\tability,
2840
+ \t\t\t\t\t\tsubject: workspace,
2841
+ \t\t\t\t\t\texpected: "deny" as const,
2842
+ \t\t\t\t\t\tcode: "NOT_A_WORKSPACE_MEMBER",
2843
+ \t\t\t\t\t},
2844
+ \t\t\t\t]),
2845
+ \t\t\t]),
2846
+ \t\t).resolves.toBeUndefined();
2847
+ \t});
2848
+ });
2849
+ `;
2850
+ }
2851
+ export function workspaceDrizzleSchemaFile(database) {
2852
+ const dialect = drizzleDialects[database];
2853
+ const column = database === "mysql"
2854
+ ? (columnName) => `varchar("${columnName}", { length: 255 })`
2855
+ : (columnName) => `text("${columnName}")`;
2856
+ const imports = database === "mysql"
2857
+ ? "index, mysqlTable, uniqueIndex, varchar"
2858
+ : `index, ${dialect.tableFunction}, text, uniqueIndex`;
2859
+ return `import { ${imports} } from "${dialect.columnModule}";
2860
+ import type {
2861
+ \tInviteStatus,
2862
+ \tWorkspaceRole,
2863
+ } from "@/features/workspaces/schemas";
2864
+
2865
+ export const workspaces = ${dialect.tableFunction}(
2866
+ \t"workspaces",
2867
+ \t{
2868
+ \t\tid: ${column("id")}.primaryKey(),
2869
+ \t\tname: ${column("name")}.notNull(),
2870
+ \t\tslug: ${column("slug")}.notNull(),
2871
+ \t\tcreatedAt: ${column("created_at")}.notNull(),
2872
+ \t\tupdatedAt: ${column("updated_at")}.notNull(),
2873
+ \t},
2874
+ \t(table) => ({
2875
+ \t\tslugUnique: uniqueIndex("workspaces_slug_unique").on(table.slug),
2876
+ \t}),
2877
+ );
2878
+
2879
+ export const workspaceMembers = ${dialect.tableFunction}(
2880
+ \t"workspace_members",
2881
+ \t{
2882
+ \t\tid: ${column("id")}.primaryKey(),
2883
+ \t\tworkspaceId: ${column("workspace_id")}.notNull(),
2884
+ \t\tuserId: ${column("user_id")}.notNull(),
2885
+ \t\trole: ${column("role")}.$type<WorkspaceRole>().notNull(),
2886
+ \t\tjoinedAt: ${column("joined_at")}.notNull(),
2887
+ \t},
2888
+ \t(table) => ({
2889
+ \t\tworkspaceUserUnique: uniqueIndex(
2890
+ \t\t\t"workspace_members_workspace_user_unique",
2891
+ \t\t).on(table.workspaceId, table.userId),
2892
+ \t\tuserIdx: index("workspace_members_user_idx").on(table.userId),
2893
+ \t}),
2894
+ );
2895
+
2896
+ export const workspaceInvites = ${dialect.tableFunction}(
2897
+ \t"workspace_invites",
2898
+ \t{
2899
+ \t\tid: ${column("id")}.primaryKey(),
2900
+ \t\tworkspaceId: ${column("workspace_id")}.notNull(),
2901
+ \t\temail: ${column("email")}.notNull(),
2902
+ \t\trole: ${column("role")}.$type<WorkspaceRole>().notNull(),
2903
+ \t\ttoken: ${column("token")}.notNull(),
2904
+ \t\tinvitedByUserId: ${column("invited_by_user_id")}.notNull(),
2905
+ \t\tstatus: ${column("status")}.$type<InviteStatus>().notNull(),
2906
+ \t\texpiresAt: ${column("expires_at")}.notNull(),
2907
+ \t\tacceptedAt: ${column("accepted_at")},
2908
+ \t\tcreatedAt: ${column("created_at")}.notNull(),
2909
+ \t},
2910
+ \t(table) => ({
2911
+ \t\ttokenUnique: uniqueIndex("workspace_invites_token_unique").on(
2912
+ \t\t\ttable.token,
2913
+ \t\t),
2914
+ \t\tworkspaceStatusIdx: index("workspace_invites_workspace_status_idx").on(
2915
+ \t\t\ttable.workspaceId,
2916
+ \t\t\ttable.status,
2917
+ \t\t),
2918
+ \t}),
2919
+ );
2920
+ `;
2921
+ }
2922
+ export function workspaceDrizzleRepositoryFile(config, database) {
2923
+ const dialect = drizzleDialects[database];
2924
+ const portsModule = aliasModule(path.join(config.paths.features, "workspaces/ports.ts"));
2925
+ const workspaceCreate = dialect.supportsReturning
2926
+ ? `\t\t\tconst [row] = await db
2927
+ \t\t\t\t.insert(schema.workspaces)
2928
+ \t\t\t\t.values(values)
2929
+ \t\t\t\t.returning();
2930
+
2931
+ \t\t\tif (!row) {
2932
+ \t\t\t\tthrow new Error("Failed to create workspace.");
2933
+ \t\t\t}
2934
+
2935
+ \t\t\treturn toWorkspace(row);`
2936
+ : `\t\t\tawait db.insert(schema.workspaces).values(values);
2937
+
2938
+ \t\t\tconst [row] = await db
2939
+ \t\t\t\t.select()
2940
+ \t\t\t\t.from(schema.workspaces)
2941
+ \t\t\t\t.where(eq(schema.workspaces.id, values.id))
2942
+ \t\t\t\t.limit(1);
2943
+
2944
+ \t\t\tif (!row) {
2945
+ \t\t\t\tthrow new Error("Failed to create workspace.");
2946
+ \t\t\t}
2947
+
2948
+ \t\t\treturn toWorkspace(row);`;
2949
+ const workspaceUpdate = dialect.supportsReturning
2950
+ ? `\t\t\tconst [row] = await db
2951
+ \t\t\t\t.update(schema.workspaces)
2952
+ \t\t\t\t.set(updates)
2953
+ \t\t\t\t.where(eq(schema.workspaces.id, id))
2954
+ \t\t\t\t.returning();
2955
+
2956
+ \t\t\treturn row ? toWorkspace(row) : null;`
2957
+ : `\t\t\tawait db
2958
+ \t\t\t\t.update(schema.workspaces)
2959
+ \t\t\t\t.set(updates)
2960
+ \t\t\t\t.where(eq(schema.workspaces.id, id));
2961
+
2962
+ \t\t\tconst [row] = await db
2963
+ \t\t\t\t.select()
2964
+ \t\t\t\t.from(schema.workspaces)
2965
+ \t\t\t\t.where(eq(schema.workspaces.id, id))
2966
+ \t\t\t\t.limit(1);
2967
+
2968
+ \t\t\treturn row ? toWorkspace(row) : null;`;
2969
+ const memberCreate = dialect.supportsReturning
2970
+ ? `\t\t\tconst [row] = await db
2971
+ \t\t\t\t.insert(schema.workspaceMembers)
2972
+ \t\t\t\t.values(values)
2973
+ \t\t\t\t.returning();
2974
+
2975
+ \t\t\tif (!row) {
2976
+ \t\t\t\tthrow new Error("Failed to create workspace membership.");
2977
+ \t\t\t}
2978
+
2979
+ \t\t\treturn toMember(row);`
2980
+ : `\t\t\tawait db.insert(schema.workspaceMembers).values(values);
2981
+
2982
+ \t\t\tconst [row] = await db
2983
+ \t\t\t\t.select()
2984
+ \t\t\t\t.from(schema.workspaceMembers)
2985
+ \t\t\t\t.where(eq(schema.workspaceMembers.id, values.id))
2986
+ \t\t\t\t.limit(1);
2987
+
2988
+ \t\t\tif (!row) {
2989
+ \t\t\t\tthrow new Error("Failed to create workspace membership.");
2990
+ \t\t\t}
2991
+
2992
+ \t\t\treturn toMember(row);`;
2993
+ const memberUpdateRole = dialect.supportsReturning
2994
+ ? `\t\t\tconst [row] = await db
2995
+ \t\t\t\t.update(schema.workspaceMembers)
2996
+ \t\t\t\t.set({ role })
2997
+ \t\t\t\t.where(membershipWhere(lookup))
2998
+ \t\t\t\t.returning();
2999
+
3000
+ \t\t\treturn row ? toMember(row) : null;`
3001
+ : `\t\t\tawait db
3002
+ \t\t\t\t.update(schema.workspaceMembers)
3003
+ \t\t\t\t.set({ role })
3004
+ \t\t\t\t.where(membershipWhere(lookup));
3005
+
3006
+ \t\t\tconst [row] = await db
3007
+ \t\t\t\t.select()
3008
+ \t\t\t\t.from(schema.workspaceMembers)
3009
+ \t\t\t\t.where(membershipWhere(lookup))
3010
+ \t\t\t\t.limit(1);
3011
+
3012
+ \t\t\treturn row ? toMember(row) : null;`;
3013
+ const memberRemove = dialect.supportsReturning
3014
+ ? `\t\t\tconst rows = await db
3015
+ \t\t\t\t.delete(schema.workspaceMembers)
3016
+ \t\t\t\t.where(membershipWhere(lookup))
3017
+ \t\t\t\t.returning({ id: schema.workspaceMembers.id });
3018
+
3019
+ \t\t\treturn rows.length > 0;`
3020
+ : `\t\t\tconst [existing] = await db
3021
+ \t\t\t\t.select({ id: schema.workspaceMembers.id })
3022
+ \t\t\t\t.from(schema.workspaceMembers)
3023
+ \t\t\t\t.where(membershipWhere(lookup))
3024
+ \t\t\t\t.limit(1);
3025
+ \t\t\tif (!existing) return false;
3026
+
3027
+ \t\t\tawait db
3028
+ \t\t\t\t.delete(schema.workspaceMembers)
3029
+ \t\t\t\t.where(membershipWhere(lookup));
3030
+
3031
+ \t\t\treturn true;`;
3032
+ const inviteCreate = dialect.supportsReturning
3033
+ ? `\t\t\tconst [row] = await db
3034
+ \t\t\t\t.insert(schema.workspaceInvites)
3035
+ \t\t\t\t.values(values)
3036
+ \t\t\t\t.returning();
3037
+
3038
+ \t\t\tif (!row) {
3039
+ \t\t\t\tthrow new Error("Failed to create workspace invite.");
3040
+ \t\t\t}
3041
+
3042
+ \t\t\treturn toInvite(row);`
3043
+ : `\t\t\tawait db.insert(schema.workspaceInvites).values(values);
3044
+
3045
+ \t\t\tconst [row] = await db
3046
+ \t\t\t\t.select()
3047
+ \t\t\t\t.from(schema.workspaceInvites)
3048
+ \t\t\t\t.where(eq(schema.workspaceInvites.id, values.id))
3049
+ \t\t\t\t.limit(1);
3050
+
3051
+ \t\t\tif (!row) {
3052
+ \t\t\t\tthrow new Error("Failed to create workspace invite.");
3053
+ \t\t\t}
3054
+
3055
+ \t\t\treturn toInvite(row);`;
3056
+ const inviteUpdateStatus = dialect.supportsReturning
3057
+ ? `\t\t\tconst [row] = await db
3058
+ \t\t\t\t.update(schema.workspaceInvites)
3059
+ \t\t\t\t.set({ status, acceptedAt: acceptedAt ?? null })
3060
+ \t\t\t\t.where(eq(schema.workspaceInvites.id, id))
3061
+ \t\t\t\t.returning();
3062
+
3063
+ \t\t\treturn row ? toInvite(row) : null;`
3064
+ : `\t\t\tawait db
3065
+ \t\t\t\t.update(schema.workspaceInvites)
3066
+ \t\t\t\t.set({ status, acceptedAt: acceptedAt ?? null })
3067
+ \t\t\t\t.where(eq(schema.workspaceInvites.id, id));
3068
+
3069
+ \t\t\tconst [row] = await db
3070
+ \t\t\t\t.select()
3071
+ \t\t\t\t.from(schema.workspaceInvites)
3072
+ \t\t\t\t.where(eq(schema.workspaceInvites.id, id))
3073
+ \t\t\t\t.limit(1);
3074
+
3075
+ \t\t\treturn row ? toInvite(row) : null;`;
3076
+ return `import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
3077
+ import { and, asc, count, desc, eq, inArray } from "drizzle-orm";
3078
+ import type {
3079
+ \tWorkspace,
3080
+ \tWorkspaceInvite,
3081
+ \tWorkspaceInviteRepository,
3082
+ \tWorkspaceMember,
3083
+ \tWorkspaceMemberRepository,
3084
+ \tWorkspaceMembershipLookup,
3085
+ \tWorkspaceMemberWithUser,
3086
+ \tWorkspaceRepository,
3087
+ } from "${portsModule}";
3088
+ import * as schema from "@/infra/db/schema";
3089
+
3090
+ type WorkspaceRow = typeof schema.workspaces.$inferSelect;
3091
+ type MemberRow = typeof schema.workspaceMembers.$inferSelect;
3092
+ type InviteRow = typeof schema.workspaceInvites.$inferSelect;
3093
+
3094
+ function toWorkspace(row: WorkspaceRow): Workspace {
3095
+ \treturn {
3096
+ \t\tid: row.id,
3097
+ \t\tname: row.name,
3098
+ \t\tslug: row.slug,
3099
+ \t\tcreatedAt: row.createdAt,
3100
+ \t\tupdatedAt: row.updatedAt,
3101
+ \t};
3102
+ }
3103
+
3104
+ function toMember(row: MemberRow): WorkspaceMember {
3105
+ \treturn {
3106
+ \t\tid: row.id,
3107
+ \t\tworkspaceId: row.workspaceId,
3108
+ \t\tuserId: row.userId,
3109
+ \t\trole: row.role,
3110
+ \t\tjoinedAt: row.joinedAt,
3111
+ \t};
3112
+ }
3113
+
3114
+ function toInvite(row: InviteRow): WorkspaceInvite {
3115
+ \treturn {
3116
+ \t\tid: row.id,
3117
+ \t\tworkspaceId: row.workspaceId,
3118
+ \t\temail: row.email,
3119
+ \t\trole: row.role,
3120
+ \t\ttoken: row.token,
3121
+ \t\tinvitedByUserId: row.invitedByUserId,
3122
+ \t\tstatus: row.status,
3123
+ \t\texpiresAt: row.expiresAt,
3124
+ \t\tacceptedAt: row.acceptedAt,
3125
+ \t\tcreatedAt: row.createdAt,
3126
+ \t};
3127
+ }
3128
+
3129
+ function membershipWhere(lookup: WorkspaceMembershipLookup) {
3130
+ \treturn and(
3131
+ \t\teq(schema.workspaceMembers.workspaceId, lookup.workspaceId),
3132
+ \t\teq(schema.workspaceMembers.userId, lookup.userId),
3133
+ \t);
3134
+ }
3135
+
3136
+ export function createDrizzleWorkspaceRepository(
3137
+ \tdb: ${dialect.dbTypeName}<typeof schema>,
3138
+ ): WorkspaceRepository {
3139
+ \treturn {
3140
+ \t\tasync findById(id) {
3141
+ \t\t\tconst [row] = await db
3142
+ \t\t\t\t.select()
3143
+ \t\t\t\t.from(schema.workspaces)
3144
+ \t\t\t\t.where(eq(schema.workspaces.id, id))
3145
+ \t\t\t\t.limit(1);
3146
+
3147
+ \t\t\treturn row ? toWorkspace(row) : null;
3148
+ \t\t},
3149
+
3150
+ \t\tasync findBySlug(slug) {
3151
+ \t\t\tconst [row] = await db
3152
+ \t\t\t\t.select()
3153
+ \t\t\t\t.from(schema.workspaces)
3154
+ \t\t\t\t.where(eq(schema.workspaces.slug, slug))
3155
+ \t\t\t\t.limit(1);
3156
+
3157
+ \t\t\treturn row ? toWorkspace(row) : null;
3158
+ \t\t},
3159
+
3160
+ \t\tasync findManyByIds(ids) {
3161
+ \t\t\tif (ids.length === 0) return [];
3162
+
3163
+ \t\t\tconst rows = await db
3164
+ \t\t\t\t.select()
3165
+ \t\t\t\t.from(schema.workspaces)
3166
+ \t\t\t\t.where(inArray(schema.workspaces.id, ids));
3167
+
3168
+ \t\t\treturn rows.map(toWorkspace);
3169
+ \t\t},
3170
+
3171
+ \t\tasync create(data) {
3172
+ \t\t\tconst now = new Date().toISOString();
3173
+ \t\t\tconst values = {
3174
+ \t\t\t\tid: crypto.randomUUID(),
3175
+ \t\t\t\tname: data.name,
3176
+ \t\t\t\tslug: data.slug,
3177
+ \t\t\t\tcreatedAt: now,
3178
+ \t\t\t\tupdatedAt: now,
3179
+ \t\t\t};
3180
+
3181
+ ${workspaceCreate}
3182
+ \t\t},
3183
+
3184
+ \t\tasync update(id, data) {
3185
+ \t\t\tconst updates: Partial<typeof schema.workspaces.$inferInsert> = {
3186
+ \t\t\t\tupdatedAt: new Date().toISOString(),
3187
+ \t\t\t};
3188
+ \t\t\tif (data.name !== undefined) updates.name = data.name;
3189
+ \t\t\tif (data.slug !== undefined) updates.slug = data.slug;
3190
+
3191
+ ${workspaceUpdate}
3192
+ \t\t},
3193
+ \t};
3194
+ }
3195
+
3196
+ export function createDrizzleWorkspaceMemberRepository(
3197
+ \tdb: ${dialect.dbTypeName}<typeof schema>,
3198
+ ): WorkspaceMemberRepository {
3199
+ \treturn {
3200
+ \t\tasync findMembership(lookup) {
3201
+ \t\t\tconst [row] = await db
3202
+ \t\t\t\t.select()
3203
+ \t\t\t\t.from(schema.workspaceMembers)
3204
+ \t\t\t\t.where(membershipWhere(lookup))
3205
+ \t\t\t\t.limit(1);
3206
+
3207
+ \t\t\treturn row ? toMember(row) : null;
3208
+ \t\t},
3209
+
3210
+ \t\tasync listByUser(userId) {
3211
+ \t\t\tconst rows = await db
3212
+ \t\t\t\t.select()
3213
+ \t\t\t\t.from(schema.workspaceMembers)
3214
+ \t\t\t\t.where(eq(schema.workspaceMembers.userId, userId))
3215
+ \t\t\t\t.orderBy(asc(schema.workspaceMembers.joinedAt));
3216
+
3217
+ \t\t\treturn rows.map(toMember);
3218
+ \t\t},
3219
+
3220
+ \t\tasync listByWorkspace(workspaceId) {
3221
+ \t\t\tconst rows = await db
3222
+ \t\t\t\t.select({
3223
+ \t\t\t\t\tmember: schema.workspaceMembers,
3224
+ \t\t\t\t\tuser: {
3225
+ \t\t\t\t\t\tid: schema.user.id,
3226
+ \t\t\t\t\t\tname: schema.user.name,
3227
+ \t\t\t\t\t\temail: schema.user.email,
3228
+ \t\t\t\t\t\timage: schema.user.image,
3229
+ \t\t\t\t\t},
3230
+ \t\t\t\t})
3231
+ \t\t\t\t.from(schema.workspaceMembers)
3232
+ \t\t\t\t.innerJoin(
3233
+ \t\t\t\t\tschema.user,
3234
+ \t\t\t\t\teq(schema.workspaceMembers.userId, schema.user.id),
3235
+ \t\t\t\t)
3236
+ \t\t\t\t.where(eq(schema.workspaceMembers.workspaceId, workspaceId))
3237
+ \t\t\t\t.orderBy(asc(schema.workspaceMembers.joinedAt));
3238
+
3239
+ \t\t\treturn rows.map(
3240
+ \t\t\t\t(row): WorkspaceMemberWithUser => ({
3241
+ \t\t\t\t\t...toMember(row.member),
3242
+ \t\t\t\t\tuser: {
3243
+ \t\t\t\t\t\tid: row.user.id,
3244
+ \t\t\t\t\t\tname: row.user.name,
3245
+ \t\t\t\t\t\temail: row.user.email,
3246
+ \t\t\t\t\t\timage: row.user.image,
3247
+ \t\t\t\t\t},
3248
+ \t\t\t\t}),
3249
+ \t\t\t);
3250
+ \t\t},
3251
+
3252
+ \t\tasync create(data) {
3253
+ \t\t\tconst values = {
3254
+ \t\t\t\tid: crypto.randomUUID(),
3255
+ \t\t\t\tworkspaceId: data.workspaceId,
3256
+ \t\t\t\tuserId: data.userId,
3257
+ \t\t\t\trole: data.role,
3258
+ \t\t\t\tjoinedAt: new Date().toISOString(),
3259
+ \t\t\t};
3260
+
3261
+ ${memberCreate}
3262
+ \t\t},
3263
+
3264
+ \t\tasync updateRole(lookup, role) {
3265
+ ${memberUpdateRole}
3266
+ \t\t},
3267
+
3268
+ \t\tasync remove(lookup) {
3269
+ ${memberRemove}
3270
+ \t\t},
3271
+
3272
+ \t\tasync countAdmins(workspaceId) {
3273
+ \t\t\tconst [row] = await db
3274
+ \t\t\t\t.select({ total: count() })
3275
+ \t\t\t\t.from(schema.workspaceMembers)
3276
+ \t\t\t\t.where(
3277
+ \t\t\t\t\tand(
3278
+ \t\t\t\t\t\teq(schema.workspaceMembers.workspaceId, workspaceId),
3279
+ \t\t\t\t\t\teq(schema.workspaceMembers.role, "admin"),
3280
+ \t\t\t\t\t),
3281
+ \t\t\t\t);
3282
+
3283
+ \t\t\treturn row?.total ?? 0;
3284
+ \t\t},
3285
+ \t};
3286
+ }
3287
+
3288
+ export function createDrizzleWorkspaceInviteRepository(
3289
+ \tdb: ${dialect.dbTypeName}<typeof schema>,
3290
+ ): WorkspaceInviteRepository {
3291
+ \treturn {
3292
+ \t\tasync findByToken(token) {
3293
+ \t\t\tconst [row] = await db
3294
+ \t\t\t\t.select()
3295
+ \t\t\t\t.from(schema.workspaceInvites)
3296
+ \t\t\t\t.where(eq(schema.workspaceInvites.token, token))
3297
+ \t\t\t\t.limit(1);
3298
+
3299
+ \t\t\treturn row ? toInvite(row) : null;
3300
+ \t\t},
3301
+
3302
+ \t\tasync listByWorkspace(workspaceId, status) {
3303
+ \t\t\tconst where = status
3304
+ \t\t\t\t? and(
3305
+ \t\t\t\t\t\teq(schema.workspaceInvites.workspaceId, workspaceId),
3306
+ \t\t\t\t\t\teq(schema.workspaceInvites.status, status),
3307
+ \t\t\t\t\t)
3308
+ \t\t\t\t: eq(schema.workspaceInvites.workspaceId, workspaceId);
3309
+
3310
+ \t\t\tconst rows = await db
3311
+ \t\t\t\t.select()
3312
+ \t\t\t\t.from(schema.workspaceInvites)
3313
+ \t\t\t\t.where(where)
3314
+ \t\t\t\t.orderBy(desc(schema.workspaceInvites.createdAt));
3315
+
3316
+ \t\t\treturn rows.map(toInvite);
3317
+ \t\t},
3318
+
3319
+ \t\tasync create(data) {
3320
+ \t\t\tconst values = {
3321
+ \t\t\t\tid: crypto.randomUUID(),
3322
+ \t\t\t\tworkspaceId: data.workspaceId,
3323
+ \t\t\t\temail: data.email,
3324
+ \t\t\t\trole: data.role,
3325
+ \t\t\t\ttoken: data.token,
3326
+ \t\t\t\tinvitedByUserId: data.invitedByUserId,
3327
+ \t\t\t\tstatus: "pending" as const,
3328
+ \t\t\t\texpiresAt: data.expiresAt,
3329
+ \t\t\t\tacceptedAt: null,
3330
+ \t\t\t\tcreatedAt: new Date().toISOString(),
3331
+ \t\t\t};
3332
+
3333
+ ${inviteCreate}
3334
+ \t\t},
3335
+
3336
+ \t\tasync updateStatus(id, status, acceptedAt) {
3337
+ ${inviteUpdateStatus}
3338
+ \t\t},
3339
+ \t};
3340
+ }
3341
+ `;
3342
+ }
3343
+ export function tenancyTenantHelperFile() {
3344
+ return `import {
3345
+ \ttype ActivityTenant,
3346
+ \ttype AuthRequestLike,
3347
+ \tcreateTenant,
3348
+ \tisUnboundPort,
3349
+ } from "@beignet/core/ports";
3350
+ import type { WorkspaceMembershipContext } from "@/features/workspaces/policy";
3351
+ import type { AppPorts } from "@/ports";
3352
+ import type { AuthSession } from "@/ports/auth";
3353
+
3354
+ /**
3355
+ * Cookie set by POST /api/workspaces/switch to pick the active workspace.
3356
+ */
3357
+ export const WORKSPACE_COOKIE_NAME = "beignet-workspace";
3358
+
3359
+ export type WorkspaceResolution = {
3360
+ \ttenant?: ActivityTenant;
3361
+ \tmembership: WorkspaceMembershipContext | null;
3362
+ };
3363
+
3364
+ export type ResolveRequestWorkspaceInput = {
3365
+ \tauth: AuthSession | null;
3366
+ \treq: AuthRequestLike;
3367
+ \tports: Pick<AppPorts, "members">;
3368
+ };
3369
+
3370
+ /**
3371
+ * Resolve the active workspace and membership for a request.
3372
+ *
3373
+ * Authenticated users resolve through their memberships, with the
3374
+ * \`beignet-workspace\` cookie choosing among them and the first membership as
3375
+ * the fallback. Anonymous requests and users with zero memberships get no
3376
+ * tenant so the UI can route to workspace creation.
3377
+ */
3378
+ export async function resolveRequestWorkspace({
3379
+ \tauth,
3380
+ \treq,
3381
+ \tports,
3382
+ }: ResolveRequestWorkspaceInput): Promise<WorkspaceResolution> {
3383
+ \tif (!auth) {
3384
+ \t\treturn { tenant: undefined, membership: null };
3385
+ \t}
3386
+
3387
+ \t// Focused test apps built on createTestPorts may leave the workspace ports
3388
+ \t// unbound; they resolve no workspace instead of failing on first use.
3389
+ \tif (isUnboundPort(ports.members)) {
3390
+ \t\treturn { tenant: undefined, membership: null };
3391
+ \t}
3392
+
3393
+ \tconst memberships = await ports.members.listByUser(auth.user.id);
3394
+ \tif (memberships.length === 0) {
3395
+ \t\treturn { tenant: undefined, membership: null };
3396
+ \t}
3397
+
3398
+ \tconst cookieWorkspaceId = readWorkspaceCookie(req);
3399
+ \tconst active =
3400
+ \t\tmemberships.find(
3401
+ \t\t\t(membership) => membership.workspaceId === cookieWorkspaceId,
3402
+ \t\t) ?? memberships[0];
3403
+ \tif (!active) {
3404
+ \t\treturn { tenant: undefined, membership: null };
3405
+ \t}
3406
+
3407
+ \treturn {
3408
+ \t\ttenant: createTenant(active.workspaceId),
3409
+ \t\tmembership: { workspaceId: active.workspaceId, role: active.role },
3410
+ \t};
3411
+ }
3412
+
3413
+ /**
3414
+ * Resolve a workspace for service work (schedules, tasks, outbox drains).
3415
+ * Service work either targets an explicit workspace or iterates workspaces
3416
+ * itself.
3417
+ */
3418
+ export function resolveServiceWorkspace(workspaceId: string | undefined): {
3419
+ \ttenant?: ActivityTenant;
3420
+ } {
3421
+ \tconst normalized = normalizeWorkspaceId(workspaceId);
3422
+ \treturn { tenant: normalized ? createTenant(normalized) : undefined };
3423
+ }
3424
+
3425
+ function readWorkspaceCookie(req: AuthRequestLike): string | undefined {
3426
+ \tconst header = req.headers.get("cookie");
3427
+ \tif (!header) return undefined;
3428
+
3429
+ \tfor (const part of header.split(";")) {
3430
+ \t\tconst separator = part.indexOf("=");
3431
+ \t\tif (separator === -1) continue;
3432
+ \t\tconst name = part.slice(0, separator).trim();
3433
+ \t\tif (name !== WORKSPACE_COOKIE_NAME) continue;
3434
+ \t\tconst value = part.slice(separator + 1).trim();
3435
+ \t\treturn normalizeWorkspaceId(decodeURIComponent(value));
3436
+ \t}
3437
+
3438
+ \treturn undefined;
3439
+ }
3440
+
3441
+ function normalizeWorkspaceId(value: unknown): string | undefined {
3442
+ \treturn typeof value === "string" && value.trim().length > 0
3443
+ \t\t? value.trim()
3444
+ \t\t: undefined;
3445
+ }
3446
+ `;
3447
+ }
3448
+ export function tenancyServerContextFile() {
3449
+ return `import "@beignet/core/server-only";
3450
+ import {
3451
+ \ttype ActivityActor,
3452
+ \tcreateAnonymousActor,
3453
+ \tcreateServiceActor,
3454
+ \tcreateUserActor,
3455
+ } from "@beignet/core/ports";
3456
+ import { defineServerContext } from "@beignet/core/server";
3457
+ import type { TraceContext } from "@beignet/core/tracing";
3458
+ import type { AppContext, AppRuntimePorts } from "@/app-context";
3459
+ import { resolveRequestWorkspace, resolveServiceWorkspace } from "@/lib/tenant";
3460
+
3461
+ export type AppServiceContextInput =
3462
+ \t| {
3463
+ \t\t\tactor?: ActivityActor;
3464
+ \t\t\ttenantId?: string;
3465
+ \t }
3466
+ \t| undefined;
3467
+
3468
+ /**
3469
+ * Context blueprint shared by the runtime server and route tests. The active
3470
+ * workspace becomes \`ctx.tenant\` and the actor's membership in it becomes
3471
+ * \`ctx.membership\`.
3472
+ */
3473
+ export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
3474
+ \tgate: (ports) => ports.gate,
3475
+ \trequest: async ({ req, ports, requestId, trace }) => {
3476
+ \t\tconst auth = await ports.auth.getSession(req);
3477
+ \t\tconst { tenant, membership } = await resolveRequestWorkspace({
3478
+ \t\t\tauth,
3479
+ \t\t\treq,
3480
+ \t\t\tports,
3481
+ \t\t});
3482
+
3483
+ \t\treturn {
3484
+ \t\t\trequestId,
3485
+ \t\t\tactor: auth
3486
+ \t\t\t\t? createUserActor(auth.user.id, {
3487
+ \t\t\t\t\t\tdisplayName: auth.user.name,
3488
+ \t\t\t\t\t\tmetadata: membership
3489
+ \t\t\t\t\t\t\t? { membershipRole: membership.role }
3490
+ \t\t\t\t\t\t\t: undefined,
3491
+ \t\t\t\t\t})
3492
+ \t\t\t\t: createAnonymousActor(),
3493
+ \t\t\tauth,
3494
+ \t\t\t...trace,
3495
+ \t\t\tports,
3496
+ \t\t\t...(tenant ? { tenant } : {}),
3497
+ \t\t\tmembership,
3498
+ \t\t};
3499
+ \t},
3500
+ \tservice: ({
3501
+ \t\tports,
3502
+ \t\tinput,
3503
+ \t\trequestId,
3504
+ \t\ttrace,
3505
+ \t}: {
3506
+ \t\tports: AppRuntimePorts;
3507
+ \t\tinput: AppServiceContextInput;
3508
+ \t\trequestId: string;
3509
+ \t\ttrace: TraceContext;
3510
+ \t}) => {
3511
+ \t\tconst { tenant } = resolveServiceWorkspace(input?.tenantId);
3512
+
3513
+ \t\treturn {
3514
+ \t\t\trequestId,
3515
+ \t\t\tactor: input?.actor ?? createServiceActor("beignet-service"),
3516
+ \t\t\tauth: null,
3517
+ \t\t\t...trace,
3518
+ \t\t\tports,
3519
+ \t\t\t...(tenant ? { tenant } : {}),
3520
+ \t\t\tmembership: null,
3521
+ \t\t};
3522
+ \t},
3523
+ });
3524
+ `;
3525
+ }
3526
+ export function workspaceClientQueriesFile() {
3527
+ return `import type { QueryClient } from "@tanstack/react-query";
3528
+ import { rq } from "@/client";
3529
+ import {
3530
+ \tacceptInvite,
3531
+ \tcreateInvite,
3532
+ \tcreateWorkspace,
3533
+ \tgetCurrentWorkspace,
3534
+ \tlistInvites,
3535
+ \tlistMembers,
3536
+ \tlistMyWorkspaces,
3537
+ \tremoveMember,
3538
+ \trevokeInvite,
3539
+ \tswitchWorkspace,
3540
+ \tupdateMemberRole,
3541
+ \tupdateWorkspace,
3542
+ } from "@/features/workspaces/contracts";
3543
+
3544
+ export function currentWorkspaceQueryOptions() {
3545
+ \treturn rq(getCurrentWorkspace).queryOptions();
3546
+ }
3547
+
3548
+ export function myWorkspacesQueryOptions() {
3549
+ \treturn rq(listMyWorkspaces).queryOptions();
3550
+ }
3551
+
3552
+ export function membersQueryOptions(options?: { enabled?: boolean }) {
3553
+ \treturn rq(listMembers).queryOptions(options);
3554
+ }
3555
+
3556
+ export function invitesQueryOptions(options?: { enabled?: boolean }) {
3557
+ \treturn rq(listInvites).queryOptions(options);
3558
+ }
3559
+
3560
+ export function createWorkspaceMutationOptions() {
3561
+ \treturn rq(createWorkspace).mutationOptions();
3562
+ }
3563
+
3564
+ export function updateWorkspaceMutationOptions() {
3565
+ \treturn rq(updateWorkspace).mutationOptions();
3566
+ }
3567
+
3568
+ export function switchWorkspaceMutationOptions() {
3569
+ \treturn rq(switchWorkspace).mutationOptions();
3570
+ }
3571
+
3572
+ export function updateMemberRoleMutationOptions() {
3573
+ \treturn rq(updateMemberRole).mutationOptions();
3574
+ }
3575
+
3576
+ export function removeMemberMutationOptions() {
3577
+ \treturn rq(removeMember).mutationOptions();
3578
+ }
3579
+
3580
+ export function createInviteMutationOptions() {
3581
+ \treturn rq(createInvite).mutationOptions();
3582
+ }
3583
+
3584
+ export function revokeInviteMutationOptions() {
3585
+ \treturn rq(revokeInvite).mutationOptions();
3586
+ }
3587
+
3588
+ export function acceptInviteMutationOptions() {
3589
+ \treturn rq(acceptInvite).mutationOptions();
3590
+ }
3591
+
3592
+ export function invalidateWorkspaces(queryClient: QueryClient) {
3593
+ \treturn Promise.all([
3594
+ \t\trq(getCurrentWorkspace).invalidate(queryClient),
3595
+ \t\trq(listMyWorkspaces).invalidate(queryClient),
3596
+ \t]);
3597
+ }
3598
+
3599
+ export function invalidateMembers(queryClient: QueryClient) {
3600
+ \treturn rq(listMembers).invalidate(queryClient);
3601
+ }
3602
+
3603
+ export function invalidateInvites(queryClient: QueryClient) {
3604
+ \treturn rq(listInvites).invalidate(queryClient);
3605
+ }
3606
+ `;
3607
+ }
3608
+ export function workspaceSwitcherComponentFile() {
3609
+ return `"use client";
3610
+
3611
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3612
+ import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
3613
+ import { useRouter } from "next/navigation";
3614
+ import {
3615
+ \tDropdownMenu,
3616
+ \tDropdownMenuContent,
3617
+ \tDropdownMenuItem,
3618
+ \tDropdownMenuTrigger,
3619
+ } from "@/components/ui/dropdown-menu";
3620
+ import {
3621
+ \tcurrentWorkspaceQueryOptions,
3622
+ \tmyWorkspacesQueryOptions,
3623
+ \tswitchWorkspaceMutationOptions,
3624
+ } from "@/features/workspaces/client/queries";
3625
+ import { cn } from "@/lib/utils";
3626
+
3627
+ function WorkspaceMark({ name }: { name: string }) {
3628
+ \treturn (
3629
+ \t\t<span className="inline-flex size-6 shrink-0 items-center justify-center rounded bg-primary font-bold text-[11px] text-primary-foreground">
3630
+ \t\t\t{(name || "?").slice(0, 1).toUpperCase()}
3631
+ \t\t</span>
3632
+ \t);
3633
+ }
3634
+
3635
+ /**
3636
+ * Lists the actor's workspaces and switches the active one through
3637
+ * POST /api/workspaces/switch, which sets the \`beignet-workspace\` cookie.
3638
+ */
3639
+ export function WorkspaceSwitcher() {
3640
+ \tconst router = useRouter();
3641
+ \tconst queryClient = useQueryClient();
3642
+ \tconst currentQuery = useQuery(currentWorkspaceQueryOptions());
3643
+ \tconst workspacesQuery = useQuery(myWorkspacesQueryOptions());
3644
+
3645
+ \tconst switchMutation = useMutation({
3646
+ \t\t...switchWorkspaceMutationOptions(),
3647
+ \t\tonSuccess: async () => {
3648
+ \t\t\tawait queryClient.invalidateQueries();
3649
+ \t\t\trouter.refresh();
3650
+ \t\t},
3651
+ \t});
3652
+
3653
+ \tconst current = currentQuery.data?.workspace ?? null;
3654
+ \tconst workspaces = workspacesQuery.data?.items ?? [];
3655
+
3656
+ \treturn (
3657
+ \t\t<DropdownMenu>
3658
+ \t\t\t<DropdownMenuTrigger
3659
+ \t\t\t\tclassName="flex h-9 w-full items-center gap-2 rounded-md border px-2 text-left text-sm font-medium hover:bg-muted"
3660
+ \t\t\t\taria-label="Switch workspace"
3661
+ \t\t\t\tdisabled={switchMutation.isPending}
3662
+ \t\t\t>
3663
+ \t\t\t\t<WorkspaceMark name={current?.name ?? "?"} />
3664
+ \t\t\t\t<span className="flex-1 truncate">
3665
+ \t\t\t\t\t{current?.name ?? "Select workspace"}
3666
+ \t\t\t\t</span>
3667
+ \t\t\t\t<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
3668
+ \t\t\t</DropdownMenuTrigger>
3669
+ \t\t\t<DropdownMenuContent align="start" className="w-56">
3670
+ \t\t\t\t<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
3671
+ \t\t\t\t\tWorkspaces
3672
+ \t\t\t\t</p>
3673
+ \t\t\t\t{workspaces.map((workspace) => (
3674
+ \t\t\t\t\t<DropdownMenuItem
3675
+ \t\t\t\t\t\tkey={workspace.id}
3676
+ \t\t\t\t\t\tonSelect={() => {
3677
+ \t\t\t\t\t\t\tif (workspace.id === current?.id) return;
3678
+ \t\t\t\t\t\t\tswitchMutation.mutate({
3679
+ \t\t\t\t\t\t\t\tbody: { workspaceId: workspace.id },
3680
+ \t\t\t\t\t\t\t});
3681
+ \t\t\t\t\t\t}}
3682
+ \t\t\t\t\t>
3683
+ \t\t\t\t\t\t<WorkspaceMark name={workspace.name} />
3684
+ \t\t\t\t\t\t<span className="flex-1 truncate">{workspace.name}</span>
3685
+ \t\t\t\t\t\t<CheckIcon
3686
+ \t\t\t\t\t\t\tclassName={cn(
3687
+ \t\t\t\t\t\t\t\t"size-4",
3688
+ \t\t\t\t\t\t\t\tworkspace.id === current?.id ? "opacity-100" : "opacity-0",
3689
+ \t\t\t\t\t\t\t)}
3690
+ \t\t\t\t\t\t/>
3691
+ \t\t\t\t\t</DropdownMenuItem>
3692
+ \t\t\t\t))}
3693
+ \t\t\t\t{workspaces.length === 0 ? (
3694
+ \t\t\t\t\t<DropdownMenuItem disabled>No workspaces yet</DropdownMenuItem>
3695
+ \t\t\t\t) : null}
3696
+ \t\t\t</DropdownMenuContent>
3697
+ \t\t</DropdownMenu>
3698
+ \t);
3699
+ }
3700
+ `;
3701
+ }
3702
+ export function workspaceSettingsFormComponentFile() {
3703
+ return `"use client";
3704
+
3705
+ import { rootFormError } from "@beignet/react-hook-form";
3706
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3707
+ import { useRouter } from "next/navigation";
3708
+ import { rhf } from "@/client/forms";
3709
+ import { Button } from "@/components/ui/button";
3710
+ import {
3711
+ \tCard,
3712
+ \tCardContent,
3713
+ \tCardDescription,
3714
+ \tCardHeader,
3715
+ \tCardTitle,
3716
+ } from "@/components/ui/card";
3717
+ import { Input } from "@/components/ui/input";
3718
+ import { Label } from "@/components/ui/label";
3719
+ import {
3720
+ \tcreateWorkspaceMutationOptions,
3721
+ \tcurrentWorkspaceQueryOptions,
3722
+ \tinvalidateWorkspaces,
3723
+ \tupdateWorkspaceMutationOptions,
3724
+ } from "@/features/workspaces/client/queries";
3725
+ import { createWorkspace, updateWorkspace } from "@/features/workspaces/contracts";
3726
+
3727
+ const createWorkspaceForm = rhf(createWorkspace);
3728
+ const updateWorkspaceForm = rhf(updateWorkspace);
3729
+
3730
+ function CreateWorkspaceCard() {
3731
+ \tconst router = useRouter();
3732
+ \tconst queryClient = useQueryClient();
3733
+ \tconst form = createWorkspaceForm.useForm({
3734
+ \t\tdefaultValues: { name: "", slug: "" },
3735
+ \t});
3736
+
3737
+ \tconst createMutation = useMutation({
3738
+ \t\t...createWorkspaceMutationOptions(),
3739
+ \t\tonSuccess: async () => {
3740
+ \t\t\tawait invalidateWorkspaces(queryClient);
3741
+ \t\t\trouter.refresh();
3742
+ \t\t},
3743
+ \t\tonError: (error) => {
3744
+ \t\t\tform.setError(
3745
+ \t\t\t\t"root",
3746
+ \t\t\t\trootFormError(error, "Could not create the workspace."),
3747
+ \t\t\t);
3748
+ \t\t},
3749
+ \t});
3750
+
3751
+ \tconst onSubmit = form.handleSubmit((body) => {
3752
+ \t\tform.clearErrors("root");
3753
+ \t\tcreateMutation.mutate({ idempotencyKey: crypto.randomUUID(), body });
3754
+ \t});
3755
+
3756
+ \treturn (
3757
+ \t\t<Card>
3758
+ \t\t\t<CardHeader>
3759
+ \t\t\t\t<CardTitle>Create a workspace</CardTitle>
3760
+ \t\t\t\t<CardDescription>
3761
+ \t\t\t\t\tYou have no active workspace yet. Create one to get started.
3762
+ \t\t\t\t</CardDescription>
3763
+ \t\t\t</CardHeader>
3764
+ \t\t\t<CardContent>
3765
+ \t\t\t\t<form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
3766
+ \t\t\t\t\t<div className="flex flex-col gap-2">
3767
+ \t\t\t\t\t\t<Label htmlFor="workspace-name">Name</Label>
3768
+ \t\t\t\t\t\t<Input
3769
+ \t\t\t\t\t\t\tid="workspace-name"
3770
+ \t\t\t\t\t\t\tplaceholder="Acme"
3771
+ \t\t\t\t\t\t\trequired
3772
+ \t\t\t\t\t\t\t{...form.register("name")}
3773
+ \t\t\t\t\t\t/>
3774
+ \t\t\t\t\t</div>
3775
+ \t\t\t\t\t<div className="flex flex-col gap-2">
3776
+ \t\t\t\t\t\t<Label htmlFor="workspace-slug">Slug</Label>
3777
+ \t\t\t\t\t\t<Input
3778
+ \t\t\t\t\t\t\tid="workspace-slug"
3779
+ \t\t\t\t\t\t\tplaceholder="acme"
3780
+ \t\t\t\t\t\t\trequired
3781
+ \t\t\t\t\t\t\t{...form.register("slug")}
3782
+ \t\t\t\t\t\t/>
3783
+ \t\t\t\t\t\t{form.formState.errors.slug ? (
3784
+ \t\t\t\t\t\t\t<p className="text-sm text-destructive">
3785
+ \t\t\t\t\t\t\t\t{form.formState.errors.slug.message}
3786
+ \t\t\t\t\t\t\t</p>
3787
+ \t\t\t\t\t\t) : null}
3788
+ \t\t\t\t\t</div>
3789
+ \t\t\t\t\t{form.formState.errors.root ? (
3790
+ \t\t\t\t\t\t<p className="text-sm text-destructive">
3791
+ \t\t\t\t\t\t\t{form.formState.errors.root.message}
3792
+ \t\t\t\t\t\t</p>
3793
+ \t\t\t\t\t) : null}
3794
+ \t\t\t\t\t<Button type="submit" disabled={createMutation.isPending}>
3795
+ \t\t\t\t\t\t{createMutation.isPending ? "Creating..." : "Create workspace"}
3796
+ \t\t\t\t\t</Button>
3797
+ \t\t\t\t</form>
3798
+ \t\t\t</CardContent>
3799
+ \t\t</Card>
3800
+ \t);
3801
+ }
3802
+
3803
+ export function WorkspaceSettingsForm() {
3804
+ \tconst router = useRouter();
3805
+ \tconst queryClient = useQueryClient();
3806
+ \tconst currentQuery = useQuery(currentWorkspaceQueryOptions());
3807
+ \tconst workspace = currentQuery.data?.workspace ?? null;
3808
+ \tconst isAdmin = currentQuery.data?.membership?.role === "admin";
3809
+
3810
+ \tconst form = updateWorkspaceForm.useForm({
3811
+ \t\tvalues: {
3812
+ \t\t\tname: workspace?.name ?? "",
3813
+ \t\t\tslug: workspace?.slug ?? "",
3814
+ \t\t},
3815
+ \t});
3816
+
3817
+ \tconst updateMutation = useMutation({
3818
+ \t\t...updateWorkspaceMutationOptions(),
3819
+ \t\tonSuccess: async () => {
3820
+ \t\t\tawait invalidateWorkspaces(queryClient);
3821
+ \t\t\trouter.refresh();
3822
+ \t\t},
3823
+ \t\tonError: (error) => {
3824
+ \t\t\tform.setError(
3825
+ \t\t\t\t"root",
3826
+ \t\t\t\trootFormError(error, "Could not update the workspace."),
3827
+ \t\t\t);
3828
+ \t\t},
3829
+ \t});
3830
+
3831
+ \tif (currentQuery.isPending) {
3832
+ \t\treturn <p className="text-sm text-muted-foreground">Loading...</p>;
3833
+ \t}
3834
+
3835
+ \tif (!workspace) {
3836
+ \t\treturn <CreateWorkspaceCard />;
3837
+ \t}
3838
+
3839
+ \tif (!isAdmin) {
3840
+ \t\treturn (
3841
+ \t\t\t<Card>
3842
+ \t\t\t\t<CardHeader>
3843
+ \t\t\t\t\t<CardTitle>{workspace.name}</CardTitle>
3844
+ \t\t\t\t\t<CardDescription>
3845
+ \t\t\t\t\t\tOnly workspace admins can change workspace settings.
3846
+ \t\t\t\t\t</CardDescription>
3847
+ \t\t\t\t</CardHeader>
3848
+ \t\t\t</Card>
3849
+ \t\t);
3850
+ \t}
3851
+
3852
+ \tconst onSubmit = form.handleSubmit((body) => {
3853
+ \t\tform.clearErrors("root");
3854
+ \t\tupdateMutation.mutate({ body });
3855
+ \t});
3856
+
3857
+ \treturn (
3858
+ \t\t<Card>
3859
+ \t\t\t<CardHeader>
3860
+ \t\t\t\t<CardTitle>Workspace</CardTitle>
3861
+ \t\t\t\t<CardDescription>
3862
+ \t\t\t\t\tRename the workspace or change its slug.
3863
+ \t\t\t\t</CardDescription>
3864
+ \t\t\t</CardHeader>
3865
+ \t\t\t<CardContent>
3866
+ \t\t\t\t<form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
3867
+ \t\t\t\t\t<div className="flex flex-col gap-2">
3868
+ \t\t\t\t\t\t<Label htmlFor="workspace-name">Name</Label>
3869
+ \t\t\t\t\t\t<Input id="workspace-name" {...form.register("name")} />
3870
+ \t\t\t\t\t\t{form.formState.errors.name ? (
3871
+ \t\t\t\t\t\t\t<p className="text-sm text-destructive">
3872
+ \t\t\t\t\t\t\t\t{form.formState.errors.name.message}
3873
+ \t\t\t\t\t\t\t</p>
3874
+ \t\t\t\t\t\t) : null}
3875
+ \t\t\t\t\t</div>
3876
+ \t\t\t\t\t<div className="flex flex-col gap-2">
3877
+ \t\t\t\t\t\t<Label htmlFor="workspace-slug">Slug</Label>
3878
+ \t\t\t\t\t\t<Input id="workspace-slug" {...form.register("slug")} />
3879
+ \t\t\t\t\t\t{form.formState.errors.slug ? (
3880
+ \t\t\t\t\t\t\t<p className="text-sm text-destructive">
3881
+ \t\t\t\t\t\t\t\t{form.formState.errors.slug.message}
3882
+ \t\t\t\t\t\t\t</p>
3883
+ \t\t\t\t\t\t) : null}
3884
+ \t\t\t\t\t</div>
3885
+ \t\t\t\t\t{form.formState.errors.root ? (
3886
+ \t\t\t\t\t\t<p className="text-sm text-destructive">
3887
+ \t\t\t\t\t\t\t{form.formState.errors.root.message}
3888
+ \t\t\t\t\t\t</p>
3889
+ \t\t\t\t\t) : null}
3890
+ \t\t\t\t\t<Button type="submit" disabled={updateMutation.isPending}>
3891
+ \t\t\t\t\t\t{updateMutation.isPending ? "Saving..." : "Save"}
3892
+ \t\t\t\t\t</Button>
3893
+ \t\t\t\t</form>
3894
+ \t\t\t</CardContent>
3895
+ \t\t</Card>
3896
+ \t);
3897
+ }
3898
+ `;
3899
+ }
3900
+ export function inviteFormComponentFile() {
3901
+ return `"use client";
3902
+
3903
+ import { rootFormError } from "@beignet/react-hook-form";
3904
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3905
+ import { rhf } from "@/client/forms";
3906
+ import { Button } from "@/components/ui/button";
3907
+ import { Input } from "@/components/ui/input";
3908
+ import { Label } from "@/components/ui/label";
3909
+ import {
3910
+ \tcreateInviteMutationOptions,
3911
+ \tinvalidateInvites,
3912
+ } from "@/features/workspaces/client/queries";
3913
+ import { createInvite } from "@/features/workspaces/contracts";
3914
+
3915
+ const createInviteForm = rhf(createInvite);
3916
+
3917
+ const roleSelectClassName =
3918
+ \t"h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs";
3919
+
3920
+ export function InviteForm() {
3921
+ \tconst queryClient = useQueryClient();
3922
+ \tconst form = createInviteForm.useForm({
3923
+ \t\tdefaultValues: { email: "", role: "member" },
3924
+ \t});
3925
+
3926
+ \tconst inviteMutation = useMutation({
3927
+ \t\t...createInviteMutationOptions(),
3928
+ \t\tonSuccess: async () => {
3929
+ \t\t\tform.reset({ email: "", role: "member" });
3930
+ \t\t\tawait invalidateInvites(queryClient);
3931
+ \t\t},
3932
+ \t\tonError: (error) => {
3933
+ \t\t\tform.setError("root", rootFormError(error, "Could not send the invite."));
3934
+ \t\t},
3935
+ \t});
3936
+
3937
+ \tconst onSubmit = form.handleSubmit((body) => {
3938
+ \t\tform.clearErrors("root");
3939
+ \t\tinviteMutation.mutate({ idempotencyKey: crypto.randomUUID(), body });
3940
+ \t});
3941
+
3942
+ \treturn (
3943
+ \t\t<form className="flex flex-col gap-2" onSubmit={onSubmit}>
3944
+ \t\t\t<div className="flex flex-wrap items-end gap-2">
3945
+ \t\t\t\t<div className="flex min-w-52 flex-1 flex-col gap-2">
3946
+ \t\t\t\t\t<Label htmlFor="invite-email">Invite by email</Label>
3947
+ \t\t\t\t\t<Input
3948
+ \t\t\t\t\t\tid="invite-email"
3949
+ \t\t\t\t\t\ttype="email"
3950
+ \t\t\t\t\t\tplaceholder="teammate@example.com"
3951
+ \t\t\t\t\t\trequired
3952
+ \t\t\t\t\t\t{...form.register("email")}
3953
+ \t\t\t\t\t/>
3954
+ \t\t\t\t</div>
3955
+ \t\t\t\t<select
3956
+ \t\t\t\t\tclassName={roleSelectClassName}
3957
+ \t\t\t\t\taria-label="Invite role"
3958
+ \t\t\t\t\t{...form.register("role")}
3959
+ \t\t\t\t>
3960
+ \t\t\t\t\t<option value="member">Member</option>
3961
+ \t\t\t\t\t<option value="admin">Admin</option>
3962
+ \t\t\t\t</select>
3963
+ \t\t\t\t<Button type="submit" disabled={inviteMutation.isPending}>
3964
+ \t\t\t\t\t{inviteMutation.isPending ? "Sending..." : "Send invite"}
3965
+ \t\t\t\t</Button>
3966
+ \t\t\t</div>
3967
+ \t\t\t{form.formState.errors.email ? (
3968
+ \t\t\t\t<p className="text-sm text-destructive">
3969
+ \t\t\t\t\t{form.formState.errors.email.message}
3970
+ \t\t\t\t</p>
3971
+ \t\t\t) : null}
3972
+ \t\t\t{form.formState.errors.root ? (
3973
+ \t\t\t\t<p className="text-sm text-destructive">
3974
+ \t\t\t\t\t{form.formState.errors.root.message}
3975
+ \t\t\t\t</p>
3976
+ \t\t\t) : null}
3977
+ \t\t</form>
3978
+ \t);
3979
+ }
3980
+ `;
3981
+ }
3982
+ export function membersSettingsComponentFile() {
3983
+ return `"use client";
3984
+
3985
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3986
+ import { useState } from "react";
3987
+ import { authClient } from "@/client/auth-client";
3988
+ import { Button } from "@/components/ui/button";
3989
+ import {
3990
+ \tCard,
3991
+ \tCardContent,
3992
+ \tCardHeader,
3993
+ \tCardTitle,
3994
+ } from "@/components/ui/card";
3995
+ import { Separator } from "@/components/ui/separator";
3996
+ import {
3997
+ \tcurrentWorkspaceQueryOptions,
3998
+ \tinvalidateInvites,
3999
+ \tinvalidateMembers,
4000
+ \tinvitesQueryOptions,
4001
+ \tmembersQueryOptions,
4002
+ \tremoveMemberMutationOptions,
4003
+ \trevokeInviteMutationOptions,
4004
+ \tupdateMemberRoleMutationOptions,
4005
+ } from "@/features/workspaces/client/queries";
4006
+ import { InviteForm } from "@/features/workspaces/components/invite-form";
4007
+ import type { WorkspaceMemberWithUser } from "@/features/workspaces/schemas";
4008
+
4009
+ const roleSelectClassName =
4010
+ \t"h-8 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs";
4011
+
4012
+ function errorMessage(error: unknown, fallback: string): string {
4013
+ \tif (error && typeof error === "object" && "message" in error) {
4014
+ \t\tconst message = (error as { message?: unknown }).message;
4015
+ \t\tif (typeof message === "string" && message.length > 0) return message;
4016
+ \t}
4017
+ \treturn fallback;
4018
+ }
4019
+
4020
+ function MemberRow({
4021
+ \tmember,
4022
+ \tisSelf,
4023
+ \tcanManage,
4024
+ \tonError,
4025
+ }: {
4026
+ \tmember: WorkspaceMemberWithUser;
4027
+ \tisSelf: boolean;
4028
+ \tcanManage: boolean;
4029
+ \tonError: (message: string) => void;
4030
+ }) {
4031
+ \tconst queryClient = useQueryClient();
4032
+
4033
+ \tconst roleMutation = useMutation({
4034
+ \t\t...updateMemberRoleMutationOptions(),
4035
+ \t\tonSuccess: async () => {
4036
+ \t\t\tawait invalidateMembers(queryClient);
4037
+ \t\t},
4038
+ \t\tonError: (error) =>
4039
+ \t\t\tonError(errorMessage(error, "Could not change the member role.")),
4040
+ \t});
4041
+ \tconst removeMutation = useMutation({
4042
+ \t\t...removeMemberMutationOptions(),
4043
+ \t\tonSuccess: async () => {
4044
+ \t\t\tawait invalidateMembers(queryClient);
4045
+ \t\t},
4046
+ \t\tonError: (error) =>
4047
+ \t\t\tonError(errorMessage(error, "Could not remove the member.")),
4048
+ \t});
4049
+
4050
+ \treturn (
4051
+ \t\t<div className="flex flex-wrap items-center gap-3 py-2">
4052
+ \t\t\t<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
4053
+ \t\t\t\t{(member.user.name || member.user.email || "?")
4054
+ \t\t\t\t\t.slice(0, 1)
4055
+ \t\t\t\t\t.toUpperCase()}
4056
+ \t\t\t</span>
4057
+ \t\t\t<div className="min-w-0 flex-1">
4058
+ \t\t\t\t<p className="truncate text-sm font-medium">
4059
+ \t\t\t\t\t{member.user.name}
4060
+ \t\t\t\t\t{isSelf ? (
4061
+ \t\t\t\t\t\t<span className="text-xs text-muted-foreground"> (you)</span>
4062
+ \t\t\t\t\t) : null}
4063
+ \t\t\t\t</p>
4064
+ \t\t\t\t<p className="truncate text-xs text-muted-foreground">
4065
+ \t\t\t\t\t{member.user.email}
4066
+ \t\t\t\t</p>
4067
+ \t\t\t</div>
4068
+ \t\t\t{canManage ? (
4069
+ \t\t\t\t<>
4070
+ \t\t\t\t\t<select
4071
+ \t\t\t\t\t\tclassName={roleSelectClassName}
4072
+ \t\t\t\t\t\taria-label="Member role"
4073
+ \t\t\t\t\t\tvalue={member.role}
4074
+ \t\t\t\t\t\tdisabled={roleMutation.isPending}
4075
+ \t\t\t\t\t\tonChange={(event) => {
4076
+ \t\t\t\t\t\t\tconst role =
4077
+ \t\t\t\t\t\t\t\tevent.currentTarget.value === "admin" ? "admin" : "member";
4078
+ \t\t\t\t\t\t\tif (role === member.role) return;
4079
+ \t\t\t\t\t\t\troleMutation.mutate({
4080
+ \t\t\t\t\t\t\t\tpath: { userId: member.userId },
4081
+ \t\t\t\t\t\t\t\tbody: { role },
4082
+ \t\t\t\t\t\t\t});
4083
+ \t\t\t\t\t\t}}
4084
+ \t\t\t\t\t>
4085
+ \t\t\t\t\t\t<option value="member">Member</option>
4086
+ \t\t\t\t\t\t<option value="admin">Admin</option>
4087
+ \t\t\t\t\t</select>
4088
+ \t\t\t\t\t<Button
4089
+ \t\t\t\t\t\ttype="button"
4090
+ \t\t\t\t\t\tvariant="destructive"
4091
+ \t\t\t\t\t\tsize="sm"
4092
+ \t\t\t\t\t\tdisabled={removeMutation.isPending}
4093
+ \t\t\t\t\t\tonClick={() =>
4094
+ \t\t\t\t\t\t\tremoveMutation.mutate({ path: { userId: member.userId } })
4095
+ \t\t\t\t\t\t}
4096
+ \t\t\t\t\t>
4097
+ \t\t\t\t\t\tRemove
4098
+ \t\t\t\t\t</Button>
4099
+ \t\t\t\t</>
4100
+ \t\t\t) : (
4101
+ \t\t\t\t<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium capitalize">
4102
+ \t\t\t\t\t{member.role}
4103
+ \t\t\t\t</span>
4104
+ \t\t\t)}
4105
+ \t\t</div>
4106
+ \t);
4107
+ }
4108
+
4109
+ export function MembersSettings() {
4110
+ \tconst session = authClient.useSession();
4111
+ \tconst queryClient = useQueryClient();
4112
+ \tconst [actionError, setActionError] = useState<string | null>(null);
4113
+
4114
+ \tconst currentQuery = useQuery(currentWorkspaceQueryOptions());
4115
+ \tconst isAdmin = currentQuery.data?.membership?.role === "admin";
4116
+ \tconst hasWorkspace = Boolean(currentQuery.data?.workspace);
4117
+ \tconst currentUserId = session.data?.user.id;
4118
+
4119
+ \tconst membersQuery = useQuery(membersQueryOptions({ enabled: hasWorkspace }));
4120
+ \tconst invitesQuery = useQuery(
4121
+ \t\tinvitesQueryOptions({ enabled: hasWorkspace && isAdmin }),
4122
+ \t);
4123
+
4124
+ \tconst revokeMutation = useMutation({
4125
+ \t\t...revokeInviteMutationOptions(),
4126
+ \t\tonSuccess: async () => {
4127
+ \t\t\tawait invalidateInvites(queryClient);
4128
+ \t\t},
4129
+ \t\tonError: (error) =>
4130
+ \t\t\tsetActionError(errorMessage(error, "Could not revoke the invite.")),
4131
+ \t});
4132
+
4133
+ \tif (currentQuery.isPending) {
4134
+ \t\treturn <p className="text-sm text-muted-foreground">Loading...</p>;
4135
+ \t}
4136
+
4137
+ \tif (!hasWorkspace || !currentUserId) {
4138
+ \t\treturn (
4139
+ \t\t\t<p className="text-sm text-muted-foreground">
4140
+ \t\t\t\tCreate or pick a workspace to manage members.
4141
+ \t\t\t</p>
4142
+ \t\t);
4143
+ \t}
4144
+
4145
+ \tconst invites = invitesQuery.data?.items ?? [];
4146
+ \tconst pendingInvites = invites.filter(
4147
+ \t\t(invite) => invite.status === "pending",
4148
+ \t);
4149
+
4150
+ \treturn (
4151
+ \t\t<div className="flex max-w-2xl flex-col gap-4">
4152
+ \t\t\t<Card>
4153
+ \t\t\t\t<CardHeader>
4154
+ \t\t\t\t\t<CardTitle>Members</CardTitle>
4155
+ \t\t\t\t</CardHeader>
4156
+ \t\t\t\t<CardContent>
4157
+ \t\t\t\t\t{membersQuery.isPending ? (
4158
+ \t\t\t\t\t\t<p className="text-sm text-muted-foreground">
4159
+ \t\t\t\t\t\t\tLoading members...
4160
+ \t\t\t\t\t\t</p>
4161
+ \t\t\t\t\t) : null}
4162
+ \t\t\t\t\t{membersQuery.isError ? (
4163
+ \t\t\t\t\t\t<p className="text-sm text-destructive">
4164
+ \t\t\t\t\t\t\tCould not load members.
4165
+ \t\t\t\t\t\t</p>
4166
+ \t\t\t\t\t) : null}
4167
+ \t\t\t\t\t<div className="flex flex-col divide-y divide-border">
4168
+ \t\t\t\t\t\t{membersQuery.data?.items.map((member) => (
4169
+ \t\t\t\t\t\t\t<MemberRow
4170
+ \t\t\t\t\t\t\t\tkey={member.id}
4171
+ \t\t\t\t\t\t\t\tmember={member}
4172
+ \t\t\t\t\t\t\t\tisSelf={member.userId === currentUserId}
4173
+ \t\t\t\t\t\t\t\tcanManage={isAdmin}
4174
+ \t\t\t\t\t\t\t\tonError={setActionError}
4175
+ \t\t\t\t\t\t\t/>
4176
+ \t\t\t\t\t\t))}
4177
+ \t\t\t\t\t</div>
4178
+ \t\t\t\t\t{actionError ? (
4179
+ \t\t\t\t\t\t<p className="mt-2 text-sm text-destructive">{actionError}</p>
4180
+ \t\t\t\t\t) : null}
4181
+ \t\t\t\t</CardContent>
4182
+ \t\t\t</Card>
4183
+
4184
+ \t\t\t{isAdmin ? (
4185
+ \t\t\t\t<Card>
4186
+ \t\t\t\t\t<CardHeader>
4187
+ \t\t\t\t\t\t<CardTitle>Invites</CardTitle>
4188
+ \t\t\t\t\t</CardHeader>
4189
+ \t\t\t\t\t<CardContent className="flex flex-col gap-3">
4190
+ \t\t\t\t\t\t<InviteForm />
4191
+ \t\t\t\t\t\t<Separator />
4192
+ \t\t\t\t\t\t{invitesQuery.isPending ? (
4193
+ \t\t\t\t\t\t\t<p className="text-sm text-muted-foreground">
4194
+ \t\t\t\t\t\t\t\tLoading invites...
4195
+ \t\t\t\t\t\t\t</p>
4196
+ \t\t\t\t\t\t) : null}
4197
+ \t\t\t\t\t\t{pendingInvites.length === 0 && !invitesQuery.isPending ? (
4198
+ \t\t\t\t\t\t\t<p className="text-sm text-muted-foreground">
4199
+ \t\t\t\t\t\t\t\tNo pending invites.
4200
+ \t\t\t\t\t\t\t</p>
4201
+ \t\t\t\t\t\t) : null}
4202
+ \t\t\t\t\t\t{pendingInvites.map((invite) => (
4203
+ \t\t\t\t\t\t\t<div
4204
+ \t\t\t\t\t\t\t\tkey={invite.id}
4205
+ \t\t\t\t\t\t\t\tclassName="flex flex-wrap items-center gap-3"
4206
+ \t\t\t\t\t\t\t>
4207
+ \t\t\t\t\t\t\t\t<div className="min-w-0 flex-1">
4208
+ \t\t\t\t\t\t\t\t\t<p className="truncate text-sm font-medium">
4209
+ \t\t\t\t\t\t\t\t\t\t{invite.email}
4210
+ \t\t\t\t\t\t\t\t\t</p>
4211
+ \t\t\t\t\t\t\t\t\t<p className="text-xs text-muted-foreground">
4212
+ \t\t\t\t\t\t\t\t\t\t{invite.role} - expires{" "}
4213
+ \t\t\t\t\t\t\t\t\t\t{new Date(invite.expiresAt).toLocaleDateString()}
4214
+ \t\t\t\t\t\t\t\t\t</p>
4215
+ \t\t\t\t\t\t\t\t</div>
4216
+ \t\t\t\t\t\t\t\t<Button
4217
+ \t\t\t\t\t\t\t\t\ttype="button"
4218
+ \t\t\t\t\t\t\t\t\tvariant="outline"
4219
+ \t\t\t\t\t\t\t\t\tsize="sm"
4220
+ \t\t\t\t\t\t\t\t\tdisabled={revokeMutation.isPending}
4221
+ \t\t\t\t\t\t\t\t\tonClick={() =>
4222
+ \t\t\t\t\t\t\t\t\t\trevokeMutation.mutate({
4223
+ \t\t\t\t\t\t\t\t\t\t\tpath: { inviteId: invite.id },
4224
+ \t\t\t\t\t\t\t\t\t\t})
4225
+ \t\t\t\t\t\t\t\t\t}
4226
+ \t\t\t\t\t\t\t\t>
4227
+ \t\t\t\t\t\t\t\t\tRevoke
4228
+ \t\t\t\t\t\t\t\t</Button>
4229
+ \t\t\t\t\t\t\t</div>
4230
+ \t\t\t\t\t\t))}
4231
+ \t\t\t\t\t</CardContent>
4232
+ \t\t\t\t</Card>
4233
+ \t\t\t) : (
4234
+ \t\t\t\t<p className="text-sm text-muted-foreground">
4235
+ \t\t\t\t\tOnly workspace admins can manage roles and invites.
4236
+ \t\t\t\t</p>
4237
+ \t\t\t)}
4238
+ \t\t</div>
4239
+ \t);
4240
+ }
4241
+ `;
4242
+ }
4243
+ export function inviteAcceptComponentFile() {
4244
+ return `"use client";
4245
+
4246
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4247
+ import Link from "next/link";
4248
+ import { useRouter } from "next/navigation";
4249
+ import { useState } from "react";
4250
+ import { authClient } from "@/client/auth-client";
4251
+ import { Button } from "@/components/ui/button";
4252
+ import {
4253
+ \tCard,
4254
+ \tCardContent,
4255
+ \tCardDescription,
4256
+ \tCardHeader,
4257
+ \tCardTitle,
4258
+ } from "@/components/ui/card";
4259
+ import {
4260
+ \tacceptInviteMutationOptions,
4261
+ \tinvalidateWorkspaces,
4262
+ \tswitchWorkspaceMutationOptions,
4263
+ } from "@/features/workspaces/client/queries";
4264
+
4265
+ function errorMessage(error: unknown, fallback: string): string {
4266
+ \tif (error && typeof error === "object" && "message" in error) {
4267
+ \t\tconst message = (error as { message?: unknown }).message;
4268
+ \t\tif (typeof message === "string" && message.length > 0) return message;
4269
+ \t}
4270
+ \treturn fallback;
4271
+ }
4272
+
4273
+ export function InviteAccept({ token }: { token: string }) {
4274
+ \tconst session = authClient.useSession();
4275
+ \tconst router = useRouter();
4276
+ \tconst queryClient = useQueryClient();
4277
+ \tconst [error, setError] = useState<string | null>(null);
4278
+
4279
+ \tconst switchMutation = useMutation({
4280
+ \t\t...switchWorkspaceMutationOptions(),
4281
+ \t\tonSettled: async () => {
4282
+ \t\t\tawait queryClient.invalidateQueries();
4283
+ \t\t\trouter.push("/dashboard");
4284
+ \t\t\trouter.refresh();
4285
+ \t\t},
4286
+ \t});
4287
+
4288
+ \tconst acceptMutation = useMutation({
4289
+ \t\t...acceptInviteMutationOptions(),
4290
+ \t\tonSuccess: async (result) => {
4291
+ \t\t\tawait invalidateWorkspaces(queryClient);
4292
+ \t\t\tswitchMutation.mutate({ body: { workspaceId: result.workspace.id } });
4293
+ \t\t},
4294
+ \t\tonError: (mutationError) =>
4295
+ \t\t\tsetError(errorMessage(mutationError, "Could not accept the invite.")),
4296
+ \t});
4297
+
4298
+ \tif (session.isPending) {
4299
+ \t\treturn (
4300
+ \t\t\t<main className="grid min-h-svh place-items-center p-6">
4301
+ \t\t\t\t<p className="text-sm text-muted-foreground">Loading...</p>
4302
+ \t\t\t</main>
4303
+ \t\t);
4304
+ \t}
4305
+
4306
+ \tif (!session.data?.user) {
4307
+ \t\treturn (
4308
+ \t\t\t<main className="grid min-h-svh place-items-center p-6">
4309
+ \t\t\t\t<Card className="w-full max-w-sm">
4310
+ \t\t\t\t\t<CardHeader>
4311
+ \t\t\t\t\t\t<CardTitle>Workspace invite</CardTitle>
4312
+ \t\t\t\t\t\t<CardDescription>
4313
+ \t\t\t\t\t\t\tSign in to accept this invite. New here? Create an account
4314
+ \t\t\t\t\t\t\tfirst, then reopen the invite link.
4315
+ \t\t\t\t\t\t</CardDescription>
4316
+ \t\t\t\t\t</CardHeader>
4317
+ \t\t\t\t\t<CardContent className="flex gap-2">
4318
+ \t\t\t\t\t\t<Button asChild>
4319
+ \t\t\t\t\t\t\t<Link href="/sign-in">Sign in</Link>
4320
+ \t\t\t\t\t\t</Button>
4321
+ \t\t\t\t\t\t<Button asChild variant="outline">
4322
+ \t\t\t\t\t\t\t<Link href="/sign-up">Create account</Link>
4323
+ \t\t\t\t\t\t</Button>
4324
+ \t\t\t\t\t</CardContent>
4325
+ \t\t\t\t</Card>
4326
+ \t\t\t</main>
4327
+ \t\t);
4328
+ \t}
4329
+
4330
+ \tconst pending = acceptMutation.isPending || switchMutation.isPending;
4331
+
4332
+ \treturn (
4333
+ \t\t<main className="grid min-h-svh place-items-center p-6">
4334
+ \t\t\t<Card className="w-full max-w-sm">
4335
+ \t\t\t\t<CardHeader>
4336
+ \t\t\t\t\t<CardTitle>Workspace invite</CardTitle>
4337
+ \t\t\t\t\t<CardDescription>
4338
+ \t\t\t\t\t\tAccept this invite to join the workspace as{" "}
4339
+ \t\t\t\t\t\t{session.data.user.name || session.data.user.email}.
4340
+ \t\t\t\t\t</CardDescription>
4341
+ \t\t\t\t</CardHeader>
4342
+ \t\t\t\t<CardContent className="flex flex-col gap-3">
4343
+ \t\t\t\t\t{error ? <p className="text-sm text-destructive">{error}</p> : null}
4344
+ \t\t\t\t\t<Button
4345
+ \t\t\t\t\t\ttype="button"
4346
+ \t\t\t\t\t\tdisabled={pending}
4347
+ \t\t\t\t\t\tonClick={() => {
4348
+ \t\t\t\t\t\t\tsetError(null);
4349
+ \t\t\t\t\t\t\tacceptMutation.mutate({ path: { token } });
4350
+ \t\t\t\t\t\t}}
4351
+ \t\t\t\t\t>
4352
+ \t\t\t\t\t\t{pending ? "Joining..." : "Accept invite"}
4353
+ \t\t\t\t\t</Button>
4354
+ \t\t\t\t</CardContent>
4355
+ \t\t\t</Card>
4356
+ \t\t</main>
4357
+ \t);
4358
+ }
4359
+ `;
4360
+ }
4361
+ export function workspaceComponentsIndexFile() {
4362
+ return `export { InviteAccept } from "./invite-accept";
4363
+ export { InviteForm } from "./invite-form";
4364
+ export { MembersSettings } from "./members-settings";
4365
+ export { WorkspaceSettingsForm } from "./workspace-settings-form";
4366
+ export { WorkspaceSwitcher } from "./workspace-switcher";
4367
+ `;
4368
+ }
4369
+ export function settingsWorkspacePageFile() {
4370
+ return `import {
4371
+ \tWorkspaceSettingsForm,
4372
+ \tWorkspaceSwitcher,
4373
+ } from "@/features/workspaces/components";
4374
+
4375
+ export default function SettingsWorkspacePage() {
4376
+ \treturn (
4377
+ \t\t<div className="flex flex-col gap-4">
4378
+ \t\t\t<div className="max-w-xs">
4379
+ \t\t\t\t<WorkspaceSwitcher />
4380
+ \t\t\t</div>
4381
+ \t\t\t<WorkspaceSettingsForm />
4382
+ \t\t</div>
4383
+ \t);
4384
+ }
4385
+ `;
4386
+ }
4387
+ export function settingsMembersPageFile() {
4388
+ return `import { MembersSettings } from "@/features/workspaces/components";
4389
+
4390
+ export default function SettingsMembersPage() {
4391
+ \treturn <MembersSettings />;
4392
+ }
4393
+ `;
4394
+ }
4395
+ export function inviteTokenPageFile() {
4396
+ return `import { InviteAccept } from "@/features/workspaces/components";
4397
+
4398
+ export default async function InvitePage({
4399
+ \tparams,
4400
+ }: {
4401
+ \tparams: Promise<{ token: string }>;
4402
+ }) {
4403
+ \tconst { token } = await params;
4404
+
4405
+ \treturn <InviteAccept token={token} />;
4406
+ }
4407
+ `;
4408
+ }
4409
+ //# sourceMappingURL=tenancy.js.map