forgeos 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/docs/changelog.md +33 -0
  4. package/package.json +1 -1
  5. package/src/forge/_generated/releaseManifest.json +1 -1
  6. package/src/forge/_generated/releaseManifest.ts +3 -3
  7. package/src/forge/cli/changed.ts +102 -8
  8. package/src/forge/cli/commands.ts +116 -4
  9. package/src/forge/cli/dev.ts +228 -0
  10. package/src/forge/cli/handoff.ts +17 -0
  11. package/src/forge/cli/main.ts +36 -0
  12. package/src/forge/cli/new.ts +4 -0
  13. package/src/forge/cli/parse.ts +24 -6
  14. package/src/forge/cli/workos.ts +250 -23
  15. package/src/forge/compiler/app-graph/build.ts +44 -1
  16. package/src/forge/compiler/client-sdk/render-client.ts +31 -0
  17. package/src/forge/compiler/data-graph/parse.ts +17 -1
  18. package/src/forge/compiler/data-graph/sql/ddl.ts +24 -13
  19. package/src/forge/compiler/diagnostics/codes.ts +6 -0
  20. package/src/forge/compiler/diagnostics/create.ts +14 -0
  21. package/src/forge/compiler/integration/plan.ts +6 -0
  22. package/src/forge/compiler/integration/render.ts +9 -0
  23. package/src/forge/compiler/integration/templates/render.ts +2 -0
  24. package/src/forge/compiler/integration/templates/types.ts +3 -0
  25. package/src/forge/compiler/integration/templates/workos.ts +165 -99
  26. package/src/forge/compiler/orchestrator/plan.ts +7 -0
  27. package/src/forge/delta/status.ts +10 -1
  28. package/src/forge/impact/index.ts +158 -1
  29. package/src/forge/impact/types.ts +27 -1
  30. package/src/forge/react/index.ts +19 -2
  31. package/src/forge/runtime/auth/resolve.ts +33 -3
  32. package/src/forge/runtime/auth/types.ts +1 -0
  33. package/src/forge/server.ts +5 -1
  34. package/src/forge/version.ts +1 -1
  35. package/src/forge/vue/index.ts +16 -1
  36. package/src/forge/workspace/git-summary.ts +1 -1
  37. package/templates/nuxt-web/web/package.json +2 -1
  38. package/templates/nuxt-web/web/tsconfig.json +4 -1
