forgeos 0.1.0-alpha.56 → 0.1.0-alpha.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.56 input=a013874c7856b533aae64f467111c45bd707703fd9db6f0b8115898afc97af53 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
1
+ // @forge-generated generator=0.1.0-alpha.57 input=7128597ce3f6cd158bf4361cebb1eef8678c19ff9b2cb60c454a0876356d37c6 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # forgeos
2
2
 
3
+ ## 0.1.0-alpha.57
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix the WorkOS AuthKit local and tunneled development flow.
8
+
9
+ - Add `response_type=code` to Forge-owned WorkOS authorization URLs and avoid sending a default `organization_id` during login so AuthKit can present normal sign-in and organization selection.
10
+ - Resolve WorkOS organization and membership claims during callback when AuthKit returns only the user/token, then expose Forge-normalized session claims through `/session`.
11
+ - Add `forge dev --public-api-url` for browser-facing API URLs behind tunnels and update the WorkOS React bridge to use backend-owned `/login`, `/session`, and `/logout` routes through same-origin/proxy-aware URL resolution.
12
+ - Sync public WorkOS browser env into `web/.env.local` during `forge add auth workos` and `forge workos setup --real`, and update WorkOS doctor/UI audit checks for the backend-owned AuthKit session flow.
13
+
3
14
  ## 0.1.0-alpha.56
4
15
 
5
16
  ### Patch Changes
@@ -22,7 +33,7 @@
22
33
  `/ready`, `/live/status`, `/outbox/status`, and `/webhooks/workos`.
23
34
  - Production docs now present the canonical flow:
24
35
  `deploy init -> env doctor -> auth/auth.md/WorkOS proof -> field-test ->
25
- readiness -> check -> package -> verify`, while keeping WorkOS FGA
36
+ readiness -> check -> package -> verify`, while keeping WorkOS FGA
26
37
  explicitly optional unless the app opts into FGA artifacts.
27
38
 
28
39
  ## 0.1.0-alpha.55
@@ -115,7 +126,7 @@
115
126
  - Tighten the no-dashboard WorkOS real setup/proof handoff.
116
127
 
117
128
  - WorkOS hosted setup now records explicit `WORKOS_MODE=agent npx --yes
118
- workos@latest auth login --json` shell guidance when CLI login is required.
129
+ workos@latest auth login --json` shell guidance when CLI login is required.
119
130
  - Failed `forge workos setup --real` login attempts now tell the operator to
120
131
  rerun the same setup command after completing OAuth/device-code login,
121
132
  instead of pointing generically at `prove --real`.
@@ -276,7 +287,7 @@
276
287
  `forge dev --seed --all-tenants`, and seed readiness evidence in dev and
277
288
  field-test summaries.
278
289
  - Update templates, docs, and tests around the new `create app -> add auth ->
279
- field-test -> deploy` path.
290
+ field-test -> deploy` path.
280
291
 
281
292
  ## 0.1.0-alpha.41
282
293
 
package/docs/changelog.md CHANGED
@@ -6,6 +6,22 @@ The canonical source file in the repository is `CHANGELOG.md`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.0-alpha.57
10
+
11
+ - Fixed the WorkOS/AuthKit login path used by local and tunneled development:
12
+ Forge-owned authorization URLs now include `response_type=code` and no longer
13
+ send a default `organization_id` unless explicitly requested.
14
+ - The WorkOS callback now resolves organization and membership claims when
15
+ AuthKit returns only the user/token, then exposes Forge-normalized session
16
+ claims through `/session`.
17
+ - Added `forge dev --public-api-url` so browser-facing API URLs can be injected
18
+ cleanly for SSH tunnels, Docker Desktop, and remote dev hosts.
19
+ - The generated WorkOS React bridge now uses backend-owned `/login`, `/session`,
20
+ and `/logout` routes through same-origin/proxy-aware URL resolution.
21
+ - `forge add auth workos`, `forge workos setup --real`, `workos doctor`, and
22
+ `inspect ui --ergonomics` now understand the backend-owned AuthKit session
23
+ flow and sync public browser env into `web/.env.local`.
24
+
9
25
  ## 0.1.0-alpha.56
10
26
 
11
27
  - Added `forge deploy init --target docker` as the canonical production Docker
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.56",
3
+ "version": "0.1.0-alpha.57",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.56","releaseId":"forgeos@0.1.0-alpha.56+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.57","releaseId":"forgeos@0.1.0-alpha.57+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.56 input=a013874c7856b533aae64f467111c45bd707703fd9db6f0b8115898afc97af53 content=a9688092f903ceb0263f9581e3ab71ecc968284ffd530d9112a5467ea10baf63
1
+ // @forge-generated generator=0.1.0-alpha.57 input=7128597ce3f6cd158bf4361cebb1eef8678c19ff9b2cb60c454a0876356d37c6 content=40c7132e14bfeffd8018f04de93db9ad294cfca4e190b21106d86bb80bc3bc03
2
2
  export const releaseManifest = {
3
3
  "defaultProvider": "local",
4
4
  "diagnostics": [],
@@ -19,7 +19,7 @@ export const releaseManifest = {
19
19
  "custom"
20
20
  ],
21
21
  "packageName": "forgeos",
22
- "packageVersion": "0.1.0-alpha.56",
23
- "releaseId": "forgeos@0.1.0-alpha.56+unknown",
22
+ "packageVersion": "0.1.0-alpha.57",
23
+ "releaseId": "forgeos@0.1.0-alpha.57+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -180,11 +180,16 @@ function permissionSubsetForRestrictedTenant(permissions: string[]): string[] {
180
180
  function buildMultiTenantProof(workspaceRoot: string, workos: ReturnType<typeof detectWorkOS>, requiresTenant: boolean) {
181
181
  const rootSeedPresent = existsSync(join(workspaceRoot, "workos-seed.yml"));
182
182
  const generatedSeedPresent = existsSync(join(workspaceRoot, "src/forge/_generated/integrations/workos/workos-seed.yml"));
183
+ const fgaArtifactsPresent =
184
+ existsSync(join(workspaceRoot, "src/forge/_generated/integrations/workos/fga.ts")) ||
185
+ existsSync(join(workspaceRoot, "src/forge/_generated/integrations/workos/resource-map.ts")) ||
186
+ existsSync(join(workspaceRoot, ".workos-fga-state.json"));
183
187
  const authMdPresent = existsSync(join(workspaceRoot, "public/auth.md"));
184
188
  const metadataPresent = existsSync(join(workspaceRoot, "public/.well-known/oauth-protected-resource"));
185
189
  const activePermissions = collectPolicyPermissions(workspaceRoot);
186
190
  const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
187
191
  const seed = parseSeedFile(workspaceRoot);
192
+ const fgaEnabled = fgaArtifactsPresent || seed.resourceTypes.length > 0;
188
193
  const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
189
194
  const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
190
195
  const acmePermissions = activePermissions;
@@ -223,8 +228,10 @@ function buildMultiTenantProof(workspaceRoot: string, workos: ReturnType<typeof
223
228
  },
224
229
  {
225
230
  id: "seed-resource-coverage",
226
- ok: missingSeedResources.length === 0,
227
- evidence: missingSeedResources.length === 0
231
+ ok: !fgaEnabled || missingSeedResources.length === 0,
232
+ evidence: !fgaEnabled
233
+ ? "FGA is not enabled; seed resource type coverage is not required for AuthKit/RBAC tenant isolation."
234
+ : missingSeedResources.length === 0
228
235
  ? `seed covers expected app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
229
236
  : `seed missing expected app resource type(s): ${missingSeedResources.join(", ")}`,
230
237
  },