@@ -28,6 +28,7 @@ export interface WorkOSCommandResult {
28
28
  checks: WorkOSCheck[];
29
29
  command?: string[];
30
30
  applied?: boolean;
31
+ data?: unknown;
31
32
  stdout?: string;
32
33
  stderr?: string;
33
34
  exitCode: 0 | 1;
@@ -87,6 +88,180 @@ function includesAll(haystack: string, needles: string[]): boolean {
87
88
  return needles.every((needle) => haystack.includes(needle));
88
89
  }
89
90
 
91
+ function uniqueSorted(values: Iterable<string>): string[] {
92
+ return [...new Set([...values].filter(Boolean))].sort();
93
+ }
94
+
95
+ function quotedValues(text: string): string[] {
96
+ return [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]!).filter(Boolean);
97
+ }
98
+
99
+ interface WorkOSSeedSummary {
100
+ exists: boolean;
101
+ valid: boolean;
102
+ path: string;
103
+ permissions: string[];
104
+ roles: string[];
105
+ resourceTypes: string[];
106
+ organizations: string[];
107
+ domains: string[];
108
+ diagnostics: string[];
109
+ }
110
+
111
+ function parseSlug(line: string): string | null {
112
+ const match = /(?:^|\s)-?\s*slug:\s*["']?([^"',\]\s]+)["']?/.exec(line);
113
+ return match?.[1] ?? null;
114
+ }
115
+
116
+ function parseName(line: string): string | null {
117
+ const match = /(?:^|\s)-?\s*name:\s*["']?([^"'\]]+(?:\s[^"'\]]+)*)["']?/.exec(line);
118
+ return match?.[1]?.trim() ?? null;
119
+ }
120
+
121
+ function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEED_FILE): WorkOSSeedSummary {
122
+ const seedPath = exists(workspaceRoot, preferredPath)
123
+ ? preferredPath
124
+ : exists(workspaceRoot, GENERATED_SEED_FILE)
125
+ ? GENERATED_SEED_FILE
126
+ : preferredPath;
127
+ const raw = readText(workspaceRoot, seedPath);
128
+ const permissions = new Set<string>();
129
+ const roles = new Set<string>();
130
+ const resourceTypes = new Set<string>();
131
+ const organizations = new Set<string>();
132
+ const domains = new Set<string>();
133
+ const diagnostics: string[] = [];
134
+ let section = "";
135
+
136
+ if (!raw.trim()) {
137
+ return {
138
+ exists: false,
139
+ valid: false,
140
+ path: seedPath,
141
+ permissions: [],
142
+ roles: [],
143
+ resourceTypes: [],
144
+ organizations: [],
145
+ domains: [],
146
+ diagnostics: [`${seedPath} is missing or empty`],
147
+ };
148
+ }
149
+
150
+ for (const line of raw.split(/\r?\n/)) {
151
+ const rootSection = /^([a-zA-Z_][\w-]*):\s*$/.exec(line);
152
+ if (rootSection) {
153
+ section = rootSection[1]!;
154
+ continue;
155
+ }
156
+
157
+ const slug = parseSlug(line);
158
+ if (slug) {
159
+ if (section === "permissions") {
160
+ permissions.add(slug);
161
+ } else if (section === "roles") {
162
+ roles.add(slug);
163
+ } else if (section === "resource_types") {
164
+ resourceTypes.add(slug);
165
+ }
166
+ }
167
+
168
+ if (section === "organizations") {
169
+ const name = parseName(line);
170
+ if (name) {
171
+ organizations.add(name);
172
+ }
173
+ for (const value of quotedValues(line)) {
174
+ if (value.includes(".")) {
175
+ domains.add(value);
176
+ }
177
+ }
178
+ }
179
+
180
+ for (const match of line.matchAll(/-\s*["']?([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)["']?/g)) {
181
+ permissions.add(match[1]!);
182
+ }
183
+ }
184
+
185
+ if (permissions.size === 0) diagnostics.push("no permission slugs found");
186
+ if (roles.size === 0) diagnostics.push("no role slugs found");
187
+ if (resourceTypes.size === 0) diagnostics.push("no resource_types slugs found");
188
+ if (organizations.size === 0) diagnostics.push("no organizations found");
189
+
190
+ return {
191
+ exists: true,
192
+ valid: diagnostics.length === 0,
193
+ path: seedPath,
194
+ permissions: uniqueSorted(permissions),
195
+ roles: uniqueSorted(roles),
196
+ resourceTypes: uniqueSorted(resourceTypes),
197
+ organizations: uniqueSorted(organizations),
198
+ domains: uniqueSorted(domains),
199
+ diagnostics,
200
+ };
201
+ }
202
+
203
+ function collectPolicyPermissions(workspaceRoot: string): string[] {
204
+ const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
205
+ policies?: Array<{ permissions?: string[] }>;
206
+ } | null;
207
+ const permissions = new Set<string>();
208
+ for (const policy of registry?.policies ?? []) {
209
+ for (const permission of policy.permissions ?? []) {
210
+ permissions.add(permission);
211
+ }
212
+ }
213
+ for (const path of ["src/policies.ts", "src/policies.workos.ts"]) {
214
+ const text = readText(workspaceRoot, path);
215
+ for (const match of text.matchAll(/canPermission\s*\(([^)]*)\)/g)) {
216
+ for (const value of quotedValues(match[1] ?? "")) {
217
+ permissions.add(value);
218
+ }
219
+ }
220
+ }
221
+ return uniqueSorted(permissions);
222
+ }
223
+
224
+ function singularResourceName(name: string): string {
225
+ if (name.endsWith("ies")) {
226
+ return `${name.slice(0, -3)}y`;
227
+ }
228
+ if (name.endsWith("ses")) {
229
+ return name.slice(0, -2);
230
+ }
231
+ if (name.endsWith("s") && name.length > 1) {
232
+ return name.slice(0, -1);
233
+ }
234
+ return name;
235
+ }
236
+
237
+ function collectExpectedResourceTypes(workspaceRoot: string): string[] {
238
+ const dataGraph = readJson(workspaceRoot, `${GENERATED_DIR}/dataGraph.json`) as {
239
+ tables?: Array<{ name?: string; fields?: Array<{ name?: string }> }>;
240
+ } | null;
241
+ const agentContract = readJson(workspaceRoot, `${GENERATED_DIR}/agentContract.json`) as {
242
+ auth?: { requiresTenant?: boolean };
243
+ } | null;
244
+ const resourceTypes = new Set<string>();
245
+ const tenantScopedTables = (dataGraph?.tables ?? []).filter((table) =>
246
+ (table.fields ?? []).some((field) => field.name === "tenantId")
247
+ );
248
+ if (agentContract?.auth?.requiresTenant || tenantScopedTables.length > 0) {
249
+ resourceTypes.add("organization");
250
+ }
251
+ for (const table of tenantScopedTables) {
252
+ if (!table.name || ["organizations", "organization", "memberships", "membership"].includes(table.name)) {
253
+ continue;
254
+ }
255
+ resourceTypes.add(singularResourceName(table.name));
256
+ }
257
+ return uniqueSorted(resourceTypes);
258
+ }
259
+
260
+ function missingValues(expected: string[], actual: string[]): string[] {
261
+ const actualSet = new Set(actual);
262
+ return expected.filter((value) => !actualSet.has(value));
263
+ }
264
+
90
265
  function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
91
266
  const packageJson = readJson(workspaceRoot, "package.json") as {
92
267
  dependencies?: Record<string, string>;
@@ -103,7 +278,11 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
103
278
  ...(packageJson?.dependencies ?? {}),
104
279
  ...(packageJson?.devDependencies ?? {}),
105
280
  };
106
- const seedFile = readText(workspaceRoot, DEFAULT_SEED_FILE) || readText(workspaceRoot, GENERATED_SEED_FILE);
281
+ const seed = parseSeedFile(workspaceRoot);
282
+ const activePermissions = collectPolicyPermissions(workspaceRoot);
283
+ const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
284
+ const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
285
+ const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
107
286
  const authRoutes = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/auth-routes.ts`);
108
287
  const fga = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`);
109
288
  const resourceMap = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`);
@@ -135,30 +314,33 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
135
314
  },
136
315
  {
137
316
  name: "seed-file",
138
- ok: exists(workspaceRoot, DEFAULT_SEED_FILE) || exists(workspaceRoot, GENERATED_SEED_FILE),
139
- detail: `${DEFAULT_SEED_FILE} exists at the app root; generated fallback is ${GENERATED_SEED_FILE}`,
317
+ ok: seed.exists,
318
+ detail: seed.exists
319
+ ? `${seed.path} exists with ${seed.permissions.length} permission(s), ${seed.roles.length} role(s), ${seed.resourceTypes.length} resource type(s)`
320
+ : `${DEFAULT_SEED_FILE} or ${GENERATED_SEED_FILE} is required`,
140
321
  },
141
322
  {
142
323
  name: "seed-organizations",
143
- ok: includesAll(seedFile, ["Acme Corp", "Globex", "acme.test", "globex.test"]),
144
- detail: "seed file contains Acme/Globex demo organizations and domains",
324
+ ok: seed.organizations.length > 0,
325
+ detail: seed.organizations.length > 0
326
+ ? `seed file contains organization(s): ${seed.organizations.join(", ")}`
327
+ : "seed file should contain at least one demo organization",
145
328
  },
146
329
  {
147
330
  name: "seed-roles-permissions",
148
- ok: includesAll(seedFile, [
149
- "owner",
150
- "manager",
151
- "member",
152
- "onboarding:read",
153
- "invitations:create",
154
- "tasks:update",
155
- ]),
156
- detail: "seed file contains owner/manager/member roles and onboarding permissions",
331
+ ok: seed.roles.length > 0 &&
332
+ (activePermissions.length === 0 ? seed.permissions.length > 0 : missingSeedPermissions.length === 0),
333
+ detail: missingSeedPermissions.length === 0
334
+ ? `seed covers ${activePermissions.length} active policy permission(s) with role(s): ${seed.roles.join(", ")}`
335
+ : `seed missing active policy permission(s): ${missingSeedPermissions.join(", ")}`,
157
336
  },
158
337
  {
159
338
  name: "seed-resource-types",
160
- ok: includesAll(seedFile, ["resource_types:", "organization", "project", "taskGroup", "task"]),
161
- detail: "seed file contains WorkOS FGA resource types for the Forge app graph",
339
+ ok: seed.resourceTypes.length > 0 &&
340
+ (expectedResourceTypes.length === 0 || missingSeedResources.length === 0),
341
+ detail: missingSeedResources.length === 0
342
+ ? `seed resource_types cover app graph: ${expectedResourceTypes.length > 0 ? expectedResourceTypes.join(", ") : seed.resourceTypes.join(", ")}`
343
+ : `seed missing resource_type(s) for app graph: ${missingSeedResources.join(", ")}`,
162
344
  },
163
345
  {
164
346
  name: "authkit-routes",
@@ -192,14 +374,17 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
192
374
  "permissionSlug",
193
375
  "resourceExternalId",
194
376
  ]) &&
195
- includesAll(fga, ["forgeWorkOSResourceTypes", "organization", "project", "task"]),
377
+ includesAll(fga, ["forgeWorkOSResourceTypes"]),
196
378
  detail: "WorkOS FGA resource-map bridge exists with sync, cache, telemetry, official check shape, and cross-tenant guard",
197
379
  },
198
380
  {
199
381
  name: "policies",
200
382
  ok: exists(workspaceRoot, "src/policies.workos.ts") &&
201
- includesAll(policies, ["canPermission", "invitations:create", "tasks:update"]),
202
- detail: "WorkOS-derived Forge policy template exists and is permission-first",
383
+ policies.includes("canPermission") &&
384
+ missingValues(activePermissions, quotedValues(policies)).length === 0,
385
+ detail: activePermissions.length === 0
386
+ ? "WorkOS-derived Forge policy template exists and is permission-first"
387
+ : `WorkOS-derived policy template covers active permission(s): ${activePermissions.join(", ")}`,
203
388
  },
204
389
  ];
205
390
  }
@@ -280,19 +465,60 @@ export function runWorkOSInstallCommand(options: WorkOSCommandOptions): WorkOSCo
280
465
 
281
466
  export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
282
467
  const file = options.file ?? DEFAULT_SEED_FILE;
468
+ const seed = parseSeedFile(options.workspaceRoot, file);
469
+ const activePermissions = collectPolicyPermissions(options.workspaceRoot);
470
+ const expectedResourceTypes = collectExpectedResourceTypes(options.workspaceRoot);
471
+ const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
472
+ const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
473
+ const unusedSeedPermissions = seed.permissions.filter((permission) => !activePermissions.includes(permission));
283
474
  const checks = [
284
475
  {
285
476
  name: "seed-file",
286
- ok: exists(options.workspaceRoot, file),
287
- detail: `${file} exists`,
477
+ ok: seed.exists,
478
+ detail: seed.exists ? `${seed.path} exists` : `${file} exists`,
479
+ },
480
+ {
481
+ name: "seed-yaml-shape",
482
+ ok: seed.valid,
483
+ detail: seed.valid ? "seed contains permissions, roles, resource_types, and organizations" : seed.diagnostics.join("; "),
484
+ },
485
+ {
486
+ name: "seed-policy-coverage",
487
+ ok: missingSeedPermissions.length === 0,
488
+ detail: missingSeedPermissions.length === 0
489
+ ? `seed covers ${activePermissions.length} active permission(s)`
490
+ : `seed missing active permission(s): ${missingSeedPermissions.join(", ")}`,
491
+ },
492
+ {
493
+ name: "seed-resource-coverage",
494
+ ok: missingSeedResources.length === 0,
495
+ detail: missingSeedResources.length === 0
496
+ ? `seed covers app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
497
+ : `seed missing resource type(s): ${missingSeedResources.join(", ")}`,
288
498
  },