@@ -244,6 +251,7 @@ function buildMultiTenantProof(workspaceRoot: string, workos: ReturnType<typeof
244
251
  appContract: {
245
252
  activePermissions,
246
253
  expectedResourceTypes,
254
+ fgaEnabled,
247
255
  seedPath: seed.path,
248
256
  seedPermissions: seed.permissions,
249
257
  seedResourceTypes: seed.resourceTypes,
@@ -2709,6 +2709,7 @@ export async function executeCommand(command: ForgeCommand): Promise<number> {
2709
2709
  webOnly: command.webOnly,
2710
2710
  open: command.open,
2711
2711
  webPort: command.webPort,
2712
+ publicApiUrl: command.publicApiUrl,
2712
2713
  telemetry: command.telemetry,
2713
2714
  envFile: command.envFile,
2714
2715
  skipStartupConsole: command.skipStartupConsole,
@@ -53,6 +53,7 @@ export interface DevCommandOptions {
53
53
  webOnly?: boolean;
54
54
  open?: boolean;
55
55
  webPort?: number;
56
+ publicApiUrl?: string;
56
57
  telemetry: string[];
57
58
  envFile?: string;
58
59
  mode?: "dev" | "serve";
@@ -305,6 +306,7 @@ function buildDetachedDevArgs(options: DevCommandOptions): string[] {
305
306
  if (options.webOnly) args.push("--web-only");
306
307
  if (options.open) args.push("--open");
307
308
  if (options.webPort !== undefined) args.push("--web-port", String(options.webPort));
309
+ if (options.publicApiUrl) args.push("--public-api-url", options.publicApiUrl);
308
310
  if (options.telemetry.length > 0) args.push("--telemetry", options.telemetry.join(","));
309
311
  if (options.envFile) args.push("--env-file", options.envFile);
310
312
  if (options.seed) args.push("--seed");
@@ -1343,13 +1345,14 @@ export async function runDevCommand(
1343
1345
 
1344
1346
  if (options.webOnly) {
1345
1347
  const apiUrl = `http://${host}:${port}`;
1348
+ const publicApiUrl = options.publicApiUrl ?? apiUrl;
1346
1349
  const webHandle = startWebDevServer({
1347
1350
  workspaceRoot,
1348
1351
  host,
1349
1352
  port: webPort,
1350
1353
  requestedPort: webPortSelection.requestedPort,
1351
1354
  autoPortSelected: webPortSelection.autoPortSelected,
1352
- apiUrl,
1355
+ apiUrl: publicApiUrl,
1353
1356
  json: options.json,
1354
1357
  });
1355
1358
  if (!webHandle) {
@@ -1451,7 +1454,7 @@ export async function runDevCommand(
1451
1454
  port: webPort,
1452
1455
  requestedPort: webPortSelection.requestedPort,
1453
1456
  autoPortSelected: webPortSelection.autoPortSelected,
1454
- apiUrl: handle.url,
1457
+ apiUrl: options.publicApiUrl ?? handle.url,
1455
1458
  json: options.json,
1456
1459
  });
1457
1460
 
@@ -110,6 +110,7 @@ function formatDevHelp(): string {
110
110
  " --db memory|pglite|postgres|none Choose the development database adapter",
111
111
  " --port <port> API runtime port; use 0 for an ephemeral port",
112
112
  " --web-port <port> Web dev server port; use 0 for an ephemeral port",
113
+ " --public-api-url <url> Browser-facing API URL injected into web env, useful for tunnels",
113
114
  " --host <host> Bind host, default 127.0.0.1",
114
115
  " --no-web Start API/runtime only",
115
116
  " --api-only Start API/runtime only",
@@ -124,6 +125,7 @@ function formatDevHelp(): string {
124
125
  "",
125
126
  "Examples:",
126
127
  " forge dev --db memory --port 3777 --web-port 5174",
128
+ " forge dev --public-api-url http://localhost:3765",
127
129
  " forge dev --db memory --port 0 --web-port 0",
128
130
  " forge dev --seed --db pglite",
129
131
  " forge dev --seed --all-tenants --db pglite",
@@ -336,6 +336,7 @@ export type ForgeCommand =
336
336
  webOnly: boolean;
337
337
  open: boolean;
338
338
  webPort?: number;
339
+ publicApiUrl?: string;
339
340
  telemetry: string[];
340
341
  envFile?: string;
341
342
  skipStartupConsole: boolean;
@@ -2733,6 +2734,7 @@ export function parseCli(argv: string[]): ParsedCli {
2733
2734
  webOnly: parseFlag(argv, "--web-only"),
2734
2735
  open: parseFlag(argv, "--open"),
2735
2736
  webPort,
2737
+ publicApiUrl: parseOptionValue(argv, "--public-api-url"),
2736
2738
  telemetry: (parseOptionValue(argv, "--telemetry") ?? "local")
2737
2739
  .split(",")
2738
2740
  .map((value) => value.trim())
@@ -3250,6 +3252,7 @@ export function hasUnknownOption(argv: string[]): string | null {
3250
3252
  "--postgres-version",
3251
3253
  "--runtime-port",
3252
3254
  "--web-port",
3255
+ "--public-api-url",
3253
3256
  "--preview-port",
3254
3257
  "--preview-url",
3255
3258
  "--studio-url",
@@ -3377,6 +3380,7 @@ export function hasUnknownOption(argv: string[]): string | null {
3377
3380
  arg === "--postgres-version" ||
3378
3381
  arg === "--runtime-port" ||
3379
3382
  arg === "--web-port" ||
3383
+ arg === "--public-api-url" ||
3380
3384
  arg === "--preview-port" ||
3381
3385
  arg === "--preview-url" ||
3382
3386
  arg === "--studio-url" ||
@@ -573,6 +573,62 @@ function collectWorkOSRealEnvChecks(workspaceRoot: string, cliAuth?: WorkOSCliAu
573
573
  ];
574
574
  }
575
575
 
576
+ function renderMergedDotEnv(current: string | null, values: Record<string, string>): string {
577
+ const lines = (current ?? "").split(/\r?\n/);
578
+ const seen = new Set<string>();
579
+ const next = lines.map((line) => {
580
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
581
+ if (!match || !(match[1]! in values)) {
582
+ return line;
583
+ }
584
+ seen.add(match[1]!);
585
+ return `${match[1]}=${values[match[1]!]}`;
586
+ });
587
+ for (const [key, value] of Object.entries(values)) {
588
+ if (!seen.has(key)) {
589
+ next.push(`${key}=${value}`);
590
+ }
591
+ }
592
+ return `${next.filter((line, index, all) => line.length > 0 || index < all.length - 1).join("\n").trimEnd()}\n`;
593
+ }
594
+
595
+ function syncWorkOSPublicWebEnv(workspaceRoot: string, write: boolean): { ok: boolean; changed: string[]; path?: string; skipped?: string } {
596
+ const frontendWorkspace = findWorkOSFrontendWorkspace(workspaceRoot);
597
+ if (!frontendWorkspace) {
598
+ return { ok: true, changed: [], skipped: "no web workspace detected" };
599
+ }
600
+ const env = readRealEnv(workspaceRoot);
601
+ const clientId = env.VITE_WORKOS_CLIENT_ID || env.WORKOS_CLIENT_ID || "";
602
+ const redirectUri = env.VITE_WORKOS_REDIRECT_URI || env.WORKOS_REDIRECT_URI || "http://localhost:5173/callback";
603
+ const values: Record<string, string> = {
604
+ VITE_FORGE_URL: env.VITE_FORGE_URL || "http://localhost:3765",
605
+ VITE_WORKOS_CLIENT_ID: clientId,
606
+ VITE_WORKOS_REDIRECT_URI: redirectUri,
607
+ };
608
+ const relPath = join(frontendWorkspace, ".env.local");
609
+ const path = join(workspaceRoot, relPath);
610
+ const current = readRawText(workspaceRoot, relPath);
611
+ const next = renderMergedDotEnv(current, values);
612
+ if (current === next) {
613
+ return { ok: true, changed: [], path: relPath };
614
+ }
615
+ if (write) {
616
+ mkdirSync(dirname(path), { recursive: true });
617
+ writeFileSync(path, next, "utf8");
618
+ }
619
+ return { ok: true, changed: [relPath], path: relPath };
620
+ }
621
+
622
+ function findWorkOSFrontendWorkspace(workspaceRoot: string): string | undefined {
623
+ for (const candidate of ["web", "frontend", "client", "apps/web", "packages/web"]) {
624
+ if (existsSync(join(workspaceRoot, candidate, "package.json"))) {
625
+ return candidate;
626
+ }
627
+ }
628
+ return undefined;
629
+ }
630
+
631
+
576
632
  export function collectPolicyPermissions(workspaceRoot: string): string[] {
577
633
  const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
578
634
  policies?: Array<{ permissions?: string[] }>;
@@ -1757,12 +1813,10 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1757
1813
  readText(workspaceRoot, "web/src/main.tsx"),
1758
1814
  readText(workspaceRoot, "web/src/App.tsx"),
1759
1815
  ].join("\n");
1760
- const appShellUsesWorkOSProvider =
1761
- frontendAppShell.includes("ForgeWorkOSAuthProvider") ||
1762
- frontendAppShell.includes("AuthKitProvider");
1763
- const authBridgeProvidesToken =
1764
- generatedFrontendAuthBridge.includes("getToken") ||
1765
- generatedFrontendAuthBridge.includes("getAccessToken");
1816
+ const appShellUsesWorkOSProvider = frontendAppShell.includes("ForgeWorkOSAuthProvider");
1817
+ const authBridgeUsesBackendRoutes =
1818
+ generatedFrontendAuthBridge.includes("workOSApiUrl") &&
1819
+ ["/login", "/logout", "/session"].every((route) => generatedFrontendAuthBridge.includes(route));
1766
1820
  const authBridgeProvidesSessionClaims =
1767
1821
  includesAll(generatedFrontendAuthBridge, ["useForgeWorkOSSession", "/session", "claims"]);
1768
1822
  const authSessionProxyConfigured = webAuthSessionProxyConfigured(workspaceRoot);
@@ -1809,7 +1863,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1809
1863
  ok: !hasWeb || (hasValue(realEnv, "VITE_WORKOS_CLIENT_ID") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_CLIENT_ID=")) &&
1810
1864
  (hasValue(realEnv, "VITE_WORKOS_REDIRECT_URI") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_REDIRECT_URI=")),
1811
1865
  detail: hasWeb
1812
- ? "web workspace has VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI guidance for AuthKit React"
1866
+ ? "web workspace has browser WorkOS client ID and redirect URI guidance for backend-owned AuthKit routes"
1813
1867
  : "no web workspace detected",
1814
1868
  },
1815
1869
  {
@@ -1821,11 +1875,11 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1821
1875
  },
1822
1876
  {
1823
1877
  name: "browser-authkit-bridge",
1824
- ok: !hasWeb || includesAll(generatedFrontendAuthBridge, ["AuthKitProvider", "ForgeProvider"]) &&
1825
- authBridgeProvidesToken &&
1878
+ ok: !hasWeb || generatedFrontendAuthBridge.includes("ForgeProvider") &&
1879
+ authBridgeUsesBackendRoutes &&
1826
1880
  authBridgeProvidesSessionClaims,
1827
1881
  detail: hasWeb
1828
- ? "generated web/src/lib/workos-auth.tsx bridge provides AuthKitProvider, ForgeProvider token wiring, and normalized /session claims"
1882
+ ? "generated web/src/lib/workos-auth.tsx bridge uses backend-owned AuthKit routes, ForgeProvider, and normalized /session claims"
1829
1883
  : "no web workspace detected",
1830
1884
  },
1831
1885
  {
@@ -1841,7 +1895,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1841
1895
  name: "browser-authkit-provider",
1842
1896
  ok: !hasWeb || appShellUsesWorkOSProvider,
1843
1897
  detail: hasWeb
1844
- ? "web app shell mounts ForgeWorkOSAuthProvider or AuthKitProvider"
1898
+ ? "web app shell mounts ForgeWorkOSAuthProvider"
1845
1899
  : "no web workspace detected",
1846
1900
  },
1847
1901
  {
@@ -2486,6 +2540,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2486
2540
  const seed = parseSeedFile(options.workspaceRoot, file);
2487
2541
  const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
2488
2542
  const setupDryRun = options.dryRun || !options.real;
2543
+ const publicEnvSync = syncWorkOSPublicWebEnv(options.workspaceRoot, localOk && !setupDryRun);
2489
2544
  const configActions = runWorkOSConfigActions(seed, options, true);
2490
2545
  const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
2491
2546
  if (!localOk) {
@@ -2500,6 +2555,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2500
2555
  real: options.real ?? false,
2501
2556
  seed,
2502
2557
  seedState,
2558
+ publicEnvSync,
2503
2559
  ...(cliAuth ? { cliAuth } : {}),
2504
2560
  configActions,
2505
2561
  nextCommand: cliAuth && !cliAuth.ok
@@ -2521,6 +2577,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2521
2577
  real: false,
2522
2578
  seed,
2523
2579
  seedState,
2580
+ publicEnvSync,
2524
2581
  ...(cliAuth ? { cliAuth } : {}),
2525
2582
  configActions,
2526
2583
  nextCommand: `forge workos setup --real --file ${file} --json`,
@@ -2546,6 +2603,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2546
2603
  data: {
2547
2604
  real: true,
2548
2605
  seed,
2606
+ publicEnvSync,
2549
2607
  ...(cliAuth ? { cliAuth } : {}),
2550
2608
  seedState: seedResultData.seedState ?? readWorkOSSeedState(options.workspaceRoot, seed),
2551
2609
  seedResult: seedResult.data,
@@ -31,6 +31,7 @@ import {
31
31
  resolveByPackageName,
32
32
  resolveRecipe,
33
33
  } from "../recipes/registry.ts";
34
+ import { applyWorkOSRecipeProfile } from "../recipes/profiles.ts";
34
35
  import { discover } from "../orchestrator/discover.ts";
35
36
  import {
36
37
  loadManifest,
@@ -92,24 +93,6 @@ function recipeResultMetadata(recipe: IntegrationRecipe): Pick<
92
93
  };
93
94
  }
94
95
 
95
- const WORKOS_FGA_INTEGRATION_FILES = new Set([
96
- "workos/fga.ts",
97
- "workos/resource-map.ts",
98
- ]);
99
-
100
- function applyWorkOSRecipeProfile(
101
- recipe: NonNullable<ReturnType<typeof resolveRecipe>>,
102
- options: Pick<ForgeAddOptions, "withFga">,
103
- ): IntegrationRecipe {
104
- if (recipe.alias !== "workos" || options.withFga) {
105
- return recipe;
106
- }
107
- return {
108
- ...recipe,
109
- integrations: recipe.integrations?.filter((file) => !WORKOS_FGA_INTEGRATION_FILES.has(file)),
110
- };
111
- }
112
-
113
96
  function addExplanation(result: ForgeAddResult): string {
114
97
  if (result.mode === "package") {
115
98
  const location = result.target && result.target !== "root" ? `${result.target}/package.json` : "package.json";
@@ -528,6 +511,60 @@ function workosFrontendWorkspace(workspaceRoot: string): string | undefined {
528
511
  return findFrontendWorkspace(workspaceRoot);
529
512
  }
530
513
 
514
+ function parseDotEnv(text: string | null): Record<string, string> {
515
+ const result: Record<string, string> = {};
516
+ for (const line of (text ?? "").split(/\r?\n/)) {
517
+ const trimmed = line.trim();
518
+ if (!trimmed || trimmed.startsWith("#")) continue;
519
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
520
+ if (!match) continue;
521
+ result[match[1]!] = match[2]!.replace(/^["']|["']$/g, "");
522
+ }
523
+ return result;
524
+ }
525
+
526
+ function renderMergedDotEnv(current: string | null, values: Record<string, string>): string {
527
+ const lines = (current ?? "").split(/\r?\n/);
528
+ const seen = new Set<string>();
529
+ const next = lines.map((line) => {
530
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
531
+ if (!match || !(match[1]! in values)) {
532
+ return line;
533
+ }
534
+ seen.add(match[1]!);
535
+ return `${match[1]}=${values[match[1]!]}`;
536
+ });
537
+ for (const [key, value] of Object.entries(values)) {
538
+ if (!seen.has(key)) {
539
+ next.push(`${key}=${value}`);
540
+ }
541
+ }
542
+ return `${next.filter((line, index, all) => line.length > 0 || index < all.length - 1).join("\n").trimEnd()}\n`;
543
+ }
544
+
545
+ function syncWorkOSPublicWebEnv(workspaceRoot: string, frontendWorkspace: string): { changed: string[] } {
546
+ const rootLocal = parseDotEnv(nodeFileSystem.readText(join(workspaceRoot, ".env.local")));
547
+ const rootExample = parseDotEnv(nodeFileSystem.readText(join(workspaceRoot, ".env.example")));
548
+ const source = { ...rootExample, ...rootLocal };
549
+ const clientId = source.VITE_WORKOS_CLIENT_ID || source.WORKOS_CLIENT_ID || "";
550
+ const redirectUri = source.VITE_WORKOS_REDIRECT_URI || source.WORKOS_REDIRECT_URI || "http://localhost:5173/callback";
551
+ const values: Record<string, string> = {
552
+ VITE_FORGE_URL: source.VITE_FORGE_URL || "http://localhost:3765",
553
+ VITE_WORKOS_CLIENT_ID: clientId,
554
+ VITE_WORKOS_REDIRECT_URI: redirectUri,
555
+ };
556
+ const envRel = `${frontendWorkspace}/.env.local`;
557
+ const envPath = join(workspaceRoot, envRel);
558
+ const current = nodeFileSystem.readText(envPath);
559
+ const next = renderMergedDotEnv(current, values);
560
+ if (current === next) {
561
+ return { changed: [] };
562
+ }
563
+ nodeFileSystem.mkdirp(join(workspaceRoot, frontendWorkspace));
564
+ nodeFileSystem.writeText(envPath, next);
565
+ return { changed: [envRel] };
566
+ }
567
+
531
568
  function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string): {
532
569
  changed: string[];
533
570
  warnings: Diagnostic[];
@@ -610,7 +647,7 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
610
647
  " <p className=\"notice error\">{workosSession.error.message}</p>",
611
648
  " <div className=\"login-form\">",
612
649
  " <button type=\"button\" onClick={() => void workosSession.refresh()}>Retry session</button>",
613
- " <button className=\"secondary\" type=\"button\" onClick={() => auth.signOut({ returnTo: window.location.origin })}>Sign out</button>",
650
+ " <button className=\"secondary\" type=\"button\" onClick={() => auth.signOut()}>Sign out</button>",
614
651
  " </div>",
615
652
  " </section>",
616
653
  " </main>",
@@ -631,8 +668,8 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
631
668
  " <p className=\"eyebrow\">WorkOS AuthKit</p>",
632
669
  " <h1>Sign in to review vendor access</h1>",
633
670
  " <div className=\"login-form\">",
634
- " <button type=\"button\" onClick={() => void auth.signIn()}>Sign in with WorkOS</button>",
635
- " <button className=\"secondary\" type=\"button\" onClick={() => void auth.signUp()}>Create account</button>",
671
+ " <button type=\"button\" onClick={() => auth.signIn()}>Sign in with WorkOS</button>",
672
+ " <button className=\"secondary\" type=\"button\" onClick={() => auth.signUp()}>Create account</button>",
636
673
  " </div>",
637
674
  " </section>",
638
675
  " <aside className=\"login-context\">",
@@ -664,7 +701,7 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
664
701
  " persona={persona}",
665
702
  " personas={[persona]}",
666
703
  " onPersonaChange={() => undefined}",
667
- " onSignOut={() => auth.signOut({ returnTo: window.location.origin })}",
704
+ " onSignOut={() => auth.signOut()}",
668
705
  " />",
669
706
  " );",
670
707
  "}",
@@ -1103,7 +1140,11 @@ export async function forgeAdd(
1103
1140
  });
1104
1141
 
1105
1142
  const workosWeb = normalized === "workos" && frontendWorkspace
1106
- ? connectWorkOSReactRoot(options.workspaceRoot, frontendWorkspace)
1143
+ ? (() => {
1144
+ const root = connectWorkOSReactRoot(options.workspaceRoot, frontendWorkspace);
1145
+ const env = syncWorkOSPublicWebEnv(options.workspaceRoot, frontendWorkspace);
1146
+ return { changed: [...root.changed, ...env.changed], warnings: root.warnings };
1147
+ })()
1107
1148
  : { changed: [] as string[], warnings: [] as Diagnostic[] };
1108
1149
 
1109
1150
  const warningsCombined = [...preinstalledWarnings, ...warnings, ...emitResult.warnings, ...workosWeb.warnings];
@@ -285,6 +285,7 @@ export function renderWorkosEnvExample(_input: IntegrationTemplateInput): string
285
285
  "WORKOS_WEBHOOK_SECRET=",
286
286
  "",
287
287
  "# Browser AuthKit settings for Vite/React. These are public client identifiers, not secrets.",
288
+ "VITE_FORGE_URL=http://localhost:3765",
288
289
  "VITE_WORKOS_CLIENT_ID=",
289
290
  "VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback",
290
291
  "",
@@ -299,12 +300,22 @@ export function renderWorkosReactBridge(_input: IntegrationTemplateInput): strin
299
300
  "/** Forge generated WorkOS/AuthKit React bridge.",
300
301
  " * Import ForgeWorkOSAuthProvider from web/src/main.tsx, or copy the pattern into your app shell.",
301
302
  " */",
302
- 'import { AuthKitProvider, useAuth as useWorkOSAuth } from "@workos-inc/authkit-react";',
303
303
  'import { useEffect, useState, type ReactNode } from "react";',
304
304
  'import { ForgeProvider, forgeUrl } from "./forge";',
305
305
  "",
306
306
  "const clientId = import.meta.env.VITE_WORKOS_CLIENT_ID as string | undefined;",
307
307
  "const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI as string | undefined;",
308
+ "const forgeApiUrl = import.meta.env.VITE_FORGE_URL as string | undefined;",
309
+ "",
310
+ "function workOSApiUrl(path: string): URL {",
311
+ " if (typeof window !== 'undefined' && forgeApiUrl) {",
312
+ " const configured = new URL(forgeApiUrl, window.location.origin);",
313
+ " if ((configured.hostname === 'localhost' || configured.hostname === '127.0.0.1') && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {",
314
+ " return new URL(path, window.location.origin);",
315
+ " }",
316
+ " }",
317
+ " return new URL(path, forgeApiUrl || forgeUrl || window.location.origin);",
318
+ "}",
308
319
  "",
309
320
  "export function hasWorkOSBrowserConfig(): boolean {",
310
321
  " return Boolean(clientId && redirectUri);",
@@ -315,24 +326,7 @@ export function renderWorkosReactBridge(_input: IntegrationTemplateInput): strin
315
326
  " throw new Error(\"WorkOS AuthKit requires VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI\");",
316
327
  " }",
317
328
  " return (",
318
- " <AuthKitProvider clientId={clientId} redirectUri={redirectUri}>",
319
- " <ForgeWorkOSBearerProvider>{children}</ForgeWorkOSBearerProvider>",
320
- " </AuthKitProvider>",
321
- " );",
322
- "}",
323
- "",
324
- "function ForgeWorkOSBearerProvider({ children }: { children: ReactNode }) {",
325
- " const auth = useWorkOSAuth();",
326
- " return (",
327
- " <ForgeProvider",
328
- " url={forgeUrl}",
329
- " auth={{",
330
- " getToken: async () => {",
331
- " const token = await auth.getAccessToken();",
332
- " return token ?? null;",
333
- " },",
334
- " }}",
335
- " >",
329
+ " <ForgeProvider url={forgeUrl}>",
336
330
  " {children}",
337
331
  " </ForgeProvider>",
338
332
  " );",
@@ -360,7 +354,7 @@ export function renderWorkosReactBridge(_input: IntegrationTemplateInput): strin
360
354
  " setLoading(true);",
361
355
  " setError(null);",
362
356
  " try {",
363
- " const response = await fetch('/session', { credentials: 'include' });",
357
+ " const response = await fetch(workOSApiUrl('/session'), { credentials: 'include' });",
364
358
  " if (response.status === 401) {",
365
359
  " setSession(null);",
366
360
  " return;",
@@ -385,6 +379,30 @@ export function renderWorkosReactBridge(_input: IntegrationTemplateInput): strin
385
379
  " return { loading, session, error, refresh };",
386
380
  "}",
387
381
  "",
382
+ "export function useWorkOSAuth() {",
383
+ " const session = useForgeWorkOSSession();",
384
+ " const user = session.session?.user ?? null;",
385
+ " return {",
386
+ " isLoading: session.loading,",
387
+ " user,",
388
+ " session: session.session,",
389
+ " sessionError: session.error,",
390
+ " refresh: session.refresh,",
391
+ " signIn: () => {",
392
+ " const url = workOSApiUrl('/login');",
393
+ " url.searchParams.set('returnTo', window.location.pathname + window.location.search);",
394
+ " window.location.assign(url);",
395
+ " },",
396
+ " signUp: () => {",
397
+ " const url = workOSApiUrl('/login');",
398
+ " url.searchParams.set('returnTo', window.location.pathname + window.location.search);",
399
+ " window.location.assign(url);",
400
+ " },",
401
+ " signOut: () => { void fetch(workOSApiUrl('/logout'), { method: 'POST', credentials: 'include' }).finally(() => { window.location.assign('/'); }); },",
402
+ " getAccessToken: async () => null,",
403
+ " };",
404
+ "}",
405
+ "",
388
406
  "export function WorkOSLoginPanel() {",
389
407
  " const { isLoading, user, signIn, signUp, signOut } = useWorkOSAuth();",
390
408
  " useEffect(() => {",
@@ -405,12 +423,11 @@ export function renderWorkosReactBridge(_input: IntegrationTemplateInput): strin
405
423
  " return (",
406
424
  " <div>",
407
425
  " <span>{user.email}</span>",
408
- " <button type=\"button\" onClick={() => signOut({ returnTo: window.location.origin })}>Sign out</button>",
426
+ " <button type=\"button\" onClick={() => signOut()}>Sign out</button>",
409
427
  " </div>",
410
428
  " );",
411
429
  "}",
412
430
  "",
413
- "export { useWorkOSAuth };",
414
431
  "",
415
432
  ].join("\n");
416
433
  }
@@ -829,7 +846,7 @@ export function renderWorkosAuthRoutes(_input: IntegrationTemplateInput): string
829
846
  "export interface WorkOSAuthRoutesOptions {",
830
847
  " workos: WorkOS;",
831
848
  " config: ForgeWorkOSAuthKitConfig;",
832
- " resolveOrganization?: (input: { user: Record<string, unknown>; accessToken: string }) => Promise<{ organizationId?: string; organizationMembershipId?: string; role?: string; roles?: string[]; permissions?: string[] }> | { organizationId?: string; organizationMembershipId?: string; role?: string; roles?: string[]; permissions?: string[] };",
849
+ " resolveOrganization?: (input: { user: Record<string, unknown>; accessToken: string; organizationId?: string; organizationMembershipId?: string; result: Record<string, unknown> }) => Promise<{ organizationId?: string; organizationMembershipId?: string; role?: string; roles?: string[]; permissions?: string[] }> | { organizationId?: string; organizationMembershipId?: string; role?: string; roles?: string[]; permissions?: string[] };",
833
850
  "}",
834
851
  "",
835
852
  "function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response {",
@@ -863,12 +880,15 @@ export function renderWorkosAuthRoutes(_input: IntegrationTemplateInput): string
863
880
  "",
864
881
  "export function getWorkOSAuthorizationUrl(input: { workos: WorkOS; config: ForgeWorkOSAuthKitConfig; state?: string }): string {",
865
882
  " if (!input.config.redirectUri) throw new Error(\"WORKOS_REDIRECT_URI is required\");",
866
- " return input.workos.userManagement.getAuthorizationUrl({",
883
+ " const url = new URL(input.workos.userManagement.getAuthorizationUrl({",
867
884
  " provider: \"authkit\",",
868
885
  " redirectUri: input.config.redirectUri,",
869
886
  " clientId: input.config.clientId,",
870
887
  " ...(input.state ? { state: input.state } : {}),",
871
- " });",
888
+ " }));",
889
+ " url.searchParams.set(\"response_type\", \"code\");",
890
+ " url.searchParams.delete(\"organization_id\");",
891
+ " return url.toString();",
872
892
  "}",
873
893
  "",
874
894
  "export async function authenticateWorkOSCallback(input: { workos: WorkOS; config: ForgeWorkOSAuthKitConfig; code: string; resolveOrganization?: WorkOSAuthRoutesOptions[\"resolveOrganization\"] }): Promise<ForgeWorkOSSession> {",
@@ -877,19 +897,34 @@ export function renderWorkosAuthRoutes(_input: IntegrationTemplateInput): string
877
897
  " clientId: input.config.clientId,",
878
898
  " });",
879
899
  " const user = workOSUserRecord(result.user);",
900
+ " const rawResult = result as Record<string, unknown>;",
901
+ " const resultOrganization = (rawResult.organization ?? {}) as Record<string, unknown>;",
902
+ " const resultMembership = (rawResult.organizationMembership ?? {}) as Record<string, unknown>;",
903
+ " const resultOrganizationId = typeof result.organizationId === \"string\" ? result.organizationId : typeof resultOrganization.id === \"string\" ? resultOrganization.id : undefined;",
904
+ " const resultOrganizationMembershipId = typeof result.organizationMembershipId === \"string\" ? result.organizationMembershipId : typeof resultMembership.id === \"string\" ? resultMembership.id : undefined;",
880
905
  " const resolved = await input.resolveOrganization?.({",
881
906
  " user,",
882
907
  " accessToken: result.accessToken,",
908
+ " organizationId: resultOrganizationId,",
909
+ " organizationMembershipId: resultOrganizationMembershipId,",
910
+ " result: rawResult,",
883
911
  " });",
912
+ " const resultRoles = Array.isArray(result.roles) ? result.roles.filter((role): role is string => typeof role === \"string\") : [];",
913
+ " const resultPermissions = Array.isArray(result.permissions) ? result.permissions.filter((permission): permission is string => typeof permission === \"string\") : [];",
914
+ " const organizationId = resolved?.organizationId ?? resultOrganizationId;",
915
+ " const organizationMembershipId = resolved?.organizationMembershipId ?? resultOrganizationMembershipId;",
916
+ " const role = resolved?.role ?? (typeof result.role === \"string\" ? result.role : resultRoles[0]);",
917
+ " const roles = resolved?.roles ?? resultRoles;",
918
+ " const permissions = resolved?.permissions ?? resultPermissions;",
884
919
  " return {",
885
920
  " user: workOSSessionUser(user),",
886
921
  " accessToken: result.accessToken,",
887
922
  " ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}),",
888
- " ...(resolved?.organizationId ? { organizationId: resolved.organizationId } : {}),",
889
- " ...(resolved?.organizationMembershipId ? { organizationMembershipId: resolved.organizationMembershipId } : {}),",
890
- " ...(resolved?.role ? { role: resolved.role } : {}),",
891
- " ...(resolved?.roles ? { roles: resolved.roles } : {}),",
892
- " ...(resolved?.permissions ? { permissions: resolved.permissions } : {}),",
923
+ " ...(organizationId ? { organizationId } : {}),",
924
+ " ...(organizationMembershipId ? { organizationMembershipId } : {}),",
925
+ " ...(role ? { role } : {}),",
926
+ " ...(roles.length > 0 ? { roles } : {}),",
927
+ " ...(permissions.length > 0 ? { permissions } : {}),",
893
928
  " issuedAt: Math.floor(Date.now() / 1000),",
894
929
  " };",
895
930
  "}",
@@ -16,6 +16,7 @@ import { buildRuntimeMatrix } from "../classifier/runtime-matrix.ts";
16
16
  import { emit } from "../emitter/emit.ts";
17
17
  import { PackageGraphCompiler } from "../package-graph/compiler.ts";
18
18
  import { resolveByPackageName } from "../recipes/registry.ts";
19
+ import { applyWorkspaceRecipeProfile } from "../recipes/profiles.ts";
19
20
  import { checkImportGuards } from "./guards.ts";
20
21
  import {
21
22
  saveManifest,
@@ -45,9 +46,10 @@ import { verifyLockIntegrity } from "./verify.ts";
45
46
 
46
47
  function classifyPackages(
47
48
  packageGraph: Awaited<ReturnType<PackageGraphCompiler["build"]>>["graph"],
49
+ workspaceRoot: string,
48
50
  ): ClassifiedPackage[] {
49
51
  return packageGraph.packages.map((api) => {
50
- const recipe = resolveByPackageName(api.name) ?? undefined;
52
+ const recipe = applyWorkspaceRecipeProfile(resolveByPackageName(api.name), workspaceRoot);
51
53
  return {
52
54
  api,
53
55
  classification: classify(api, recipe),
@@ -171,7 +173,7 @@ async function runUnlocked(options: GenerateOptions): Promise<GenerateResult> {
171
173
  if (profileEnabled) {
172
174
  resetSignalProfile();
173
175
  }
174
- const classified = classifyPackages(pkgResult.graph);
176
+ const classified = classifyPackages(pkgResult.graph, options.workspaceRoot);
175
177
  const classifierSignals = profileEnabled ? getSignalProfile() : undefined;
176
178
  if (profileEnabled) {
177
179
  clearSignalProfile();
@@ -0,0 +1,81 @@
1
+ import { join } from "node:path";
2
+ import { nodeFileSystem } from "../fs/index.ts";
3
+ import { FORGE_LOCK_PATH, GENERATED_DIR } from "../emitter/constants.ts";
4
+ import type { IntegrationRecipe } from "../types/integration.ts";
5
+ import type { ForgeLock } from "../types/lock.ts";
6
+
7
+ export const WORKOS_FGA_INTEGRATION_FILES = new Set([
8
+ "workos/fga.ts",
9
+ "workos/resource-map.ts",
10
+ ]);
11
+
12
+ function filterWorkOSFga(recipe: IntegrationRecipe): IntegrationRecipe {
13
+ return {
14
+ ...recipe,
15
+ integrations: recipe.integrations?.filter((file) => !WORKOS_FGA_INTEGRATION_FILES.has(file)),
16
+ };
17
+ }
18
+
19
+ export function applyWorkOSRecipeProfile(
20
+ recipe: IntegrationRecipe,
21
+ options: { withFga?: boolean },
22
+ ): IntegrationRecipe {
23
+ if (recipe.alias !== "workos" || options.withFga) {
24
+ return recipe;
25
+ }
26
+ return filterWorkOSFga(recipe);
27
+ }
28
+
29
+ function readForgeLock(workspaceRoot: string): ForgeLock | null {
30
+ const absolute = join(workspaceRoot, FORGE_LOCK_PATH);
31
+ if (!nodeFileSystem.exists(absolute)) {
32
+ return null;
33
+ }
34
+ try {
35
+ return JSON.parse(nodeFileSystem.readText(absolute) ?? "") as ForgeLock;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function lockEnablesWorkOSFga(lock: ForgeLock | null): boolean {
42
+ const entry = lock?.packages.find((pkg) => pkg.name === "@workos-inc/node" || pkg.name === "workos");
43
+ return Boolean(entry?.generatedFiles.some((file) =>
44
+ file === `${GENERATED_DIR}/integrations/workos/fga.ts` ||
45
+ file === `${GENERATED_DIR}/integrations/workos/resource-map.ts`
46
+ ));
47
+ }
48
+
49
+ function seedEnablesWorkOSFga(workspaceRoot: string): boolean {
50
+ for (const path of [
51
+ "workos-seed.yml",
52
+ `${GENERATED_DIR}/integrations/workos/workos-seed.yml`,
53
+ ]) {
54
+ const text = nodeFileSystem.readText(join(workspaceRoot, path));
55
+ if (text !== null && /^\s*resource_types\s*:/m.test(text)) {
56
+ return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+
62
+ function workspaceEnablesWorkOSFga(workspaceRoot: string): boolean {
63
+ return nodeFileSystem.exists(join(workspaceRoot, ".workos-fga-state.json")) ||
64
+ seedEnablesWorkOSFga(workspaceRoot) ||
65
+ lockEnablesWorkOSFga(readForgeLock(workspaceRoot));
66
+ }
67
+
68
+ export function applyWorkspaceRecipeProfile(
69
+ recipe: IntegrationRecipe | null | undefined,
70
+ workspaceRoot: string,
71
+ ): IntegrationRecipe | undefined {
72
+ if (!recipe) {
73
+ return undefined;
74
+ }
75
+ if (recipe.alias !== "workos") {
76
+ return recipe;
77
+ }
78
+ return applyWorkOSRecipeProfile(recipe, {
79
+ withFga: workspaceEnablesWorkOSFga(workspaceRoot),
80
+ });
81
+ }
@@ -24,6 +24,8 @@ import {
24
24
  import { authenticateHeaders } from "../runtime/auth/authenticate.ts";
25
25
  import { loadAuthConfigFromEnv } from "../runtime/auth/config.ts";
26
26
  import { ForgeAuthError } from "../runtime/auth/errors.ts";
27
+ import { resolveAuthFromHeaders } from "../runtime/auth/resolve.ts";
28
+ import type { AuthContext } from "../runtime/auth/types.ts";
27
29
  import type { TableMapEntry } from "../compiler/data-graph/sql/serialize.ts";
28
30
  import type { SqlPlan } from "../compiler/data-graph/sql/types.ts";
29
31
  import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
@@ -506,6 +508,61 @@ function workOSDevSessionClaims(session: WorkOSDevSession): Record<string, unkno
506
508
  };
507
509
  }
508
510
 
511
+ function authFromWorkOSDevSessionCookie(
512
+ request: Request,
513
+ envStore: ReturnType<typeof getRuntimeEnvStore>,
514
+ ): AuthContext | null {
515
+ const cookiePassword = envStore.resolve("WORKOS_COOKIE_PASSWORD");
516
+ if (!cookiePassword) {
517
+ return null;
518
+ }
519
+ const session = decodeWorkOSDevSession(
520
+ readRequestCookie(request, workOSDevCookieName(envStore)),
521
+ cookiePassword,
522
+ );
523
+ if (!session) {
524
+ return null;
525
+ }
526
+ const claims = workOSDevSessionClaims(session);
527
+ return resolveAuthFromHeaders({
528
+ userId: typeof claims.sub === "string" ? claims.sub : undefined,
529
+ tenantId: typeof claims.organization_id === "string" ? claims.organization_id : undefined,
530
+ organizationId: typeof claims.organization_id === "string" ? claims.organization_id : undefined,
531
+ organizationMembershipId:
532
+ typeof claims.organization_membership_id === "string" ? claims.organization_membership_id : undefined,
533
+ role: typeof claims.role === "string" ? claims.role : undefined,
534
+ roles: Array.isArray(claims.roles) ? claims.roles.filter((role): role is string => typeof role === "string") : [],
535
+ permissions: Array.isArray(claims.permissions)
536
+ ? claims.permissions.filter((permission): permission is string => typeof permission === "string")
537
+ : [],
538
+ claims,
539
+ });
540
+ }
541
+
542
+ async function authenticateDevRequest(
543
+ request: Request,
544
+ authConfig: ReturnType<typeof loadAuthConfigFromEnv>,
545
+ envStore: ReturnType<typeof getRuntimeEnvStore>,
546
+ ): Promise<AuthContext> {
547
+ if (authConfig.mode === "dev-headers") {
548
+ const headerAuth = await authenticateHeaders(request.headers, authConfig);
549
+ if (headerAuth.kind !== "anonymous") {
550
+ return headerAuth;
551
+ }
552
+ return authFromWorkOSDevSessionCookie(request, envStore) ?? headerAuth;
553
+ }
554
+
555
+ try {
556
+ return await authenticateHeaders(request.headers, authConfig);
557
+ } catch (error) {
558
+ const cookieAuth = authFromWorkOSDevSessionCookie(request, envStore);
559
+ if (cookieAuth) {
560
+ return cookieAuth;
561
+ }
562
+ throw error;
563
+ }
564
+ }
565
+
509
566
  function createWorkOSDevSessionCookie(
510
567
  envStore: ReturnType<typeof getRuntimeEnvStore>,
511
568
  session: WorkOSDevSession,
@@ -539,17 +596,64 @@ function workOSConfigDiagnostics(envStore: ReturnType<typeof getRuntimeEnvStore>
539
596
  );
540
597
  }
541
598
 
542
- function workOSAuthorizationUrl(envStore: ReturnType<typeof getRuntimeEnvStore>, state: string): string {
599
+ function workOSAuthorizationUrl(
600
+ envStore: ReturnType<typeof getRuntimeEnvStore>,
601
+ state: string,
602
+ organizationId?: string,
603
+ ): string {
543
604
  const url = new URL("https://api.workos.com/user_management/authorize");
605
+ url.searchParams.set("response_type", "code");
544
606
  url.searchParams.set("provider", "authkit");
545
607
  url.searchParams.set("client_id", envStore.resolve("WORKOS_CLIENT_ID") ?? "");
546
608
  url.searchParams.set("redirect_uri", envStore.resolve("WORKOS_REDIRECT_URI") ?? "");
609
+ const effectiveOrganizationId = normalizeOptionalId(organizationId);
610
+ if (effectiveOrganizationId) {
611
+ url.searchParams.set("organization_id", effectiveOrganizationId);
612
+ }
547
613
  if (state) {
548
614
  url.searchParams.set("state", state);
549
615
  }
550
616
  return url.toString();
551
617
  }
552
618
 
619
+ function normalizeOptionalId(value: string | undefined): string | undefined {
620
+ const trimmed = value?.trim();
621
+ if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined;
622
+ return trimmed;
623
+ }
624
+
625
+ function uniqueStrings(values: Iterable<unknown>): string[] {
626
+ return [...new Set([...values].filter((value): value is string => typeof value === "string" && value.length > 0))];
627
+ }
628
+
629
+ function permissionsFromPolicyRegistry(workspaceRoot: string): string[] {
630
+ const registryPath = join(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`);
631
+ if (!existsSync(registryPath)) return [];
632
+ try {
633
+ const registry = readGeneratedJson<{
634
+ policies?: Array<{ permissions?: unknown[] }>;
635
+ }>(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`);
636
+ return uniqueStrings(((registry?.policies ?? [])).flatMap((policy) => policy.permissions ?? []));
637
+ } catch {
638
+ return [];
639
+ }
640
+ }
641
+
642
+ function deriveDevPermissionsForRole(workspaceRoot: string, role: string | undefined): string[] {
643
+ const permissions = permissionsFromPolicyRegistry(workspaceRoot);
644
+ if (permissions.length === 0) return [];
645
+ const normalizedRole = role?.toLowerCase();
646
+ if (normalizedRole && ["owner", "admin", "manager", "security"].includes(normalizedRole)) {
647
+ return permissions;
648
+ }
649
+ if (normalizedRole === "requester") {
650
+ return permissions.filter((permission) =>
651
+ /(^|:)read$|read|list|view|audit|request|demo:seed/i.test(permission),
652
+ );
653
+ }
654
+ return permissions.filter((permission) => /(^|:)read$|read|list|view|audit|demo:seed/i.test(permission));
655
+ }
656
+
553
657
  async function authenticateWorkOSCode(workspaceRoot: string, envStore: ReturnType<typeof getRuntimeEnvStore>, code: string) {
554
658
  const req = createRequire(join(workspaceRoot, "package.json"));
555
659
  const localEntry = join(workspaceRoot, "node_modules/@workos-inc/node/index.js");
@@ -569,14 +673,60 @@ async function authenticateWorkOSCode(workspaceRoot: string, envStore: ReturnTyp
569
673
  clientId: envStore.resolve("WORKOS_CLIENT_ID") ?? "",
570
674
  })
571
675
  : null;
572
- const userManagement = (client as { userManagement?: { authenticateWithCode?: (input: { code: string; clientId: string }) => Promise<Record<string, unknown>> } } | null)?.userManagement;
676
+ const userManagement = (client as {
677
+ userManagement?: {
678
+ authenticateWithCode?: (input: { code: string; clientId: string }) => Promise<Record<string, unknown>>;
679
+ listOrganizationMemberships?: (input: { userId?: string; organizationId?: string }) => Promise<unknown>;
680
+ };
681
+ } | null)?.userManagement;
573
682
  if (!userManagement?.authenticateWithCode) {
574
683
  throw new Error("@workos-inc/node does not expose userManagement.authenticateWithCode");
575
684
  }
576
- return userManagement.authenticateWithCode({
685
+ const result = await userManagement.authenticateWithCode({
577
686
  code,
578
687
  clientId: envStore.resolve("WORKOS_CLIENT_ID") ?? "",
579
688
  });
689
+ const user = (result.user ?? {}) as Record<string, unknown>;
690
+ const userId = typeof user.id === "string" ? user.id : undefined;
691
+ const defaultOrganizationId =
692
+ normalizeOptionalId(envStore.resolve("WORKOS_DEFAULT_ORGANIZATION_ID")) ||
693
+ normalizeOptionalId(envStore.resolve("WORKOS_ORGANIZATION_ID"));
694
+ const hasOrganization =
695
+ typeof result.organizationId === "string" ||
696
+ (typeof ((result.organization ?? {}) as Record<string, unknown>).id === "string");
697
+ if (!hasOrganization && userId && defaultOrganizationId && userManagement.listOrganizationMemberships) {
698
+ const membershipList = await userManagement.listOrganizationMemberships({
699
+ userId,
700
+ organizationId: defaultOrganizationId,
701
+ });
702
+ const data = (membershipList as { data?: unknown[] }).data;
703
+ const membership = Array.isArray(data)
704
+ ? data.find((item) => {
705
+ const record = item as Record<string, unknown>;
706
+ return record.status === undefined || record.status === "active";
707
+ }) as Record<string, unknown> | undefined
708
+ : undefined;
709
+ if (membership) {
710
+ const roleRecord = membership.role as Record<string, unknown> | undefined;
711
+ const roles = Array.isArray(membership.roles)
712
+ ? membership.roles
713
+ .map((role) => (typeof role === "string" ? role : (role as Record<string, unknown>).slug))
714
+ .filter((role): role is string => typeof role === "string")
715
+ : [];
716
+ const role = typeof roleRecord?.slug === "string" ? roleRecord.slug : roles[0];
717
+ return {
718
+ ...result,
719
+ organizationId: defaultOrganizationId,
720
+ organization: { id: defaultOrganizationId },
721
+ organizationMembershipId: typeof membership.id === "string" ? membership.id : undefined,
722
+ organizationMembership: membership,
723
+ ...(role ? { role } : {}),
724
+ ...(roles.length > 0 ? { roles } : role ? { roles: [role] } : {}),
725
+ permissions: deriveDevPermissionsForRole(workspaceRoot, role),
726
+ };
727
+ }
728
+ }
729
+ return result;
580
730
  }
581
731
 
582
732
  function acceptsHtml(request: Request): boolean {
@@ -1409,8 +1559,10 @@ export async function startDevServer(
1409
1559
  if (diagnostics.length > 0) {
1410
1560
  return jsonResponse({ ok: false, diagnostics }, 503);
1411
1561
  }
1412
- const returnTo = normalizeLocalRedirect(new URL(request.url).searchParams.get("returnTo"));
1413
- return redirectResponse(workOSAuthorizationUrl(envStore, returnTo));
1562
+ const url = new URL(request.url);
1563
+ const returnTo = normalizeLocalRedirect(url.searchParams.get("returnTo"));
1564
+ const organizationId = url.searchParams.get("organizationId") ?? url.searchParams.get("organization_id") ?? undefined;
1565
+ return redirectResponse(workOSAuthorizationUrl(envStore, returnTo, organizationId));
1414
1566
  }
1415
1567
 
1416
1568
  if (pathname === "/callback") {
@@ -1733,7 +1885,7 @@ export async function startDevServer(
1733
1885
  }
1734
1886
  }
1735
1887
 
1736
- const auth = await authenticateHeaders(request.headers, authConfig);
1888
+ const auth = await authenticateDevRequest(request, authConfig, getRuntimeEnvStore(workspaceRoot));
1737
1889
  const lastRevision = Number(
1738
1890
  request.headers.get("last-event-id") ??
1739
1891
  url.searchParams.get("lastRevision") ??
@@ -1857,7 +2009,7 @@ export async function startDevServer(
1857
2009
  400,
1858
2010
  );
1859
2011
  }
1860
- const auth = await authenticateHeaders(request.headers, authConfig);
2012
+ const auth = await authenticateDevRequest(request, authConfig, getRuntimeEnvStore(workspaceRoot));
1861
2013
  const envStore = getRuntimeEnvStore(workspaceRoot);
1862
2014
  const secretRegistry = loadSecretRegistry(workspaceRoot);
1863
2015
  const bundle = createRuntimeSecretsBundle({
@@ -1977,7 +2129,7 @@ export async function startDevServer(
1977
2129
  }
1978
2130
 
1979
2131
  const purpose = body.purpose ?? "dev_agent_chat";
1980
- const auth = await authenticateHeaders(request.headers, authConfig);
2132
+ const auth = await authenticateDevRequest(request, authConfig, getRuntimeEnvStore(workspaceRoot));
1981
2133
  const envStore = getRuntimeEnvStore(workspaceRoot);
1982
2134
  const secretRegistry = loadSecretRegistry(workspaceRoot);
1983
2135
  const bundle = createRuntimeSecretsBundle({
@@ -2392,7 +2544,7 @@ export async function startDevServer(
2392
2544
  const externalInvoke = parseExternalInvoke(pathname);
2393
2545
  if (externalInvoke) {
2394
2546
  const args = await parseRequestArgs(request);
2395
- const auth = await authenticateHeaders(request.headers, authConfig);
2547
+ const auth = await authenticateDevRequest(request, authConfig, getRuntimeEnvStore(workspaceRoot));
2396
2548
  const result = await runExternalEntry(
2397
2549
  workspaceRoot,
2398
2550
  {
@@ -2460,7 +2612,7 @@ export async function startDevServer(
2460
2612
  }
2461
2613
 
2462
2614
  const args = await parseRequestArgs(request);
2463
- const auth = await authenticateHeaders(request.headers, authConfig);
2615
+ const auth = await authenticateDevRequest(request, authConfig, getRuntimeEnvStore(workspaceRoot));
2464
2616
 
2465
2617
  const result = await runQuery(
2466
2618
  workspaceRoot,
@@ -2533,7 +2685,7 @@ export async function startDevServer(
2533
2685
  }
2534
2686
 
2535
2687
  const args = await parseRequestArgs(request);
2536
- const auth = await authenticateHeaders(request.headers, authConfig);
2688
+ const auth = await authenticateDevRequest(request, authConfig, getRuntimeEnvStore(workspaceRoot));
2537
2689
 
2538
2690
  await prepareRuntimeEnvironment(workspaceRoot, {
2539
2691
  mock: options.mock,
@@ -233,20 +233,6 @@ function hasWorkOSIntegration(workspaceRoot: string): boolean {
233
233
  nodeFileSystem.exists(join(workspaceRoot, `${GENERATED}/integrations/workos/auth-routes.ts`));
234
234
  }
235
235
 
236
- function webPackageHasAuthKit(workspaceRoot: string): boolean {
237
- const path = join(workspaceRoot, "web/package.json");
238
- if (!nodeFileSystem.exists(path)) return false;
239
- try {
240
- const packageJson = JSON.parse(nodeFileSystem.readText(path) ?? "{}") as {
241
- dependencies?: Record<string, string>;
242
- devDependencies?: Record<string, string>;
243
- };
244
- return Boolean(packageJson.dependencies?.["@workos-inc/authkit-react"] ?? packageJson.devDependencies?.["@workos-inc/authkit-react"]);
245
- } catch {
246
- return false;
247
- }
248
- }
249
-
250
236
  type UiPackageManager = "bun" | "npm" | "pnpm" | "yarn";
251
237
 
252
238
  interface UiPackageContext {
@@ -1440,23 +1426,19 @@ function runUiAudit(options: UiCommandOptions): UiCommandResult {
1440
1426
  hasWorkOSIntegration(options.workspaceRoot) &&
1441
1427
  manifest.webRoot &&
1442
1428
  webSources.length > 0 &&
1443
- (!webPackageHasAuthKit(options.workspaceRoot) ||
1444
- (!/AuthKitProvider/.test(appShellText) && !/ForgeWorkOSAuthProvider/.test(appShellText)) ||
1445
- (!/getToken/.test(webImplementationText) && !/getAccessToken/.test(webImplementationText)))
1429
+ !/ForgeWorkOSAuthProvider/.test(appShellText)
1446
1430
  ) {
1447
1431
  diagnostics.push(diagnostic(
1448
1432
  "warning",
1449
1433
  "FORGE_UI_WORKOS_AUTHKIT_MISSING",
1450
- "WorkOS integration is present, but the web app does not appear to mount AuthKitProvider or pass a WorkOS token provider into ForgeProvider.",
1434
+ "WorkOS integration is present, but the web app does not appear to mount ForgeWorkOSAuthProvider.",
1451
1435
  ));
1452
1436
  }
1453
1437
  if (
1454
1438
  hasWorkOSIntegration(options.workspaceRoot) &&
1455
1439
  manifest.webRoot &&
1456
1440
  webSources.length > 0 &&
1457
- webPackageHasAuthKit(options.workspaceRoot) &&
1458
- (/AuthKitProvider/.test(appShellText) || /ForgeWorkOSAuthProvider/.test(appShellText)) &&
1459
- (/getToken/.test(webImplementationText) || /getAccessToken/.test(webImplementationText)) &&
1441
+ /ForgeWorkOSAuthProvider/.test(appShellText) &&
1460
1442
  (!webUsesWorkOSSessionClaims(webImplementationText) || !webConfigProxiesWorkOSSession(options.workspaceRoot, manifest.webRoot))
1461
1443
  ) {
1462
1444
  diagnostics.push(diagnostic(
@@ -1,3 +1,3 @@
1
- export const FORGEOS_VERSION = "0.1.0-alpha.56";
1
+ export const FORGEOS_VERSION = "0.1.0-alpha.57";
2
2
  export const GENERATOR_VERSION = FORGEOS_VERSION;
3
3
  export const CLI_VERSION = FORGEOS_VERSION;