289
499
  ];
290
500
  const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
291
501
  if (!checks.every((check) => check.ok)) {
292
- return { ok: false, kind: "workos-seed", checks, command, applied: false, exitCode: 1 };
502
+ return {
503
+ ok: false,
504
+ kind: "workos-seed",
505
+ checks,
506
+ command,
507
+ applied: false,
508
+ data: { seed, activePermissions, expectedResourceTypes, unusedSeedPermissions },
509
+ exitCode: 1,
510
+ };
293
511
  }
294
512
  if (!options.yes || options.dryRun) {
295
- return { ok: true, kind: "workos-seed", checks, command, applied: false, exitCode: 0 };
513
+ return {
514
+ ok: true,
515
+ kind: "workos-seed",
516
+ checks,
517
+ command,
518
+ applied: false,
519
+ data: { seed, activePermissions, expectedResourceTypes, unusedSeedPermissions },
520
+ exitCode: 0,
521
+ };
296
522
  }
297
523
  const child = runExternalCommand(command, options);
298
524
  return {
@@ -301,6 +527,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
301
527
  checks,
302
528
  command,
303
529
  applied: child.status === 0,
530
+ data: { seed, activePermissions, expectedResourceTypes, unusedSeedPermissions },
304
531
  stdout: child.stdout,
305
532
  stderr: child.stderr,
306
533
  exitCode: child.status === 0 ? 0 : 1,
@@ -3,6 +3,8 @@ import { hashStable } from "../primitives/hash.ts";
3
3
  import { normalizePath } from "../primitives/paths.ts";
4
4
  import { stableSortEdges, stableSortSymbols } from "../primitives/sort.ts";
5
5
  import type { AppGraph, ForgeEdge, ForgeSymbol, SourceFile } from "../types/app-graph.ts";
6
+ import { FORGE_RUNTIME_EXPORT_NAME_REQUIRED } from "../diagnostics/codes.ts";
7
+ import { createDiagnostic } from "../diagnostics/create.ts";
6
8
  import { detectDuplicateSymbols } from "./dup-symbol.ts";
7
9
  import { buildModuleGraph } from "./module-graph.ts";
8
10
  import { incrementalParse } from "./parser.ts";
@@ -97,6 +99,46 @@ function buildAppGraphEdges(
97
99
  return edges;
98
100
  }
99
101
 
102
+ const NAMED_EXPORT_REQUIRED_KINDS = new Set([
103
+ "command",
104
+ "query",
105
+ "liveQuery",
106
+ "action",
107
+ "workflow",
108
+ "endpoint",
109
+ ]);
110
+
111
+ function detectUnnamedForgeExports(symbols: ForgeSymbol[]): AppGraph["diagnostics"] {
112
+ return symbols
113
+ .filter((symbol) =>
114
+ NAMED_EXPORT_REQUIRED_KINDS.has(symbol.kind) &&
115
+ symbol.meta.exportPath === "default"
116
+ )
117
+ .map((symbol) =>
118
+ createDiagnostic({
119
+ severity: "error",
120
+ code: FORGE_RUNTIME_EXPORT_NAME_REQUIRED,
121
+ message:
122
+ `Forge ${symbol.kind} in ${symbol.file} uses export default; runtime entries must be named exports for stable API names.`,
123
+ file: symbol.file,
124
+ span: symbol.span,
125
+ fixHint:
126
+ `Replace \`export default ${symbol.kind}(...)\` with \`export const ${suggestExportName(symbol)} = ${symbol.kind}(...)\`.`,
127
+ suggestedCommands: ["forge check --json", "forge generate"],
128
+ })
129
+ );
130
+ }
131
+
132
+ function suggestExportName(symbol: ForgeSymbol): string {
133
+ const base = symbol.file
134
+ .split("/")
135
+ .pop()
136
+ ?.replace(/\.[cm]?[jt]sx?$/, "")
137
+ .replace(/[^a-zA-Z0-9]+(.)/g, (_, next: string) => next.toUpperCase())
138
+ .replace(/^[^a-zA-Z_]+/, "") || symbol.kind;
139
+ return base || symbol.kind;
140
+ }
141
+
100
142
  export async function buildAppGraph(
101
143
  options: AppGraphBuildOptions,
102
144
  ): Promise<AppGraph> {
@@ -141,6 +183,7 @@ export async function buildAppGraph(
141
183
  const symbolsMs = Date.now() - checkpoint;
142
184
  checkpoint = Date.now();
143
185
  const dupDiagnostics = detectDuplicateSymbols(forgeSymbols);
186
+ const unnamedExportDiagnostics = detectUnnamedForgeExports(forgeSymbols);
144
187
  const duplicatesMs = Date.now() - checkpoint;
145
188
  checkpoint = Date.now();
146
189
  const moduleGraph = buildModuleGraph(
@@ -166,7 +209,7 @@ export async function buildAppGraph(
166
209
  symbols: sortedSymbols,
167
210
  edges: stableSortEdges(buildAppGraphEdges(sortedSymbols, moduleGraph)),
168
211
  moduleGraph,
169
- diagnostics: [...parseDiagnostics, ...dupDiagnostics],
212
+ diagnostics: [...parseDiagnostics, ...dupDiagnostics, ...unnamedExportDiagnostics],
170
213
  };
171
214
  recordAppGraphProfile(graph, {
172
215
  normalizeMs,
@@ -4,7 +4,12 @@ export function renderClientTypesTs(): string {
4
4
  return `export type ForgeStaticAuth = {
5
5
  userId?: string;
6
6
  tenantId?: string;
7
+ organizationId?: string;
8
+ organizationMembershipId?: string;
7
9
  role?: string;
10
+ roles?: string[];
11
+ permissions?: string[];
12
+ claims?: Record<string, unknown>;
8
13
  token?: string;
9
14
  getToken?: () => string | Promise<string>;
10
15
  headers?: Record<string, string>;
@@ -14,7 +19,12 @@ export function renderClientTypesTs(): string {
14
19
  export type ForgeResolvedAuth = {
15
20
  userId?: string;
16
21
  tenantId?: string;
22
+ organizationId?: string;
23
+ organizationMembershipId?: string;
17
24
  role?: string;
25
+ roles?: string[];
26
+ permissions?: string[];
27
+ claims?: Record<string, unknown>;
18
28
  token?: string;
19
29
  getToken?: () => string | Promise<string>;
20
30
  headers?: Record<string, string>;
@@ -159,7 +169,12 @@ async function resolveAuthHeaders(
159
169
  typeof value === "string" &&
160
170
  key !== "userId" &&
161
171
  key !== "tenantId" &&
172
+ key !== "organizationId" &&
173
+ key !== "organizationMembershipId" &&
162
174
  key !== "role" &&
175
+ key !== "roles" &&
176
+ key !== "permissions" &&
177
+ key !== "claims" &&
163
178
  key !== "token"
164
179
  ) {
165
180
  headers[key] = value;
@@ -172,9 +187,25 @@ async function resolveAuthHeaders(
172
187
  if (resolved.tenantId) {
173
188
  headers["x-forge-tenant-id"] = resolved.tenantId;
174
189
  }
190
+ if (resolved.organizationId) {
191
+ headers["x-forge-organization-id"] = resolved.organizationId;
192
+ headers["x-forge-tenant-id"] ??= resolved.organizationId;
193
+ }
194
+ if (resolved.organizationMembershipId) {
195
+ headers["x-forge-organization-membership-id"] = resolved.organizationMembershipId;
196
+ }
175
197
  if (resolved.role) {
176
198
  headers["x-forge-role"] = resolved.role;
177
199
  }
200
+ if (Array.isArray(resolved.roles)) {
201
+ headers["x-forge-roles"] = JSON.stringify(resolved.roles);
202
+ }
203
+ if (Array.isArray(resolved.permissions)) {
204
+ headers["x-forge-permissions"] = JSON.stringify(resolved.permissions);
205
+ }
206
+ if (resolved.claims && typeof resolved.claims === "object") {
207
+ headers["x-forge-claims"] = JSON.stringify(resolved.claims);
208
+ }
178
209
 
179
210
  return headers;
180
211
  }
@@ -36,6 +36,22 @@ function unwrapStringLiteral(node: SyntaxNode): string | null {
36
36
  return null;
37
37
  }
38
38
 
39
+ function unwrapNullableType(node: SyntaxNode): string | null {
40
+ if (node.type !== "call_expression") {
41
+ return null;
42
+ }
43
+ const functionNode = node.childForFieldName("function");
44
+ if (functionNode?.type !== "identifier" || functionNode.text !== "nullable") {
45
+ return null;
46
+ }
47
+ const argsNode = node.childForFieldName("arguments");
48
+ const firstArg = argsNode?.namedChildren.find((child) =>
49
+ child.type !== "," && child.type !== "comment" && child.type !== "(" && child.type !== ")"
50
+ );
51
+ const inner = firstArg ? unwrapStringLiteral(firstArg) : null;
52
+ return inner ? `${inner}?` : null;
53
+ }
54
+
39
55
  function extractFieldsFromObject(objectNode: SyntaxNode, options: { skipConfigName?: boolean } = {}): DataField[] {
40
56
  const fields: DataField[] = [];
41
57
 
@@ -64,7 +80,7 @@ function extractFieldsFromObject(objectNode: SyntaxNode, options: { skipConfigNa
64
80
  continue;
65
81
  }
66
82
 
67
- const typeValue = unwrapStringLiteral(valueNode);
83
+ const typeValue = unwrapStringLiteral(valueNode) ?? unwrapNullableType(valueNode);
68
84
  if (typeValue !== null) {
69
85
  fields.push({ name: key, type: typeValue });
70
86
  }
@@ -34,6 +34,7 @@ const SYSTEM_LIVE_SUBSCRIPTION_DEBUG_SQL = `CREATE TABLE IF NOT EXISTS _forge_li
34
34
 
35
35
  interface ParsedFieldType {
36
36
  sqlType: string;
37
+ nullable?: boolean;
37
38
  references?: { table: string; column: string };
38
39
  checkConstraint?: string;
39
40
  }
@@ -102,28 +103,35 @@ function parseFieldType(
102
103
  tableName: string,
103
104
  diagnostics: Diagnostic[],
104
105
  ): ParsedFieldType | null {
105
- const raw = field.type.trim().toLowerCase();
106
+ const declared = field.type.trim();
107
+ const nullable = declared.endsWith("?");
108
+ const raw = (nullable ? declared.slice(0, -1) : declared).trim().toLowerCase();
109
+
110
+ const withNullability = (parsed: Omit<ParsedFieldType, "nullable">): ParsedFieldType => ({
111
+ ...parsed,
112
+ ...(nullable ? { nullable: true } : {}),
113
+ });
106
114
 
107
115
  if (raw === "uuid") {
108
- return { sqlType: "uuid" };
116
+ return withNullability({ sqlType: "uuid" });
109
117
  }
110
118
  if (raw === "text" || raw === "string") {
111
- return { sqlType: "text" };
119
+ return withNullability({ sqlType: "text" });
112
120
  }
113
121
  if (raw === "number") {
114
- return { sqlType: "double precision" };
122
+ return withNullability({ sqlType: "double precision" });
115
123
  }
116
124
  if (raw === "integer" || raw === "int") {
117
- return { sqlType: "integer" };
125
+ return withNullability({ sqlType: "integer" });
118
126
  }
119
127
  if (raw === "boolean" || raw === "bool") {
120
- return { sqlType: "boolean" };
128
+ return withNullability({ sqlType: "boolean" });
121
129
  }
122
- if (raw === "timestamp") {
123
- return { sqlType: "timestamptz" };
130
+ if (raw === "timestamp" || raw === "timestamptz") {
131
+ return withNullability({ sqlType: "timestamptz" });
124
132
  }
125
133
  if (raw === "json") {
126
- return { sqlType: "jsonb" };
134
+ return withNullability({ sqlType: "jsonb" });
127
135
  }
128
136
 
129
137
  if (raw.startsWith("enum:")) {
@@ -144,13 +152,14 @@ function parseFieldType(
144
152
  }
145
153
  const checkValues = values.map((value) => `'${value.replace(/'/g, "''")}'`).join(", ");
146
154
  return {
155
+ ...(nullable ? { nullable: true } : {}),
147
156
  sqlType: "text",
148
157
  checkConstraint: `${quoteIdent(toSnakeCase(field.name))} IN (${checkValues})`,
149
158
  };
150
159
  }
151
160
 
152
161
  if (raw.startsWith("enum_")) {
153
- return { sqlType: "text" };
162
+ return withNullability({ sqlType: "text" });
154
163
  }
155
164
 
156
165
  if (raw.startsWith("ref:")) {
@@ -166,6 +175,7 @@ function parseFieldType(
166
175
  return null;
167
176
  }
168
177
  return {
178
+ ...(nullable ? { nullable: true } : {}),
169
179
  sqlType: "uuid",
170
180
  references: { table: toSnakeCase(refTable), column: "id" },
171
181
  };
@@ -173,6 +183,7 @@ function parseFieldType(
173
183
 
174
184
  if (raw === "ref") {
175
185
  return {
186
+ ...(nullable ? { nullable: true } : {}),
176
187
  sqlType: "uuid",
177
188
  references: { table: toSnakeCase(field.name.replace(/Id$/i, "")), column: "id" },
178
189
  };
@@ -190,7 +201,7 @@ function parseFieldType(
190
201
 
191
202
  function defaultExprForField(field: DataField): string | undefined {
192
203
  const name = field.name;
193
- const type = field.type.trim().toLowerCase();
204
+ const type = field.type.trim().replace(/\?$/, "").toLowerCase();
194
205
 
195
206
  if (name === "id" && type === "uuid") {
196
207
  return "gen_random_uuid()";
@@ -238,7 +249,7 @@ function buildColumns(table: DataTable, diagnostics: Diagnostic[]): ColumnDef[]
238
249
  name: snakeName,
239
250
  fieldName: field.name,
240
251
  sqlType: parsed.sqlType,
241
- nullable: false,
252
+ nullable: isPrimaryKey ? false : parsed.nullable === true,
242
253
  primaryKey: isPrimaryKey,
243
254
  defaultExpr,
244
255
  references: parsed.references,
@@ -250,7 +261,7 @@ function buildColumns(table: DataTable, diagnostics: Diagnostic[]): ColumnDef[]
250
261
  const idField = fields.find((field) => field.name === "id");
251
262
  diagnostics.push(
252
263
  createDiagnostic({
253
- severity: "error",
264
+ severity: "warning",
254
265
  code: FORGE_DB_INVALID_SQL_PLAN,
255
266
  message: `table '${table.name}' declares id as '${idField?.type ?? "unknown"}'; Forge runtime requires id: "uuid" or no id field so Forge can generate one`,
256
267
  file: table.file,
@@ -27,6 +27,8 @@ export const FORGE_DUP_RUNTIME_ENTRY = "FORGE_DUP_RUNTIME_ENTRY" as const;
27
27
  export const FORGE_RUNTIME_UNRESOLVABLE = "FORGE_RUNTIME_UNRESOLVABLE" as const;
28
28
  export const FORGE_RUNTIME_NOT_FOUND = "FORGE_RUNTIME_NOT_FOUND" as const;
29
29
  export const FORGE_RUNTIME_GUARD_BLOCKED = "FORGE_RUNTIME_GUARD_BLOCKED" as const;
30
+ export const FORGE_RUNTIME_EXPORT_NAME_REQUIRED =
31
+ "FORGE_RUNTIME_EXPORT_NAME_REQUIRED" as const;
30
32
  export const FORGE_EXTERNAL_RUNTIME_NOT_FOUND =
31
33
  "FORGE_EXTERNAL_RUNTIME_NOT_FOUND" as const;
32
34
  export const FORGE_EXTERNAL_RUNTIME_UNSUPPORTED =
@@ -46,6 +48,8 @@ export const FORGE_DB_MIGRATION_FAILED = "FORGE_DB_MIGRATION_FAILED" as const;
46
48
  export const FORGE_DB_UNSUPPORTED_FIELD_TYPE =
47
49
  "FORGE_DB_UNSUPPORTED_FIELD_TYPE" as const;
48
50
  export const FORGE_DB_INVALID_SQL_PLAN = "FORGE_DB_INVALID_SQL_PLAN" as const;
51
+ export const FORGE_DB_EMPTY_TIMESTAMP_LITERAL =
52
+ "FORGE_DB_EMPTY_TIMESTAMP_LITERAL" as const;
49
53
  export const FORGE_DB_TRANSACTION_FAILED = "FORGE_DB_TRANSACTION_FAILED" as const;
50
54
  export const FORGE_DB_OUTBOX_WRITE_FAILED = "FORGE_DB_OUTBOX_WRITE_FAILED" as const;
51
55
  export const FORGE_DB_ADAPTER_UNAVAILABLE = "FORGE_DB_ADAPTER_UNAVAILABLE" as const;
@@ -414,6 +418,7 @@ export const DIAGNOSTIC_CODES = [
414
418
  FORGE_RUNTIME_UNRESOLVABLE,
415
419
  FORGE_RUNTIME_NOT_FOUND,
416
420
  FORGE_RUNTIME_GUARD_BLOCKED,
421
+ FORGE_RUNTIME_EXPORT_NAME_REQUIRED,
417
422
  FORGE_EXTERNAL_RUNTIME_NOT_FOUND,
418
423
  FORGE_EXTERNAL_RUNTIME_UNSUPPORTED,
419
424
  FORGE_EXTERNAL_RUNTIME_FAILED,
@@ -427,6 +432,7 @@ export const DIAGNOSTIC_CODES = [
427
432
  FORGE_DB_MIGRATION_FAILED,
428
433
  FORGE_DB_UNSUPPORTED_FIELD_TYPE,
429
434
  FORGE_DB_INVALID_SQL_PLAN,
435
+ FORGE_DB_EMPTY_TIMESTAMP_LITERAL,
430
436
  FORGE_DB_TRANSACTION_FAILED,
431
437
  FORGE_DB_OUTBOX_WRITE_FAILED,
432
438
  FORGE_DB_ADAPTER_UNAVAILABLE,
@@ -47,6 +47,13 @@ function defaultGuidanceForCode(code: string): DiagnosticGuidance | null {
47
47
  docs: ["src/forge/_generated/runtimeRules.md", "AGENTS.md"],
48
48
  };
49
49
  }
50
+ if (code === "FORGE_RUNTIME_EXPORT_NAME_REQUIRED") {
51
+ return {
52
+ fixHint: "Forge runtime entries must be named exports. Replace `export default command(...)` with `export const createThing = command(...)` so generated API, frontend bindings, and capability maps have a stable name.",
53
+ suggestedCommands: ["forge check --json", "forge generate"],
54
+ docs: ["AGENTS.md", "src/forge/_generated/runtimeRules.md"],
55
+ };
56
+ }
50
57
  if (code === "FORGE_SECRET_DIRECT_PROCESS_ENV") {
51
58
  return {
52
59
  fixHint: "Use ctx.secrets or ctx.config for Forge runtime secrets/config; public frontend bridge env may use the framework's public env surface.",
@@ -75,6 +82,13 @@ function defaultGuidanceForCode(code: string): DiagnosticGuidance | null {
75
82
  docs: ["src/forge/_generated/agentContract.json"],
76
83
  };
77
84
  }
85
+ if (code === "FORGE_DB_EMPTY_TIMESTAMP_LITERAL") {
86
+ return {
87
+ fixHint: "Do not use an empty string for timestamp fields. Use a real ISO timestamp, omit an optional timestamp field, declare it as `timestamp?` / `nullable(\"timestamp\")`, or model it as text if empty string is meaningful.",
88
+ suggestedCommands: ["forge check --json", "forge dev --db pglite --once --json"],
89
+ docs: ["AGENTS.md"],
90
+ };
91
+ }
78
92
  if (code.startsWith("FORGE_UI_")) {
79
93
  return {
80
94
  fixHint: "Inspect the last UI run and use the UI report as repair input.",