forgeos 0.1.0-alpha.47 → 0.1.0-alpha.49

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.
@@ -1,21 +1,25 @@
1
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { basename, join } from "node:path";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
7
7
  import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
8
8
 
9
- export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup" | "prove";
9
+ export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup" | "prove" | "fga";
10
+ export type WorkOSFgaAction = "plan" | "sync" | "prove" | "doctor";
10
11
 
11
12
  export interface WorkOSCommandOptions {
12
13
  subcommand: WorkOSSubcommand;
14
+ fgaAction?: WorkOSFgaAction;
13
15
  workspaceRoot: string;
14
16
  json: boolean;
15
17
  file?: string;
16
18
  yes: boolean;
17
19
  dryRun: boolean;
18
20
  real?: boolean;
21
+ write?: boolean;
22
+ writePath?: string;
19
23
  commandRunner?: WorkOSCommandRunner;
20
24
  }
21
25
 
@@ -27,7 +31,7 @@ export interface WorkOSCheck {
27
31
 
28
32
  export interface WorkOSCommandResult {
29
33
  ok: boolean;
30
- kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup" | "workos-prove";
34
+ kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup" | "workos-prove" | "workos-fga";
31
35
  checks: WorkOSCheck[];
32
36
  command?: string[];
33
37
  applied?: boolean;
@@ -44,12 +48,15 @@ export type WorkOSCommandRunner = (
44
48
  cwd: string;
45
49
  encoding: "utf8";
46
50
  stdio: ["ignore", "pipe", "pipe"];
51
+ env?: Record<string, string | undefined>;
47
52
  },
48
53
  ) => { status: number | null; stdout: string; stderr: string };
49
54
 
50
55
  const DEFAULT_SEED_FILE = "workos-seed.yml";
51
56
  const GENERATED_SEED_FILE = `${GENERATED_DIR}/integrations/workos/workos-seed.yml`;
52
57
  const WORKOS_SEED_STATE_FILE = ".workos-seed-state.json";
58
+ const WORKOS_FGA_STATE_FILE = ".workos-fga-state.json";
59
+ const WORKOS_FGA_SETUP_GUIDE_FILE = ".forge/workos-fga-setup.md";
53
60
 
54
61
  function runExternalCommand(
55
62
  command: string[],
@@ -60,6 +67,11 @@ function runExternalCommand(
60
67
  cwd: options.workspaceRoot,
61
68
  encoding: "utf8",
62
69
  stdio: ["ignore", "pipe", "pipe"],
70
+ env: {
71
+ ...process.env,
72
+ ...readRealEnv(options.workspaceRoot),
73
+ WORKOS_MODE: process.env.WORKOS_MODE || "agent",
74
+ },
63
75
  });
64
76
  return {
65
77
  status: result.status,
@@ -329,12 +341,176 @@ function workosJwksUri(clientId: string | undefined): string {
329
341
  return clientId ? `https://api.workos.com/sso/jwks/${clientId}` : "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
330
342
  }
331
343
 
332
- function collectWorkOSRealEnvChecks(workspaceRoot: string): WorkOSCheck[] {
344
+ export interface WorkOSCliAuthSummary {
345
+ required: boolean;
346
+ ok: boolean;
347
+ method: "api-key" | "cli";
348
+ skippedReason?: string;
349
+ statusCommand: string[];
350
+ loginCommand?: string[];
351
+ loginAttempted: boolean;
352
+ authenticated?: boolean;
353
+ email?: string;
354
+ userId?: string;
355
+ tokenExpired?: boolean;
356
+ hasRefreshToken?: boolean;
357
+ activeEnvironment?: unknown;
358
+ status?: number | null;
359
+ loginStatus?: number | null;
360
+ detail: string;
361
+ loginInstructions?: {
362
+ url?: string;
363
+ code?: string;
364
+ message?: string;
365
+ };
366
+ nextActions: string[];
367
+ }
368
+
369
+ function parseJsonObject(text: string): Record<string, unknown> | null {
370
+ try {
371
+ const parsed = JSON.parse(text);
372
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
373
+ ? parsed as Record<string, unknown>
374
+ : null;
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ function stringField(value: unknown): string | undefined {
381
+ return typeof value === "string" && value.trim() ? value : undefined;
382
+ }
383
+
384
+ function booleanField(value: unknown): boolean | undefined {
385
+ return typeof value === "boolean" ? value : undefined;
386
+ }
387
+
388
+ function extractWorkOSLoginInstructions(stdout: string, stderr: string): WorkOSCliAuthSummary["loginInstructions"] | undefined {
389
+ const output = `${stdout}\n${stderr}`.trim();
390
+ if (!output) {
391
+ return undefined;
392
+ }
393
+ const url = output.match(/https?:\/\/[^\s"'<>]+/)?.[0];
394
+ const code = output.match(/(?:code|verification code|user code)[^\w]*([A-Z0-9-]{4,})/i)?.[1]
395
+ ?? output.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4}|[A-Z0-9]{6,12})\b/)?.[1];
396
+ const message = output
397
+ .split(/\r?\n/)
398
+ .map((line) => line.trim())
399
+ .filter(Boolean)
400
+ .slice(0, 12)
401
+ .join("\n");
402
+ return {
403
+ ...(url ? { url } : {}),
404
+ ...(code ? { code } : {}),
405
+ ...(message ? { message } : {}),
406
+ };
407
+ }
408
+
409
+ function workOSCliAuthCheck(auth: WorkOSCliAuthSummary): WorkOSCheck {
410
+ return {
411
+ name: "workos-cli-auth",
412
+ ok: auth.ok,
413
+ detail: auth.detail,
414
+ };
415
+ }
416
+
417
+ function ensureWorkOSCliAuthForHosted(options: WorkOSCommandOptions): WorkOSCliAuthSummary {
418
+ const env = readRealEnv(options.workspaceRoot);
419
+ const statusCommand = ["npx", "--yes", "workos@latest", "auth", "status", "--json"];
420
+ if (hasValue(env, "WORKOS_API_KEY")) {
421
+ return {
422
+ required: false,
423
+ ok: true,
424
+ method: "api-key",
425
+ skippedReason: "WORKOS_API_KEY is present; WorkOS CLI browser login is optional for hosted setup",
426
+ statusCommand,
427
+ loginAttempted: false,
428
+ detail: "WORKOS_API_KEY is present; hosted WorkOS setup can use API key authentication",
429
+ nextActions: [],
430
+ };
431
+ }
432
+
433
+ const status = runExternalCommand(statusCommand, options);
434
+ const parsed = parseJsonObject(status.stdout);
435
+ const authenticated = booleanField(parsed?.authenticated) ?? false;
436
+ const tokenExpired = booleanField(parsed?.tokenExpired) ?? false;
437
+ const hasRefreshToken = booleanField(parsed?.hasRefreshToken) ?? false;
438
+ const email = stringField(parsed?.email);
439
+ const userId = stringField(parsed?.userId);
440
+ const activeEnvironment = parsed?.activeEnvironment;
441
+ if (status.status === 0 && authenticated && (!tokenExpired || hasRefreshToken)) {
442
+ return {
443
+ required: true,
444
+ ok: true,
445
+ method: "cli",
446
+ statusCommand,
447
+ loginAttempted: false,
448
+ authenticated,
449
+ ...(email ? { email } : {}),
450
+ ...(userId ? { userId } : {}),
451
+ tokenExpired,
452
+ hasRefreshToken,
453
+ ...(activeEnvironment ? { activeEnvironment } : {}),
454
+ status: status.status,
455
+ detail: tokenExpired && hasRefreshToken
456
+ ? `WorkOS CLI is authenticated${email ? ` as ${email}` : ""}; token is expired but a refresh token is available`
457
+ : `WorkOS CLI is authenticated${email ? ` as ${email}` : ""}`,
458
+ nextActions: [],
459
+ };
460
+ }
461
+
462
+ const loginCommand = ["npx", "--yes", "workos@latest", "auth", "login", "--json"];
463
+ const login = runExternalCommand(loginCommand, options);
464
+ const instructions = extractWorkOSLoginInstructions(login.stdout, login.stderr);
465
+ if (login.status === 0) {
466
+ return {
467
+ required: true,
468
+ ok: true,
469
+ method: "cli",
470
+ statusCommand,
471
+ loginCommand,
472
+ loginAttempted: true,
473
+ authenticated: true,
474
+ status: status.status,
475
+ loginStatus: login.status,
476
+ detail: "WorkOS CLI login completed or was already authenticated",
477
+ ...(instructions ? { loginInstructions: instructions } : {}),
478
+ nextActions: [],
479
+ };
480
+ }
481
+
482
+ const nextActions = [
483
+ "complete the WorkOS CLI OAuth/device-code login shown in loginInstructions",
484
+ "rerun forge workos prove --real --file workos-seed.yml --json",
485
+ ];
486
+ return {
487
+ required: true,
488
+ ok: false,
489
+ method: "cli",
490
+ statusCommand,
491
+ loginCommand,
492
+ loginAttempted: true,
493
+ authenticated,
494
+ ...(email ? { email } : {}),
495
+ ...(userId ? { userId } : {}),
496
+ tokenExpired,
497
+ hasRefreshToken,
498
+ ...(activeEnvironment ? { activeEnvironment } : {}),
499
+ status: status.status,
500
+ loginStatus: login.status,
501
+ detail: instructions?.url || instructions?.code
502
+ ? "WorkOS CLI login is required; open the URL and enter the code from loginInstructions, then rerun the command"
503
+ : "WorkOS CLI login is required; run WORKOS_MODE=agent npx --yes workos@latest auth login --json and rerun the Forge command",
504
+ ...(instructions ? { loginInstructions: instructions } : {}),
505
+ nextActions,
506
+ };
507
+ }
508
+
509
+ function collectWorkOSRealEnvChecks(workspaceRoot: string, cliAuth?: WorkOSCliAuthSummary): WorkOSCheck[] {
333
510
  const env = readRealEnv(workspaceRoot);
334
511
  const authMode = env.FORGE_AUTH_MODE;
335
512
  const clientId = env.WORKOS_CLIENT_ID || env.VITE_WORKOS_CLIENT_ID;
336
513
  const required = [
337
- ["WORKOS_API_KEY", "WorkOS API key is required to apply hosted seed/config"],
338
514
  ["WORKOS_CLIENT_ID", "WorkOS client ID is required for AuthKit and JWKS discovery"],
339
515
  ["WORKOS_COOKIE_PASSWORD", "cookie password is required for AuthKit session signing"],
340
516
  ["FORGE_AUTH_ISSUER", "Forge OIDC issuer must be https://api.workos.com"],
@@ -349,6 +525,15 @@ function collectWorkOSRealEnvChecks(workspaceRoot: string): WorkOSCheck[] {
349
525
  ? `FORGE_AUTH_MODE=${authMode} is production-capable`
350
526
  : "FORGE_AUTH_MODE must be oidc or jwt before running hosted WorkOS proof",
351
527
  },
528
+ {
529
+ name: "real-env-workos_api_key-or-cli-auth",
530
+ ok: hasValue(env, "WORKOS_API_KEY") || cliAuth?.ok === true,
531
+ detail: hasValue(env, "WORKOS_API_KEY")
532
+ ? "WORKOS_API_KEY is present"
533
+ : cliAuth?.ok
534
+ ? "WORKOS_API_KEY is missing, but WorkOS CLI authentication is available for no-dashboard hosted setup"
535
+ : "WORKOS_API_KEY is missing and WorkOS CLI authentication is not complete; run WorkOS CLI login",
536
+ },
352
537
  ...required.map(([name, detail]) => ({
353
538
  name: `real-env-${name.toLowerCase()}`,
354
539
  ok: hasValue(env, name),
@@ -419,9 +604,945 @@ export function missingValues(expected: string[], actual: string[]): string[] {
419
604
  return expected.filter((value) => !actualSet.has(value));
420
605
  }
421
606
 
607
+ export interface WorkOSFgaResource {
608
+ externalId: string;
609
+ type: string;
610
+ tenant: string;
611
+ name: string;
612
+ parentExternalId?: string;
613
+ parentType?: string;
614
+ }
615
+
616
+ export interface WorkOSFgaProofScenario {
617
+ name: string;
618
+ expected: "allow" | "deny";
619
+ permission: string;
620
+ resourceExternalId: string;
621
+ resourceTypeSlug: string;
622
+ organization: string;
623
+ reason: string;
624
+ }
625
+
626
+ export interface WorkOSFgaManifest {
627
+ schemaVersion: "0.1.0";
628
+ provider: "workos";
629
+ kind: "fga-manifest";
630
+ seedFile: string;
631
+ seedHash?: string;
632
+ manifestHash: string;
633
+ permissions: string[];
634
+ roles: string[];
635
+ resourceTypes: string[];
636
+ organizations: string[];
637
+ resources: WorkOSFgaResource[];
638
+ proofScenarios: WorkOSFgaProofScenario[];
639
+ diagnostics: string[];
640
+ }
641
+
642
+ export interface WorkOSFgaStateSummary {
643
+ exists: boolean;
644
+ valid: boolean;
645
+ path: string;
646
+ matchesManifestHash: boolean | null;
647
+ manifestHash?: string;
648
+ currentManifestHash?: string;
649
+ syncedAt?: string;
650
+ provedAt?: string;
651
+ mode?: "local" | "real";
652
+ sdkOk?: boolean;
653
+ diagnostics: string[];
654
+ }
655
+
656
+ export interface WorkOSFgaHostedSetup {
657
+ requiredResourceTypes: string[];
658
+ rootResourceType: "organization";
659
+ missingResourceTypes: string[];
660
+ requiredMembershipEnv: string[];
661
+ managedBy: "hosted-workos";
662
+ cliSupport: "resources-and-checks";
663
+ sdkSupport: "resources-and-checks";
664
+ docs: string[];
665
+ nextActions: string[];
666
+ }
667
+
668
+ export interface WorkOSFgaResourceTypeSetup {
669
+ slug: string;
670
+ displayName: string;
671
+ hostedAction: "none" | "configure-resource-type";
672
+ requiredBeforeRealSync: boolean;
673
+ permissions: string[];
674
+ roles: string[];
675
+ parentTypes: string[];
676
+ childTypes: string[];
677
+ exampleExternalIds: string[];
678
+ proofScenarios: string[];
679
+ notes: string[];
680
+ }
681
+
682
+ export interface WorkOSFgaSetupGuide {
683
+ resourceTypes: WorkOSFgaResourceTypeSetup[];
684
+ markdown: string;
685
+ docs: string[];
686
+ unsupportedAutomation: string[];
687
+ }
688
+
689
+ function slugifyExternalIdPart(value: string): string {
690
+ return value
691
+ .trim()
692
+ .toLowerCase()
693
+ .replace(/[^a-z0-9]+/g, "-")
694
+ .replace(/^-+|-+$/g, "") || "demo";
695
+ }
696
+
697
+ function preferredParentType(resourceType: string, resourceTypes: string[]): string | undefined {
698
+ const has = (value: string) => resourceTypes.includes(value);
699
+ if (resourceType === "organization") return undefined;
700
+ if (["access_request", "accessRequest", "evidence_document", "evidenceDocument"].includes(resourceType)) {
701
+ if (has("vendor")) return "vendor";
702
+ }
703
+ if (["task", "taskGroup"].includes(resourceType)) {
704
+ if (has("team")) return "team";
705
+ if (has("project")) return "project";
706
+ }
707
+ if (resourceType === "team" && has("project")) return "project";
708
+ return has("organization") ? "organization" : undefined;
709
+ }
710
+
711
+ function stableJson(value: unknown): string {
712
+ if (Array.isArray(value)) {
713
+ return `[${value.map(stableJson).join(",")}]`;
714
+ }
715
+ if (value && typeof value === "object") {
716
+ return `{${Object.entries(value as Record<string, unknown>)
717
+ .sort(([a], [b]) => a.localeCompare(b))
718
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`)
719
+ .join(",")}}`;
720
+ }
721
+ return JSON.stringify(value);
722
+ }
723
+
724
+ function hashObject(value: unknown): string {
725
+ return createHash("sha256").update(stableJson(value)).digest("hex");
726
+ }
727
+
728
+ export function collectWorkOSFgaManifest(
729
+ workspaceRoot: string,
730
+ preferredSeedPath = DEFAULT_SEED_FILE,
731
+ ): WorkOSFgaManifest {
732
+ const seed = parseSeedFile(workspaceRoot, preferredSeedPath);
733
+ const activePermissions = collectPolicyPermissions(workspaceRoot);
734
+ const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
735
+ const permissions = uniqueSorted([...seed.permissions, ...activePermissions]);
736
+ const resourceTypes = uniqueSorted([...seed.resourceTypes, ...expectedResourceTypes]);
737
+ const organizations = seed.organizations.length > 0 ? seed.organizations : ["Demo Organization"];
738
+ const diagnostics: string[] = [];
739
+ const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
740
+ const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
741
+ if (!seed.exists) diagnostics.push(`${preferredSeedPath} is missing; run forge add auth workos or forge workos seed --dry-run first`);
742
+ if (missingSeedPermissions.length > 0) diagnostics.push(`seed missing active policy permission(s): ${missingSeedPermissions.join(", ")}`);
743
+ if (missingSeedResources.length > 0) diagnostics.push(`seed missing app resource type(s): ${missingSeedResources.join(", ")}`);
744
+ if (!resourceTypes.includes("organization")) diagnostics.push("FGA graph should include an organization resource type for tenant roots");
745
+ if (permissions.length === 0) diagnostics.push("no WorkOS permission slugs were discovered");
746
+ if (seed.roles.length === 0) diagnostics.push("no WorkOS roles were discovered");
747
+
748
+ const resources: WorkOSFgaResource[] = [];
749
+ for (const organization of organizations) {
750
+ const orgSlug = slugifyExternalIdPart(organization);
751
+ const orgExternalId = `organization:${orgSlug}`;
752
+ if (resourceTypes.includes("organization")) {
753
+ resources.push({
754
+ externalId: orgExternalId,
755
+ type: "organization",
756
+ tenant: organization,
757
+ name: organization,
758
+ });
759
+ }
760
+ for (const resourceType of resourceTypes.filter((type) => type !== "organization")) {
761
+ const parentType = preferredParentType(resourceType, resourceTypes);
762
+ const parentExternalId = parentType
763
+ ? parentType === "organization"
764
+ ? orgExternalId
765
+ : `${parentType}:${orgSlug}:demo`
766
+ : undefined;
767
+ resources.push({
768
+ externalId: `${resourceType}:${orgSlug}:demo`,
769
+ type: resourceType,
770
+ tenant: organization,
771
+ name: `${organization} ${resourceType}`,
772
+ ...(parentType ? { parentType } : {}),
773
+ ...(parentExternalId ? { parentExternalId } : {}),
774
+ });
775
+ }
776
+ }
777
+
778
+ const firstOrg = organizations[0] ?? "Demo Organization";
779
+ const secondOrg = organizations[1] ?? `${firstOrg} Other`;
780
+ const firstResource = resources.find((resource) => resource.type !== "organization") ?? resources[0];
781
+ const firstPermission = permissions[0] ?? "app:read";
782
+ const proofScenarios: WorkOSFgaProofScenario[] = firstResource
783
+ ? [
784
+ {
785
+ name: "allowed-same-tenant",
786
+ expected: "allow",
787
+ permission: firstPermission,
788
+ resourceExternalId: firstResource.externalId,
789
+ resourceTypeSlug: firstResource.type,
790
+ organization: firstOrg,
791
+ reason: "membership, permission, and resource tenant match",
792
+ },
793
+ {
794
+ name: "cross-tenant-read-denied",
795
+ expected: "deny",
796
+ permission: firstPermission,
797
+ resourceExternalId: firstResource.externalId,
798
+ resourceTypeSlug: firstResource.type,
799
+ organization: secondOrg,
800
+ reason: "resource belongs to a different organization tenant",
801
+ },
802
+ ]
803
+ : [];
804
+
805
+ const hashInput = {
806
+ seedFile: seed.path,
807
+ seedHash: seed.exists ? hashSeedFile(workspaceRoot, seed.path) : undefined,
808
+ permissions,
809
+ roles: seed.roles,
810
+ resourceTypes,
811
+ organizations,
812
+ resources,
813
+ proofScenarios,
814
+ };
815
+ return {
816
+ schemaVersion: "0.1.0",
817
+ provider: "workos",
818
+ kind: "fga-manifest",
819
+ seedFile: seed.path,
820
+ ...(seed.exists ? { seedHash: hashSeedFile(workspaceRoot, seed.path) } : {}),
821
+ manifestHash: hashObject(hashInput),
822
+ permissions,
823
+ roles: seed.roles,
824
+ resourceTypes,
825
+ organizations,
826
+ resources,
827
+ proofScenarios,
828
+ diagnostics,
829
+ };
830
+ }
831
+
832
+ export function readWorkOSFgaState(
833
+ workspaceRoot: string,
834
+ manifest: WorkOSFgaManifest,
835
+ ): WorkOSFgaStateSummary {
836
+ const path = WORKOS_FGA_STATE_FILE;
837
+ const absolute = join(workspaceRoot, path);
838
+ if (!existsSync(absolute)) {
839
+ return {
840
+ exists: false,
841
+ valid: false,
842
+ path,
843
+ matchesManifestHash: null,
844
+ currentManifestHash: manifest.manifestHash,
845
+ diagnostics: [`${path} is missing; run forge workos fga sync --json`],
846
+ };
847
+ }
848
+ try {
849
+ const parsed = JSON.parse(readFileSync(absolute, "utf8")) as {
850
+ manifestHash?: unknown;
851
+ syncedAt?: unknown;
852
+ provedAt?: unknown;
853
+ mode?: unknown;
854
+ sdkOk?: unknown;
855
+ };
856
+ const diagnostics: string[] = [];
857
+ const manifestHash = typeof parsed.manifestHash === "string" ? parsed.manifestHash : undefined;
858
+ const mode = parsed.mode === "real" ? "real" : parsed.mode === "local" ? "local" : undefined;
859
+ const sdkOk = typeof parsed.sdkOk === "boolean" ? parsed.sdkOk : undefined;
860
+ if (!manifestHash) diagnostics.push("FGA state is missing manifestHash");
861
+ if (typeof parsed.syncedAt !== "string") diagnostics.push("FGA state is missing syncedAt");
862
+ if (!mode) diagnostics.push("FGA state is missing mode");
863
+ if (mode === "real" && sdkOk !== true) diagnostics.push("real FGA state is missing sdkOk:true from WorkOS Authorization API sync/proof");
864
+ return {
865
+ exists: true,
866
+ valid: diagnostics.length === 0,
867
+ path,
868
+ matchesManifestHash: manifestHash ? manifestHash === manifest.manifestHash : null,
869
+ ...(manifestHash ? { manifestHash } : {}),
870
+ currentManifestHash: manifest.manifestHash,
871
+ ...(typeof parsed.syncedAt === "string" ? { syncedAt: parsed.syncedAt } : {}),
872
+ ...(typeof parsed.provedAt === "string" ? { provedAt: parsed.provedAt } : {}),
873
+ ...(mode ? { mode } : {}),
874
+ ...(sdkOk !== undefined ? { sdkOk } : {}),
875
+ diagnostics,
876
+ };
877
+ } catch (error) {
878
+ return {
879
+ exists: true,
880
+ valid: false,
881
+ path,
882
+ matchesManifestHash: null,
883
+ currentManifestHash: manifest.manifestHash,
884
+ diagnostics: [`failed to parse ${path}: ${error instanceof Error ? error.message : String(error)}`],
885
+ };
886
+ }
887
+ }
888
+
889
+ function writeWorkOSFgaState(input: {
890
+ workspaceRoot: string;
891
+ manifest: WorkOSFgaManifest;
892
+ mode: "local" | "real";
893
+ proved: boolean;
894
+ sdkOk?: boolean;
895
+ sdk?: unknown;
896
+ }): string {
897
+ const now = new Date().toISOString();
898
+ const payload = {
899
+ schemaVersion: "0.1.0",
900
+ provider: "workos",
901
+ kind: "fga-state",
902
+ mode: input.mode,
903
+ seedFile: input.manifest.seedFile,
904
+ seedHash: input.manifest.seedHash,
905
+ manifestHash: input.manifest.manifestHash,
906
+ syncedAt: now,
907
+ ...(input.proved ? { provedAt: now } : {}),
908
+ ...(input.sdkOk !== undefined ? { sdkOk: input.sdkOk } : {}),
909
+ ...(input.sdk ? { sdk: input.sdk } : {}),
910
+ permissions: input.manifest.permissions,
911
+ roles: input.manifest.roles,
912
+ resourceTypes: input.manifest.resourceTypes,
913
+ organizations: input.manifest.organizations,
914
+ resources: input.manifest.resources,
915
+ proofScenarios: input.manifest.proofScenarios,
916
+ };
917
+ writeFileSync(join(input.workspaceRoot, WORKOS_FGA_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
918
+ return WORKOS_FGA_STATE_FILE;
919
+ }
920
+
921
+ function hostedFgaResourceTypes(manifest: WorkOSFgaManifest): string[] {
922
+ return manifest.resourceTypes.filter((resourceType) => resourceType !== "organization");
923
+ }
924
+
925
+ function fgaMembershipEnvKey(organization: string): string {
926
+ const suffix = slugifyExternalIdPart(organization).replace(/-/g, "_").toUpperCase();
927
+ return `WORKOS_FGA_MEMBERSHIP_${suffix}`;
928
+ }
929
+
930
+ function extractMissingWorkOSFgaResourceTypes(data: unknown, manifest?: WorkOSFgaManifest): string[] {
931
+ const missing = new Set<string>();
932
+ const add = (value: unknown) => {
933
+ if (typeof value === "string" && value && value !== "organization") {
934
+ missing.add(value);
935
+ }
936
+ };
937
+ if (data && typeof data === "object") {
938
+ const errors = (data as { errors?: unknown }).errors;
939
+ if (Array.isArray(errors)) {
940
+ for (const error of errors) {
941
+ if (!error || typeof error !== "object") continue;
942
+ const record = error as Record<string, unknown>;
943
+ const message = typeof record.message === "string" ? record.message : "";
944
+ const status = String(record.status ?? "");
945
+ if (message.includes("AuthorizationResourceType not found") || status === "404") {
946
+ add(record.resourceTypeSlug);
947
+ }
948
+ const match = /AuthorizationResourceType not found:\s*['"]([^'"]+)['"]/.exec(message);
949
+ add(match?.[1]);
950
+ }
951
+ }
952
+ }
953
+ const text = JSON.stringify(data) ?? "";
954
+ const missingTypePattern = /AuthorizationResourceType not found:\s*['"]([^'"]+)['"]/g;
955
+ let match: RegExpExecArray | null;
956
+ while ((match = missingTypePattern.exec(text))) {
957
+ add(match[1]);
958
+ }
959
+ if (missing.size === 0 && text.includes("AuthorizationResourceType not found") && manifest) {
960
+ for (const resourceType of hostedFgaResourceTypes(manifest)) {
961
+ missing.add(resourceType);
962
+ }
963
+ }
964
+ return uniqueSorted(missing);
965
+ }
966
+
967
+ function workOSFgaHostedSetup(manifest: WorkOSFgaManifest, sdkData?: unknown): WorkOSFgaHostedSetup {
968
+ const requiredResourceTypes = hostedFgaResourceTypes(manifest);
969
+ const missingResourceTypes = extractMissingWorkOSFgaResourceTypes(sdkData, manifest);
970
+ const resourceList = (missingResourceTypes.length > 0 ? missingResourceTypes : requiredResourceTypes).join(", ") || "none";
971
+ const requiredMembershipEnv = manifest.organizations.map(fgaMembershipEnvKey);
972
+ return {
973
+ requiredResourceTypes,
974
+ rootResourceType: "organization",
975
+ missingResourceTypes,
976
+ requiredMembershipEnv,
977
+ managedBy: "hosted-workos",
978
+ cliSupport: "resources-and-checks",
979
+ sdkSupport: "resources-and-checks",
980
+ docs: [
981
+ "https://workos.com/docs/fga/resource-types",
982
+ "https://workos.com/docs/fga/resources",
983
+ "https://workos.com/docs/fga/access-checks",
984
+ ],
985
+ nextActions: [
986
+ missingResourceTypes.length > 0
987
+ ? `configure missing WorkOS FGA resource type(s): ${resourceList}`
988
+ : `confirm WorkOS FGA resource type(s) exist: ${resourceList}`,
989
+ "treat organization as the WorkOS tenant root; ForgeOS does not create it as an authorization resource",
990
+ `set WorkOS FGA membership env for real access checks: WORKOS_FGA_MEMBERSHIPS_JSON or ${requiredMembershipEnv.join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>"}`,
991
+ "rerun forge workos fga sync --real --file workos-seed.yml --json",
992
+ "rerun forge workos fga prove --real --file workos-seed.yml --json",
993
+ "rerun forge deploy check --production --json",
994
+ ],
995
+ };
996
+ }
997
+
998
+ function displayResourceType(slug: string): string {
999
+ return slug
1000
+ .replace(/[_-]+/g, " ")
1001
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
1002
+ }
1003
+
1004
+ function permissionResourcePart(permission: string): string {
1005
+ return permission.split(":")[0] ?? permission;
1006
+ }
1007
+
1008
+ function permissionMatchesResourceType(permission: string, resourceType: string): boolean {
1009
+ const part = permissionResourcePart(permission);
1010
+ const normalizedPart = singularResourceName(part.replace(/[-_]/g, ""));
1011
+ const normalizedType = singularResourceName(resourceType.replace(/[-_]/g, ""));
1012
+ return part === resourceType ||
1013
+ singularResourceName(part) === resourceType ||
1014
+ normalizedPart === normalizedType ||
1015
+ normalizedType.startsWith(normalizedPart) ||
1016
+ normalizedPart === `${normalizedType}request`;
1017
+ }
1018
+
1019
+ function permissionsForResourceType(manifest: WorkOSFgaManifest, resourceType: string): string[] {
1020
+ if (resourceType === "organization") {
1021
+ return manifest.permissions.filter((permission) => permissionResourcePart(permission) === "organization");
1022
+ }
1023
+ return manifest.permissions.filter((permission) => permissionMatchesResourceType(permission, resourceType));
1024
+ }
1025
+
1026
+ function roleLooksRelevantToResourceType(role: string, resourceType: string): boolean {
1027
+ const normalizedRole = role.replace(/[-_]/g, "");
1028
+ const normalizedType = resourceType.replace(/[-_]/g, "");
1029
+ return normalizedRole.includes(normalizedType) ||
1030
+ normalizedRole.includes(singularResourceName(normalizedType)) ||
1031
+ ["owner", "admin", "manager", "member", "auditor", "reviewer", "requester", "security"].some((shared) => normalizedRole.includes(shared));
1032
+ }
1033
+
1034
+ function rolesForResourceType(manifest: WorkOSFgaManifest, resourceType: string): string[] {
1035
+ const relevant = manifest.roles.filter((role) => roleLooksRelevantToResourceType(role, resourceType));
1036
+ return relevant.length > 0 ? relevant : manifest.roles;
1037
+ }
1038
+
1039
+ function workOSFgaSetupGuide(manifest: WorkOSFgaManifest, hostedSetup: WorkOSFgaHostedSetup): WorkOSFgaSetupGuide {
1040
+ const resourcesByType = new Map<string, WorkOSFgaResource[]>();
1041
+ for (const resource of manifest.resources) {
1042
+ resourcesByType.set(resource.type, [...resourcesByType.get(resource.type) ?? [], resource]);
1043
+ }
1044
+ const resourceTypes = manifest.resourceTypes.map((slug): WorkOSFgaResourceTypeSetup => {
1045
+ const resources = resourcesByType.get(slug) ?? [];
1046
+ const parentTypes = uniqueSorted(resources.map((resource) => resource.parentType ?? "").filter(Boolean));
1047
+ const childTypes = uniqueSorted(manifest.resources
1048
+ .filter((resource) => resource.parentType === slug)
1049
+ .map((resource) => resource.type));
1050
+ const proofScenarios = manifest.proofScenarios
1051
+ .filter((scenario) => scenario.resourceTypeSlug === slug)
1052
+ .map((scenario) => `${scenario.name}:${scenario.expected}`);
1053
+ const permissions = permissionsForResourceType(manifest, slug);
1054
+ const roles = rolesForResourceType(manifest, slug);
1055
+ const isRoot = slug === hostedSetup.rootResourceType;
1056
+ return {
1057
+ slug,
1058
+ displayName: displayResourceType(slug),
1059
+ hostedAction: isRoot ? "none" : "configure-resource-type",
1060
+ requiredBeforeRealSync: !isRoot,
1061
+ permissions,
1062
+ roles,
1063
+ parentTypes,
1064
+ childTypes,
1065
+ exampleExternalIds: resources.map((resource) => resource.externalId).slice(0, 4),
1066
+ proofScenarios,
1067
+ notes: isRoot
1068
+ ? [
1069
+ "Treat WorkOS organization as the tenant root.",
1070
+ "ForgeOS keeps organization in the graph for parent/tenant reasoning, but real sync does not create it as an authorization resource.",
1071
+ ]
1072
+ : [
1073
+ "Create/configure this resource type in hosted WorkOS before real sync.",
1074
+ parentTypes.length > 0
1075
+ ? `Expected parent type(s): ${parentTypes.join(", ")}.`
1076
+ : "No parent type inferred from the app graph.",
1077
+ ],
1078
+ };
1079
+ });
1080
+ const markdown = [
1081
+ "# WorkOS FGA Setup",
1082
+ "",
1083
+ "ForgeOS derived this resource graph from the app contract, policies, and workos-seed.yml.",
1084
+ "WorkOS resource type configuration is hosted WorkOS configuration. ForgeOS uses the WorkOS CLI/API or SDK to sync resources and prove authorization checks after those resource types exist.",
1085
+ "",
1086
+ "## Resource Types",
1087
+ "",
1088
+ ...resourceTypes.flatMap((resourceType) => [
1089
+ `### ${resourceType.slug}`,
1090
+ "",
1091
+ `- Display name: ${resourceType.displayName}`,
1092
+ `- Hosted action: ${resourceType.hostedAction}`,
1093
+ `- Required before real sync: ${resourceType.requiredBeforeRealSync ? "yes" : "no"}`,
1094
+ `- Parent types: ${resourceType.parentTypes.join(", ") || "none"}`,
1095
+ `- Child types: ${resourceType.childTypes.join(", ") || "none"}`,
1096
+ `- Permissions to attach/model: ${resourceType.permissions.join(", ") || "none inferred"}`,
1097
+ `- Roles to review for this type: ${resourceType.roles.join(", ") || "none inferred"}`,
1098
+ `- Example external IDs: ${resourceType.exampleExternalIds.join(", ") || "none"}`,
1099
+ `- Proof scenarios: ${resourceType.proofScenarios.join(", ") || "none"}`,
1100
+ ...resourceType.notes.map((note) => `- Note: ${note}`),
1101
+ "",
1102
+ ]),
1103
+ "## Permission And Role Coverage",
1104
+ "",
1105
+ `- Permissions discovered: ${manifest.permissions.join(", ") || "none"}`,
1106
+ `- Roles discovered: ${manifest.roles.join(", ") || "none"}`,
1107
+ "- In hosted WorkOS, ensure each permission is scoped to the intended resource type and each role includes the permissions needed by your Forge policies.",
1108
+ "- ForgeOS will not claim production readiness until real Authorization API checks pass for the generated proof scenarios.",
1109
+ "",
1110
+ "## Required Membership Environment",
1111
+ "",
1112
+ "- WORKOS_FGA_MEMBERSHIPS_JSON: JSON object mapping organization name to organizationMembershipId",
1113
+ ...hostedSetup.requiredMembershipEnv.map((env) => `- ${env}: organizationMembershipId for that organization`),
1114
+ "",
1115
+ "## Commands",
1116
+ "",
1117
+ "```bash",
1118
+ "forge workos fga plan --file workos-seed.yml --write --json",
1119
+ "forge workos fga sync --real --file workos-seed.yml --json",
1120
+ "forge workos fga prove --real --file workos-seed.yml --json",
1121
+ "forge deploy check --production --json",
1122
+ "```",
1123
+ "",
1124
+ ].join("\n");
1125
+ return {
1126
+ resourceTypes,
1127
+ markdown,
1128
+ docs: hostedSetup.docs,
1129
+ unsupportedAutomation: [
1130
+ "ForgeOS does not invent WorkOS CLI/API calls for resource type creation.",
1131
+ "ForgeOS can create/read FGA resources and run authorization checks through the WorkOS CLI/API or SDK after resource types exist.",
1132
+ ],
1133
+ };
1134
+ }
1135
+
1136
+ function resolveWorkOSFgaSetupGuidePath(options: WorkOSCommandOptions): string | undefined {
1137
+ if (!options.write && !options.writePath) {
1138
+ return undefined;
1139
+ }
1140
+ const candidate = options.writePath?.trim();
1141
+ return candidate && candidate !== "true" && !candidate.startsWith("--") ? candidate : WORKOS_FGA_SETUP_GUIDE_FILE;
1142
+ }
1143
+
1144
+ function writeWorkOSFgaSetupGuide(
1145
+ workspaceRoot: string,
1146
+ guide: WorkOSFgaSetupGuide,
1147
+ preferredPath = WORKOS_FGA_SETUP_GUIDE_FILE,
1148
+ ): string {
1149
+ const relativePath = preferredPath.startsWith("/") ? preferredPath.slice(1) : preferredPath;
1150
+ const absolutePath = join(workspaceRoot, relativePath);
1151
+ mkdirSync(dirname(absolutePath), { recursive: true });
1152
+ writeFileSync(absolutePath, guide.markdown, "utf8");
1153
+ return relativePath;
1154
+ }
1155
+
1156
+ function fgaData(input: {
1157
+ action: WorkOSFgaAction;
1158
+ manifest: WorkOSFgaManifest;
1159
+ state: WorkOSFgaStateSummary;
1160
+ real?: boolean;
1161
+ cliAuth?: WorkOSCliAuthSummary;
1162
+ workosSdk?: unknown;
1163
+ stateFile?: string;
1164
+ nextCommand?: string;
1165
+ nextActions?: string[];
1166
+ setupGuidePath?: string;
1167
+ }): Record<string, unknown> {
1168
+ const hostedSetup = workOSFgaHostedSetup(input.manifest, input.workosSdk);
1169
+ const setupGuide = workOSFgaSetupGuide(input.manifest, hostedSetup);
1170
+ return {
1171
+ action: input.action,
1172
+ real: input.real ?? false,
1173
+ manifest: input.manifest,
1174
+ state: input.state,
1175
+ hostedSetup,
1176
+ resourceTypeSetup: setupGuide.resourceTypes,
1177
+ setupGuide,
1178
+ ...(input.cliAuth ? { cliAuth: input.cliAuth } : {}),
1179
+ ...(input.workosSdk ? { workosSdk: input.workosSdk } : {}),
1180
+ ...(input.stateFile ? { stateFile: input.stateFile } : {}),
1181
+ ...(input.setupGuidePath ? { setupGuidePath: input.setupGuidePath } : {}),
1182
+ nextCommand: input.nextCommand,
1183
+ nextActions: input.nextActions ?? hostedSetup.nextActions,
1184
+ notes: [
1185
+ "ForgeOS derives resource graph and proof scenarios from app contract, policies, and workos-seed.yml.",
1186
+ "WorkOS FGA resource types are hosted WorkOS configuration; ForgeOS syncs/proves resources and gates production deploys through .workos-fga-state.json.",
1187
+ ],
1188
+ };
1189
+ }
1190
+
1191
+ function runWorkOSFgaSdk(
1192
+ options: WorkOSCommandOptions,
1193
+ manifest: WorkOSFgaManifest,
1194
+ action: "sync" | "prove",
1195
+ ): { ok: boolean; command: string[]; data: Record<string, unknown>; status: number | null; stdout?: string; stderr?: string } {
1196
+ const script = String.raw`
1197
+ const { spawnSync } = await import("node:child_process");
1198
+ const payload = JSON.parse(process.env.FORGE_WORKOS_FGA_PAYLOAD || "{}");
1199
+ const out = { ok: true, action: payload.action, resources: [], checks: [], skipped: [], errors: [] };
1200
+ function message(error) {
1201
+ return error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
1202
+ }
1203
+ function status(error) {
1204
+ return error && typeof error === "object" && "status" in error ? error.status : error && typeof error === "object" && "statusCode" in error ? error.statusCode : undefined;
1205
+ }
1206
+ async function listAll(page) {
1207
+ if (!page) return [];
1208
+ if (Array.isArray(page.data)) return page.data;
1209
+ if (Symbol.asyncIterator in Object(page)) {
1210
+ const values = [];
1211
+ for await (const item of page) values.push(item);
1212
+ return values;
1213
+ }
1214
+ return [];
1215
+ }
1216
+ function slugify(value) {
1217
+ return String(value || "")
1218
+ .trim()
1219
+ .toLowerCase()
1220
+ .replace(/[^a-z0-9]+/g, "-")
1221
+ .replace(/^-+|-+$/g, "") || "demo";
1222
+ }
1223
+ function membershipEnvKey(organization) {
1224
+ return "WORKOS_FGA_MEMBERSHIP_" + slugify(organization).replace(/-/g, "_").toUpperCase();
1225
+ }
1226
+ function readMembershipMap(organizations) {
1227
+ const memberships = new Map();
1228
+ for (const name of ["WORKOS_FGA_MEMBERSHIPS_JSON", "WORKOS_FGA_TEST_MEMBERSHIPS"]) {
1229
+ const raw = process.env[name];
1230
+ if (!raw) continue;
1231
+ try {
1232
+ const parsed = JSON.parse(raw);
1233
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1234
+ for (const [organization, membershipId] of Object.entries(parsed)) {
1235
+ if (typeof membershipId === "string" && membershipId.trim()) {
1236
+ memberships.set(organization, membershipId.trim());
1237
+ memberships.set(slugify(organization), membershipId.trim());
1238
+ }
1239
+ }
1240
+ }
1241
+ } catch (error) {
1242
+ out.ok = false;
1243
+ out.errors.push({ operation: "membership.env", env: name, message: "failed to parse JSON membership map: " + message(error) });
1244
+ }
1245
+ }
1246
+ for (const organization of organizations || []) {
1247
+ const envKey = membershipEnvKey(organization);
1248
+ const membershipId = process.env[envKey];
1249
+ if (membershipId && membershipId.trim()) {
1250
+ memberships.set(organization, membershipId.trim());
1251
+ memberships.set(slugify(organization), membershipId.trim());
1252
+ }
1253
+ }
1254
+ const legacy = process.env.WORKOS_FGA_TEST_MEMBERSHIP_ID || process.env.WORKOS_FGA_ORGANIZATION_MEMBERSHIP_ID;
1255
+ if (legacy && legacy.trim() && organizations && organizations.length === 1) {
1256
+ memberships.set(organizations[0], legacy.trim());
1257
+ memberships.set(slugify(organizations[0]), legacy.trim());
1258
+ }
1259
+ return memberships;
1260
+ }
1261
+ function parseCliJson(text) {
1262
+ try {
1263
+ const parsed = JSON.parse(text || "{}");
1264
+ return parsed && typeof parsed === "object" ? parsed : {};
1265
+ } catch (error) {
1266
+ return { parseError: message(error), raw: text };
1267
+ }
1268
+ }
1269
+ function cliApi(path, options = {}) {
1270
+ const args = ["--yes", "workos@latest", "api", path, "--method", options.method || "GET", "--json"];
1271
+ if (options.data) args.push("--data", JSON.stringify(options.data));
1272
+ if (options.yes) args.push("--yes");
1273
+ const child = spawnSync("npx", args, {
1274
+ encoding: "utf8",
1275
+ env: { ...process.env, WORKOS_MODE: process.env.WORKOS_MODE || "agent" },
1276
+ });
1277
+ const parsed = parseCliJson(child.stdout);
1278
+ return {
1279
+ ok: child.status === 0,
1280
+ status: child.status,
1281
+ data: parsed,
1282
+ stdout: child.stdout,
1283
+ stderr: child.stderr,
1284
+ };
1285
+ }
1286
+ function dataList(payload) {
1287
+ if (Array.isArray(payload)) return payload;
1288
+ if (Array.isArray(payload?.data)) return payload.data;
1289
+ if (Array.isArray(payload?.list)) return payload.list;
1290
+ return [];
1291
+ }
1292
+ function encodePathPart(value) {
1293
+ return encodeURIComponent(String(value));
1294
+ }
1295
+ async function runWithWorkOSCli() {
1296
+ const orgResponse = cliApi("/organizations", { method: "GET" });
1297
+ if (!orgResponse.ok) {
1298
+ out.ok = false;
1299
+ out.errors.push({ operation: "cli.organizations.list", status: orgResponse.status, message: orgResponse.stderr || JSON.stringify(orgResponse.data) });
1300
+ return;
1301
+ }
1302
+ const orgs = dataList(orgResponse.data);
1303
+ const orgByName = new Map(orgs.map((org) => [org.name, org]));
1304
+ const sortedResources = [...payload.manifest.resources].sort((a, b) => {
1305
+ if (a.type === "organization") return -1;
1306
+ if (b.type === "organization") return 1;
1307
+ if (!a.parentExternalId && b.parentExternalId) return -1;
1308
+ if (a.parentExternalId && !b.parentExternalId) return 1;
1309
+ return a.externalId.localeCompare(b.externalId);
1310
+ });
1311
+ for (const resource of sortedResources) {
1312
+ if (resource.type === "organization") {
1313
+ out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationName: resource.tenant, status: "root-organization" });
1314
+ continue;
1315
+ }
1316
+ const org = orgByName.get(resource.tenant);
1317
+ if (!org?.id) {
1318
+ out.ok = false;
1319
+ out.errors.push({ operation: "organization.lookup", organization: resource.tenant, message: "organization not found in WorkOS environment" });
1320
+ continue;
1321
+ }
1322
+ const getPath = "/authorization/organizations/" + encodePathPart(org.id) + "/resources/" + encodePathPart(resource.type) + "/" + encodePathPart(resource.externalId);
1323
+ const existing = cliApi(getPath, { method: "GET" });
1324
+ if (existing.ok) {
1325
+ out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "existing", id: existing.data?.id });
1326
+ continue;
1327
+ }
1328
+ const created = cliApi("/authorization/resources", {
1329
+ method: "POST",
1330
+ yes: true,
1331
+ data: {
1332
+ organization_id: org.id,
1333
+ resource_type_slug: resource.type,
1334
+ external_id: resource.externalId,
1335
+ name: resource.name,
1336
+ ...(resource.parentExternalId && resource.parentType !== "organization" ? { parent_resource_external_id: resource.parentExternalId, parent_resource_type_slug: resource.parentType } : {}),
1337
+ },
1338
+ });
1339
+ if (!created.ok) {
1340
+ out.ok = false;
1341
+ out.errors.push({ operation: "resource.create", externalId: resource.externalId, resourceTypeSlug: resource.type, status: created.status, message: created.stderr || JSON.stringify(created.data) });
1342
+ continue;
1343
+ }
1344
+ out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "created", id: created.data?.id });
1345
+ }
1346
+ if (payload.action === "prove") {
1347
+ const memberships = readMembershipMap(payload.manifest.organizations);
1348
+ for (const scenario of payload.manifest.proofScenarios) {
1349
+ const membershipId = memberships.get(scenario.organization) || memberships.get(slugify(scenario.organization));
1350
+ if (!membershipId) {
1351
+ out.ok = false;
1352
+ out.errors.push({
1353
+ operation: "authorization.check",
1354
+ scenario: scenario.name,
1355
+ organization: scenario.organization,
1356
+ expected: scenario.expected,
1357
+ message: "missing organizationMembershipId for WorkOS FGA proof scenario",
1358
+ env: ["WORKOS_FGA_MEMBERSHIPS_JSON", membershipEnvKey(scenario.organization)],
1359
+ });
1360
+ continue;
1361
+ }
1362
+ const checked = cliApi("/authorization/organization_memberships/" + encodePathPart(membershipId) + "/check", {
1363
+ method: "POST",
1364
+ yes: true,
1365
+ data: {
1366
+ permission_slug: scenario.permission,
1367
+ resource_external_id: scenario.resourceExternalId,
1368
+ resource_type_slug: scenario.resourceTypeSlug,
1369
+ },
1370
+ });
1371
+ if (!checked.ok) {
1372
+ out.ok = false;
1373
+ out.errors.push({ operation: "authorization.check", scenario: scenario.name, status: checked.status, message: checked.stderr || JSON.stringify(checked.data) });
1374
+ continue;
1375
+ }
1376
+ const expectedAuthorized = scenario.expected === "allow";
1377
+ const passed = Boolean(checked.data?.authorized) === expectedAuthorized;
1378
+ if (!passed) out.ok = false;
1379
+ out.checks.push({ name: scenario.name, organization: scenario.organization, expected: scenario.expected, authorized: Boolean(checked.data?.authorized), ok: passed });
1380
+ }
1381
+ }
1382
+ }
1383
+ try {
1384
+ if (!process.env.WORKOS_API_KEY) {
1385
+ await runWithWorkOSCli();
1386
+ console.log(JSON.stringify(out));
1387
+ process.exit(out.ok ? 0 : 1);
1388
+ }
1389
+ const mod = await import("@workos-inc/node");
1390
+ const WorkOS = mod.WorkOS || mod.default?.WorkOS;
1391
+ if (!WorkOS) throw new Error("@workos-inc/node did not export WorkOS");
1392
+ const workos = new WorkOS(process.env.WORKOS_API_KEY);
1393
+ const orgs = await listAll(await workos.organizations.listOrganizations());
1394
+ const orgByName = new Map(orgs.map((org) => [org.name, org]));
1395
+ const sortedResources = [...payload.manifest.resources].sort((a, b) => {
1396
+ if (a.type === "organization") return -1;
1397
+ if (b.type === "organization") return 1;
1398
+ if (!a.parentExternalId && b.parentExternalId) return -1;
1399
+ if (a.parentExternalId && !b.parentExternalId) return 1;
1400
+ return a.externalId.localeCompare(b.externalId);
1401
+ });
1402
+ for (const resource of sortedResources) {
1403
+ if (resource.type === "organization") {
1404
+ out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationName: resource.tenant, status: "root-organization" });
1405
+ continue;
1406
+ }
1407
+ const org = orgByName.get(resource.tenant);
1408
+ if (!org) {
1409
+ out.ok = false;
1410
+ out.errors.push({ operation: "organization.lookup", organization: resource.tenant, message: "organization not found in WorkOS environment" });
1411
+ continue;
1412
+ }
1413
+ try {
1414
+ const existing = await workos.authorization.getResourceByExternalId({
1415
+ organizationId: org.id,
1416
+ resourceTypeSlug: resource.type,
1417
+ externalId: resource.externalId,
1418
+ });
1419
+ out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "existing", id: existing.id });
1420
+ } catch (error) {
1421
+ if (![404, "404"].includes(status(error))) {
1422
+ out.ok = false;
1423
+ out.errors.push({ operation: "resource.get", externalId: resource.externalId, resourceTypeSlug: resource.type, status: status(error), message: message(error) });
1424
+ continue;
1425
+ }
1426
+ try {
1427
+ const created = await workos.authorization.createResource({
1428
+ organizationId: org.id,
1429
+ resourceTypeSlug: resource.type,
1430
+ externalId: resource.externalId,
1431
+ name: resource.name,
1432
+ ...(resource.parentExternalId && resource.parentType !== "organization" ? { parentResourceExternalId: resource.parentExternalId, parentResourceTypeSlug: resource.parentType } : {}),
1433
+ });
1434
+ out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "created", id: created.id });
1435
+ } catch (createError) {
1436
+ out.ok = false;
1437
+ out.errors.push({ operation: "resource.create", externalId: resource.externalId, resourceTypeSlug: resource.type, status: status(createError), message: message(createError) });
1438
+ }
1439
+ }
1440
+ }
1441
+ if (payload.action === "prove") {
1442
+ const memberships = readMembershipMap(payload.manifest.organizations);
1443
+ for (const scenario of payload.manifest.proofScenarios) {
1444
+ const membershipId = memberships.get(scenario.organization) || memberships.get(slugify(scenario.organization));
1445
+ if (!membershipId) {
1446
+ out.ok = false;
1447
+ out.errors.push({
1448
+ operation: "authorization.check",
1449
+ scenario: scenario.name,
1450
+ organization: scenario.organization,
1451
+ expected: scenario.expected,
1452
+ message: "missing organizationMembershipId for WorkOS FGA proof scenario",
1453
+ env: ["WORKOS_FGA_MEMBERSHIPS_JSON", membershipEnvKey(scenario.organization)],
1454
+ });
1455
+ continue;
1456
+ }
1457
+ try {
1458
+ const check = await workos.authorization.check({
1459
+ organizationMembershipId: membershipId,
1460
+ permissionSlug: scenario.permission,
1461
+ resourceExternalId: scenario.resourceExternalId,
1462
+ resourceTypeSlug: scenario.resourceTypeSlug,
1463
+ });
1464
+ const expectedAuthorized = scenario.expected === "allow";
1465
+ const passed = Boolean(check.authorized) === expectedAuthorized;
1466
+ if (!passed) out.ok = false;
1467
+ out.checks.push({ name: scenario.name, organization: scenario.organization, expected: scenario.expected, authorized: Boolean(check.authorized), ok: passed });
1468
+ } catch (checkError) {
1469
+ out.ok = false;
1470
+ out.errors.push({ operation: "authorization.check", scenario: scenario.name, status: status(checkError), message: message(checkError) });
1471
+ }
1472
+ }
1473
+ }
1474
+ } catch (error) {
1475
+ out.ok = false;
1476
+ out.errors.push({ operation: "sdk", message: message(error), status: status(error) });
1477
+ }
1478
+ console.log(JSON.stringify(out));
1479
+ process.exit(out.ok ? 0 : 1);
1480
+ `;
1481
+ const command = ["node", "--input-type=module", "-e", script];
1482
+ const displayCommand = ["node", "--input-type=module", "-e", "<forge-workos-fga-sdk>"];
1483
+ const previousPayload = process.env.FORGE_WORKOS_FGA_PAYLOAD;
1484
+ let child: { status: number | null; stdout: string; stderr: string };
1485
+ try {
1486
+ process.env.FORGE_WORKOS_FGA_PAYLOAD = JSON.stringify({ action, manifest });
1487
+ child = runExternalCommand(command, {
1488
+ ...options,
1489
+ commandRunner: options.commandRunner ?? spawnSync,
1490
+ });
1491
+ } finally {
1492
+ if (previousPayload === undefined) {
1493
+ delete process.env.FORGE_WORKOS_FGA_PAYLOAD;
1494
+ } else {
1495
+ process.env.FORGE_WORKOS_FGA_PAYLOAD = previousPayload;
1496
+ }
1497
+ }
1498
+ const parsed = parseJsonObject(child.stdout);
1499
+ return {
1500
+ ok: child.status === 0 && parsed?.ok === true,
1501
+ command: displayCommand,
1502
+ data: parsed ?? {
1503
+ ok: false,
1504
+ errors: [{ operation: "sdk.parse", message: "failed to parse WorkOS FGA SDK output" }],
1505
+ },
1506
+ status: child.status,
1507
+ stdout: child.stdout,
1508
+ stderr: child.stderr,
1509
+ };
1510
+ }
1511
+
1512
+ function workOSFgaSdkFailureDetail(data: unknown, manifest?: WorkOSFgaManifest): string {
1513
+ const text = JSON.stringify(data);
1514
+ if (text.includes("AuthorizationResourceType not found")) {
1515
+ const missing = extractMissingWorkOSFgaResourceTypes(data, manifest);
1516
+ const suffix = missing.length > 0 ? `: ${missing.join(", ")}` : "";
1517
+ return `WorkOS Authorization API returned missing FGA resource type(s)${suffix}; configure them in hosted WorkOS, then rerun forge workos fga sync --real --json`;
1518
+ }
1519
+ if (text.includes("Cannot add resource to organization resource type")) {
1520
+ return "WorkOS treats organizations as tenant roots, not creatable authorization resources; Forge will keep organization in the graph and skip resource creation for it";
1521
+ }
1522
+ if (text.includes("missing organizationMembershipId") || text.includes("WORKOS_FGA_MEMBERSHIPS_JSON")) {
1523
+ const env = manifest?.organizations.map(fgaMembershipEnvKey).join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>";
1524
+ return `WorkOS FGA real proof requires organizationMembershipId values per organization; set WORKOS_FGA_MEMBERSHIPS_JSON or ${env}`;
1525
+ }
1526
+ return "WorkOS Authorization API resource sync failed; inspect workosSdk.errors";
1527
+ }
1528
+
1529
+ function workOSFgaSdkProofComplete(data: unknown, manifest: WorkOSFgaManifest): boolean {
1530
+ if (!data || typeof data !== "object") return false;
1531
+ const record = data as { ok?: unknown; checks?: unknown; skipped?: unknown; errors?: unknown };
1532
+ if (record.ok !== true) return false;
1533
+ if (Array.isArray(record.errors) && record.errors.length > 0) return false;
1534
+ if (Array.isArray(record.skipped) && record.skipped.length > 0) return false;
1535
+ if (!Array.isArray(record.checks)) return false;
1536
+ const checks = record.checks as Array<{ name?: unknown; ok?: unknown }>;
1537
+ const checkByName = new Map(checks.map((check) => [String(check.name ?? ""), check]));
1538
+ return manifest.proofScenarios.every((scenario) => checkByName.get(scenario.name)?.ok === true);
1539
+ }
1540
+
422
1541
  export interface WorkOSDoctorData {
423
1542
  seed: WorkOSSeedSummary;
424
1543
  seedState: WorkOSSeedStateSummary;
1544
+ fgaManifest: WorkOSFgaManifest;
1545
+ fgaState: WorkOSFgaStateSummary;
425
1546
  activePermissions: string[];
426
1547
  expectedResourceTypes: string[];
427
1548
  missingSeedPermissions: string[];
@@ -435,6 +1556,8 @@ function collectWorkOSDoctorData(
435
1556
  ): WorkOSDoctorData {
436
1557
  const seed = parseSeedFile(workspaceRoot, preferredSeedPath);
437
1558
  const seedState = readWorkOSSeedState(workspaceRoot, seed);
1559
+ const fgaManifest = collectWorkOSFgaManifest(workspaceRoot, preferredSeedPath);
1560
+ const fgaState = readWorkOSFgaState(workspaceRoot, fgaManifest);
438
1561
  const activePermissions = collectPolicyPermissions(workspaceRoot);
439
1562
  const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
440
1563
  const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
@@ -445,6 +1568,8 @@ function collectWorkOSDoctorData(
445
1568
  return {
446
1569
  seed,
447
1570
  seedState,
1571
+ fgaManifest,
1572
+ fgaState,
448
1573
  activePermissions,
449
1574
  expectedResourceTypes,
450
1575
  missingSeedPermissions,
@@ -481,6 +1606,8 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
481
1606
  const {
482
1607
  seed,
483
1608
  seedState,
1609
+ fgaManifest,
1610
+ fgaState,
484
1611
  activePermissions,
485
1612
  expectedResourceTypes,
486
1613
  missingSeedPermissions,
@@ -649,6 +1776,24 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
649
1776
  ? `${WORKOS_SEED_STATE_FILE} exists but does not match current ${seed.path}; rerun forge workos seed --file ${seed.path} --json after reviewing seed changes`
650
1777
  : `${WORKOS_SEED_STATE_FILE} exists but is invalid: ${seedState.diagnostics.join("; ")}`,
651
1778
  },
1779
+ {
1780
+ name: "fga-plan",
1781
+ ok: fgaManifest.diagnostics.length === 0,
1782
+ detail: fgaManifest.diagnostics.length === 0
1783
+ ? `FGA plan covers ${fgaManifest.resourceTypes.length} resource type(s), ${fgaManifest.resources.length} resource(s), and ${fgaManifest.proofScenarios.length} proof scenario(s)`
1784
+ : `FGA plan has gap(s): ${fgaManifest.diagnostics.join("; ")}`,
1785
+ },
1786
+ {
1787
+ name: "fga-state",
1788
+ ok: true,
1789
+ detail: !fgaState.exists
1790
+ ? `${WORKOS_FGA_STATE_FILE} not found; run forge workos fga sync --json before production deploy`
1791
+ : fgaState.matchesManifestHash
1792
+ ? `${WORKOS_FGA_STATE_FILE} matches current FGA manifest${fgaState.mode === "real" ? " in real mode" : ""}`
1793
+ : fgaState.valid
1794
+ ? `${WORKOS_FGA_STATE_FILE} exists but does not match current FGA manifest; rerun forge workos fga sync --json`
1795
+ : `${WORKOS_FGA_STATE_FILE} exists but is invalid: ${fgaState.diagnostics.join("; ")}`,
1796
+ },
652
1797
  {
653
1798
  name: "authkit-routes",
654
1799
  ok: includesAll(authRoutes, ["handleWorkOSAuthRequest", "/login", "/callback", "/logout", "/session"]),
@@ -733,6 +1878,7 @@ function seedData(input: {
733
1878
  seedAlreadyApplied?: boolean;
734
1879
  seedAlreadyAppliedReason?: string;
735
1880
  seedStateFile?: string;
1881
+ cliAuth?: WorkOSCliAuthSummary;
736
1882
  workosCli?: Record<string, unknown>;
737
1883
  configActions?: WorkOSConfigActionResult[];
738
1884
  nextCommand?: string;
@@ -748,6 +1894,7 @@ function seedData(input: {
748
1894
  seedAlreadyApplied: input.seedAlreadyApplied ?? false,
749
1895
  ...(input.seedAlreadyAppliedReason ? { seedAlreadyAppliedReason: input.seedAlreadyAppliedReason } : {}),
750
1896
  ...(input.seedStateFile ? { seedStateFile: input.seedStateFile } : {}),
1897
+ ...(input.cliAuth ? { cliAuth: input.cliAuth } : {}),
751
1898
  ...(input.workosCli ? { workosCli: input.workosCli } : {}),
752
1899
  configActions: input.configActions ?? [],
753
1900
  ...(input.nextCommand ? { nextCommand: input.nextCommand } : {}),
@@ -1085,6 +2232,26 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
1085
2232
  exitCode: 0,
1086
2233
  };
1087
2234
  }
2235
+ const cliAuth = ensureWorkOSCliAuthForHosted(options);
2236
+ if (!cliAuth.ok) {
2237
+ return {
2238
+ ok: false,
2239
+ kind: "workos-seed",
2240
+ checks: [...checks, workOSCliAuthCheck(cliAuth)],
2241
+ command: cliAuth.loginCommand ?? cliAuth.statusCommand ?? command,
2242
+ applied: false,
2243
+ data: seedData({
2244
+ seed,
2245
+ activePermissions,
2246
+ expectedResourceTypes,
2247
+ unusedSeedPermissions,
2248
+ seedState,
2249
+ cliAuth,
2250
+ nextCommand: `forge workos seed --file ${file} --json`,
2251
+ }),
2252
+ exitCode: 1,
2253
+ };
2254
+ }
1088
2255
  const preparedSeed = prepareSeedFileForWorkOSCli(options.workspaceRoot, seed.path);
1089
2256
  const delegatedCommand = [
1090
2257
  "npx",
@@ -1108,11 +2275,12 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
1108
2275
  seed,
1109
2276
  activePermissions,
1110
2277
  expectedResourceTypes,
1111
- unusedSeedPermissions,
1112
- seedState,
1113
- seedFileSanitized: preparedSeed.sanitized,
1114
- configActions,
1115
- }),
2278
+ unusedSeedPermissions,
2279
+ seedState,
2280
+ cliAuth,
2281
+ seedFileSanitized: preparedSeed.sanitized,
2282
+ configActions,
2283
+ }),
1116
2284
  exitCode: 1,
1117
2285
  };
1118
2286
  }
@@ -1146,6 +2314,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
1146
2314
  expectedResourceTypes,
1147
2315
  unusedSeedPermissions,
1148
2316
  seedState: latestSeedState,
2317
+ cliAuth,
1149
2318
  seedFileSanitized: preparedSeed.sanitized,
1150
2319
  seedAlreadyApplied,
1151
2320
  seedAlreadyAppliedReason,
@@ -1169,8 +2338,12 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
1169
2338
  export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
1170
2339
  const file = options.file ?? DEFAULT_SEED_FILE;
1171
2340
  const checks = collectWorkOSChecks(options.workspaceRoot);
1172
- const realEnvChecks = options.real ? collectWorkOSRealEnvChecks(options.workspaceRoot) : [];
2341
+ const cliAuth = options.real ? ensureWorkOSCliAuthForHosted(options) : undefined;
2342
+ const realEnvChecks = options.real ? collectWorkOSRealEnvChecks(options.workspaceRoot, cliAuth) : [];
1173
2343
  const allChecks = [...checks, ...realEnvChecks];
2344
+ if (options.real && cliAuth && !cliAuth.ok) {
2345
+ allChecks.push(workOSCliAuthCheck(cliAuth));
2346
+ }
1174
2347
  const localOk = allChecks.every((check) => check.ok);
1175
2348
  const seed = parseSeedFile(options.workspaceRoot, file);
1176
2349
  const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
@@ -1189,8 +2362,11 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
1189
2362
  real: options.real ?? false,
1190
2363
  seed,
1191
2364
  seedState,
2365
+ ...(cliAuth ? { cliAuth } : {}),
1192
2366
  configActions,
1193
- nextCommand: "forge workos doctor --json",
2367
+ nextCommand: cliAuth && !cliAuth.ok
2368
+ ? `forge workos setup --real --file ${file} --json`
2369
+ : "forge workos doctor --json",
1194
2370
  },
1195
2371
  exitCode: 1,
1196
2372
  };
@@ -1207,6 +2383,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
1207
2383
  real: false,
1208
2384
  seed,
1209
2385
  seedState,
2386
+ ...(cliAuth ? { cliAuth } : {}),
1210
2387
  configActions,
1211
2388
  nextCommand: `forge workos setup --real --file ${file} --json`,
1212
2389
  },
@@ -1231,6 +2408,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
1231
2408
  data: {
1232
2409
  real: true,
1233
2410
  seed,
2411
+ ...(cliAuth ? { cliAuth } : {}),
1234
2412
  seedState: seedResultData.seedState ?? readWorkOSSeedState(options.workspaceRoot, seed),
1235
2413
  seedResult: seedResult.data,
1236
2414
  nextCommand: "forge workos doctor --json",
@@ -1311,7 +2489,320 @@ export function runWorkOSProveCommand(options: WorkOSCommandOptions): WorkOSComm
1311
2489
  };
1312
2490
  }
1313
2491
 
2492
+ function collectWorkOSFgaChecks(input: {
2493
+ manifest: WorkOSFgaManifest;
2494
+ state: WorkOSFgaStateSummary;
2495
+ requireState?: boolean;
2496
+ requireRealState?: boolean;
2497
+ requireProof?: boolean;
2498
+ }): WorkOSCheck[] {
2499
+ return [
2500
+ {
2501
+ name: "fga-manifest",
2502
+ ok: input.manifest.diagnostics.length === 0,
2503
+ detail: input.manifest.diagnostics.length === 0
2504
+ ? `manifest ${input.manifest.manifestHash.slice(0, 12)} covers ${input.manifest.resourceTypes.length} resource type(s) and ${input.manifest.resources.length} resource(s)`
2505
+ : input.manifest.diagnostics.join("; "),
2506
+ },
2507
+ {
2508
+ name: "fga-resource-types",
2509
+ ok: input.manifest.resourceTypes.length > 0 && input.manifest.resourceTypes.includes("organization"),
2510
+ detail: input.manifest.resourceTypes.length > 0
2511
+ ? `resource types: ${input.manifest.resourceTypes.join(", ")}`
2512
+ : "at least organization plus app resource types are required",
2513
+ },
2514
+ {
2515
+ name: "fga-proof-scenarios",
2516
+ ok: input.manifest.proofScenarios.some((scenario) => scenario.expected === "allow") &&
2517
+ input.manifest.proofScenarios.some((scenario) => scenario.expected === "deny"),
2518
+ detail: `proof scenarios: ${input.manifest.proofScenarios.map((scenario) => `${scenario.name}:${scenario.expected}`).join(", ") || "none"}`,
2519
+ },
2520
+ {
2521
+ name: "fga-state",
2522
+ ok: !input.requireState || Boolean(input.state.exists && input.state.valid && input.state.matchesManifestHash === true),
2523
+ detail: !input.state.exists
2524
+ ? `${WORKOS_FGA_STATE_FILE} is missing`
2525
+ : !input.state.valid
2526
+ ? `${WORKOS_FGA_STATE_FILE} is invalid: ${input.state.diagnostics.join("; ")}`
2527
+ : input.state.matchesManifestHash
2528
+ ? `${WORKOS_FGA_STATE_FILE} matches current manifest`
2529
+ : `${WORKOS_FGA_STATE_FILE} is stale or invalid: ${input.state.diagnostics.join("; ") || "manifest hash mismatch"}`,
2530
+ },
2531
+ {
2532
+ name: "fga-real-state",
2533
+ ok: !input.requireRealState || input.state.mode === "real",
2534
+ detail: input.state.mode === "real"
2535
+ ? `${WORKOS_FGA_STATE_FILE} records real sync/proof mode`
2536
+ : input.requireRealState
2537
+ ? `${WORKOS_FGA_STATE_FILE} must be produced by forge workos fga sync --real --json`
2538
+ : `${WORKOS_FGA_STATE_FILE} real mode not required for this command`,
2539
+ },
2540
+ {
2541
+ name: "fga-proof-state",
2542
+ ok: !input.requireProof || Boolean(input.state.provedAt),
2543
+ detail: input.state.provedAt
2544
+ ? `${WORKOS_FGA_STATE_FILE} records real proof at ${input.state.provedAt}`
2545
+ : input.requireProof
2546
+ ? `${WORKOS_FGA_STATE_FILE} must be produced by forge workos fga prove --real --json`
2547
+ : `${WORKOS_FGA_STATE_FILE} proof timestamp not required for this command`,
2548
+ },
2549
+ ];
2550
+ }
2551
+
2552
+ export function runWorkOSFgaCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
2553
+ const action = options.fgaAction ?? "doctor";
2554
+ const file = options.file ?? DEFAULT_SEED_FILE;
2555
+ const manifest = collectWorkOSFgaManifest(options.workspaceRoot, file);
2556
+ const initialHostedSetup = workOSFgaHostedSetup(manifest);
2557
+ const setupGuidePath = resolveWorkOSFgaSetupGuidePath(options);
2558
+ const writtenSetupGuidePath = setupGuidePath
2559
+ ? writeWorkOSFgaSetupGuide(options.workspaceRoot, workOSFgaSetupGuide(manifest, initialHostedSetup), setupGuidePath)
2560
+ : undefined;
2561
+ let state = readWorkOSFgaState(options.workspaceRoot, manifest);
2562
+ const command = ["forge", "workos", "fga", action, "--file", file];
2563
+ const real = options.real ?? false;
2564
+ const requireState = action === "prove" || action === "doctor";
2565
+ const requireRealState = real && action !== "plan";
2566
+ const requireProof = real && action === "doctor";
2567
+ const seed = parseSeedFile(options.workspaceRoot, file);
2568
+ const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
2569
+ const checks: WorkOSCheck[] = collectWorkOSFgaChecks({
2570
+ manifest,
2571
+ state,
2572
+ requireState,
2573
+ requireRealState,
2574
+ requireProof,
2575
+ });
2576
+
2577
+ if (action === "plan") {
2578
+ const ok = checks.filter((check) => check.name !== "fga-state" && check.name !== "fga-real-state" && check.name !== "fga-proof-state").every((check) => check.ok);
2579
+ return {
2580
+ ok,
2581
+ kind: "workos-fga",
2582
+ checks,
2583
+ command,
2584
+ applied: false,
2585
+ data: fgaData({
2586
+ action,
2587
+ manifest,
2588
+ state,
2589
+ real,
2590
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2591
+ nextCommand: `forge workos fga sync --file ${file} --json`,
2592
+ }),
2593
+ exitCode: ok ? 0 : 1,
2594
+ };
2595
+ }
2596
+
2597
+ if (action === "sync") {
2598
+ const cliAuth = real ? ensureWorkOSCliAuthForHosted(options) : undefined;
2599
+ const realChecks: WorkOSCheck[] = real
2600
+ ? [
2601
+ ...(cliAuth && !cliAuth.ok ? [workOSCliAuthCheck(cliAuth)] : []),
2602
+ {
2603
+ name: "fga-seed-state",
2604
+ ok: Boolean(seedState.exists && seedState.valid && seedState.matchesSeedHash === true),
2605
+ detail: seedState.matchesSeedHash
2606
+ ? `${WORKOS_SEED_STATE_FILE} matches ${seed.path}`
2607
+ : `real FGA sync requires hosted seed evidence; run forge workos prove --real --file ${file} --json first`,
2608
+ },
2609
+ ]
2610
+ : [];
2611
+ const allChecks = [...checks.filter((check) => check.name !== "fga-state" && check.name !== "fga-real-state" && check.name !== "fga-proof-state"), ...realChecks];
2612
+ if (!allChecks.every((check) => check.ok)) {
2613
+ return {
2614
+ ok: false,
2615
+ kind: "workos-fga",
2616
+ checks: allChecks,
2617
+ command: cliAuth && !cliAuth.ok ? cliAuth.loginCommand ?? cliAuth.statusCommand : command,
2618
+ applied: false,
2619
+ data: fgaData({
2620
+ action,
2621
+ manifest,
2622
+ state,
2623
+ real,
2624
+ ...(cliAuth ? { cliAuth } : {}),
2625
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2626
+ nextCommand: real ? `forge workos fga sync --real --file ${file} --json` : `forge workos fga plan --file ${file} --json`,
2627
+ }),
2628
+ exitCode: 1,
2629
+ };
2630
+ }
2631
+ if (options.dryRun) {
2632
+ return {
2633
+ ok: true,
2634
+ kind: "workos-fga",
2635
+ checks: allChecks,
2636
+ command,
2637
+ applied: false,
2638
+ data: fgaData({
2639
+ action,
2640
+ manifest,
2641
+ state,
2642
+ real,
2643
+ ...(cliAuth ? { cliAuth } : {}),
2644
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2645
+ nextCommand: real ? `forge workos fga sync --real --file ${file} --json` : `forge workos fga sync --file ${file} --json`,
2646
+ }),
2647
+ exitCode: 0,
2648
+ };
2649
+ }
2650
+ const sdk = real ? runWorkOSFgaSdk(options, manifest, "sync") : undefined;
2651
+ if (sdk && !sdk.ok) {
2652
+ return {
2653
+ ok: false,
2654
+ kind: "workos-fga",
2655
+ checks: [
2656
+ ...allChecks,
2657
+ {
2658
+ name: "fga-real-sdk-sync",
2659
+ ok: false,
2660
+ detail: workOSFgaSdkFailureDetail(sdk.data, manifest),
2661
+ },
2662
+ ],
2663
+ command: sdk.command,
2664
+ applied: false,
2665
+ data: fgaData({
2666
+ action,
2667
+ manifest,
2668
+ state,
2669
+ real,
2670
+ ...(cliAuth ? { cliAuth } : {}),
2671
+ workosSdk: sdk.data,
2672
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2673
+ nextCommand: `forge workos fga sync --real --file ${file} --json`,
2674
+ nextActions: workOSFgaHostedSetup(manifest, sdk.data).nextActions,
2675
+ }),
2676
+ stdout: sdk.stdout,
2677
+ stderr: sdk.stderr,
2678
+ exitCode: 1,
2679
+ };
2680
+ }
2681
+ const stateFile = writeWorkOSFgaState({
2682
+ workspaceRoot: options.workspaceRoot,
2683
+ manifest,
2684
+ mode: real ? "real" : "local",
2685
+ proved: false,
2686
+ ...(sdk ? { sdkOk: sdk.ok, sdk: sdk.data } : {}),
2687
+ });
2688
+ state = readWorkOSFgaState(options.workspaceRoot, manifest);
2689
+ return {
2690
+ ok: true,
2691
+ kind: "workos-fga",
2692
+ checks: collectWorkOSFgaChecks({ manifest, state, requireState: true, requireRealState: real }),
2693
+ command,
2694
+ applied: true,
2695
+ data: fgaData({
2696
+ action,
2697
+ manifest,
2698
+ state,
2699
+ real,
2700
+ ...(cliAuth ? { cliAuth } : {}),
2701
+ ...(sdk ? { workosSdk: sdk.data } : {}),
2702
+ stateFile,
2703
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2704
+ nextCommand: real ? `forge workos fga prove --real --file ${file} --json` : `forge workos fga prove --file ${file} --json`,
2705
+ }),
2706
+ exitCode: 0,
2707
+ };
2708
+ }
2709
+
2710
+ if (action === "prove") {
2711
+ const cliAuth = real ? ensureWorkOSCliAuthForHosted(options) : undefined;
2712
+ const sdk = real && state.exists && state.valid && state.matchesManifestHash === true && state.mode === "real"
2713
+ ? runWorkOSFgaSdk(options, manifest, "prove")
2714
+ : undefined;
2715
+ const sdkProofComplete = sdk ? workOSFgaSdkProofComplete(sdk.data, manifest) : false;
2716
+ const proofChecks: WorkOSCheck[] = [
2717
+ ...checks,
2718
+ ...(cliAuth && !cliAuth.ok ? [workOSCliAuthCheck(cliAuth)] : []),
2719
+ ...(sdk
2720
+ ? [
2721
+ {
2722
+ name: "fga-real-sdk-proof",
2723
+ ok: sdk.ok && sdkProofComplete,
2724
+ detail: sdk.ok && sdkProofComplete
2725
+ ? "WorkOS Authorization API resource sync/check proof completed for every scenario"
2726
+ : workOSFgaSdkFailureDetail(sdk.data, manifest),
2727
+ },
2728
+ ]
2729
+ : real
2730
+ ? [
2731
+ {
2732
+ name: "fga-real-sdk-proof",
2733
+ ok: false,
2734
+ detail: "real FGA proof requires a fresh real FGA state from forge workos fga sync --real --json",
2735
+ },
2736
+ ]
2737
+ : []),
2738
+ {
2739
+ name: "fga-cross-tenant-proof",
2740
+ ok: manifest.proofScenarios.some((scenario) => scenario.name.includes("cross-tenant") && scenario.expected === "deny"),
2741
+ detail: "FGA proof includes cross-tenant denial scenario using resourceExternalId and resourceTypeSlug",
2742
+ },
2743
+ {
2744
+ name: "fga-authorization-api-shape",
2745
+ ok: true,
2746
+ detail: "proof contract uses organizationMembershipId, resourceExternalId, resourceTypeSlug, and permission slugs",
2747
+ },
2748
+ ];
2749
+ const ok = proofChecks.every((check) => check.ok);
2750
+ const stateFile = ok && !options.dryRun
2751
+ ? writeWorkOSFgaState({
2752
+ workspaceRoot: options.workspaceRoot,
2753
+ manifest,
2754
+ mode: real ? "real" : "local",
2755
+ proved: true,
2756
+ ...(sdk ? { sdkOk: sdk.ok, sdk: sdk.data } : {}),
2757
+ })
2758
+ : undefined;
2759
+ state = stateFile ? readWorkOSFgaState(options.workspaceRoot, manifest) : state;
2760
+ return {
2761
+ ok,
2762
+ kind: "workos-fga",
2763
+ checks: proofChecks,
2764
+ command: cliAuth && !cliAuth.ok ? cliAuth.loginCommand ?? cliAuth.statusCommand : command,
2765
+ applied: Boolean(ok && !options.dryRun),
2766
+ data: fgaData({
2767
+ action,
2768
+ manifest,
2769
+ state,
2770
+ real,
2771
+ ...(cliAuth ? { cliAuth } : {}),
2772
+ ...(sdk ? { workosSdk: sdk.data } : {}),
2773
+ ...(stateFile ? { stateFile } : {}),
2774
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2775
+ nextCommand: ok ? "forge deploy check --production --json" : `forge workos fga sync${real ? " --real" : ""} --file ${file} --json`,
2776
+ }),
2777
+ stdout: sdk?.stdout,
2778
+ stderr: sdk?.stderr,
2779
+ exitCode: ok ? 0 : 1,
2780
+ };
2781
+ }
2782
+
2783
+ const ok = checks.every((check) => check.ok);
2784
+ return {
2785
+ ok,
2786
+ kind: "workos-fga",
2787
+ checks,
2788
+ command,
2789
+ applied: false,
2790
+ data: fgaData({
2791
+ action,
2792
+ manifest,
2793
+ state,
2794
+ real,
2795
+ ...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
2796
+ nextCommand: state.exists ? `forge workos fga prove --file ${file} --json` : `forge workos fga sync --file ${file} --json`,
2797
+ }),
2798
+ exitCode: ok ? 0 : 1,
2799
+ };
2800
+ }
2801
+
1314
2802
  export function runWorkOSCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
2803
+ if (options.subcommand === "fga") {
2804
+ return runWorkOSFgaCommand(options);
2805
+ }
1315
2806
  if (options.subcommand === "install") {
1316
2807
  return runWorkOSInstallCommand(options);
1317
2808
  }
@@ -1335,6 +2826,29 @@ export function formatWorkOSHuman(result: WorkOSCommandResult): string {
1335
2826
  result.ok ? "WorkOS: ok" : "WorkOS: needs attention",
1336
2827
  ...result.checks.map((check) => `${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}`),
1337
2828
  ];
2829
+ const dataObject = result.data && typeof result.data === "object"
2830
+ ? result.data as {
2831
+ cliAuth?: WorkOSCliAuthSummary;
2832
+ seedResult?: { cliAuth?: WorkOSCliAuthSummary };
2833
+ setup?: { cliAuth?: WorkOSCliAuthSummary; seedResult?: { cliAuth?: WorkOSCliAuthSummary } };
2834
+ }
2835
+ : {};
2836
+ const cliAuth = dataObject.cliAuth ?? dataObject.seedResult?.cliAuth ?? dataObject.setup?.cliAuth ?? dataObject.setup?.seedResult?.cliAuth;
2837
+ if (cliAuth) {
2838
+ if (cliAuth.ok && cliAuth.method === "cli") {
2839
+ lines.push(`WorkOS CLI authenticated${cliAuth.email ? ` as ${cliAuth.email}` : ""}; hosted setup can proceed without opening the dashboard.`);
2840
+ } else if (!cliAuth.ok && cliAuth.loginInstructions) {
2841
+ lines.push("WorkOS CLI login required before hosted setup can proceed.");
2842
+ if (cliAuth.loginInstructions.url) {
2843
+ lines.push(`open: ${cliAuth.loginInstructions.url}`);
2844
+ }
2845
+ if (cliAuth.loginInstructions.code) {
2846
+ lines.push(`code: ${cliAuth.loginInstructions.code}`);
2847
+ }
2848
+ } else if (cliAuth.method === "api-key") {
2849
+ lines.push("WorkOS hosted setup will use WORKOS_API_KEY; CLI browser login is optional.");
2850
+ }
2851
+ }
1338
2852
  if (result.command) {
1339
2853
  lines.push(`command: ${result.command.join(" ")}`);
1340
2854
  }
@@ -1396,5 +2910,25 @@ export function formatWorkOSHuman(result: WorkOSCommandResult): string {
1396
2910
  lines.push("WorkOS proof failed; inspect doctor, seed, and setup details.");
1397
2911
  }
1398
2912
  }
2913
+ if (result.kind === "workos-fga") {
2914
+ const data = result.data && typeof result.data === "object"
2915
+ ? result.data as { action?: string; real?: boolean; nextCommand?: string; stateFile?: string; setupGuidePath?: string; nextActions?: string[] }
2916
+ : {};
2917
+ if (data.setupGuidePath) {
2918
+ lines.push(`FGA setup guide: ${data.setupGuidePath}`);
2919
+ }
2920
+ if (result.ok && data.action === "plan") {
2921
+ lines.push(`WorkOS FGA plan passed; run ${data.nextCommand ?? "forge workos fga sync --json"}.`);
2922
+ } else if (result.ok && data.action === "sync") {
2923
+ lines.push(`WorkOS FGA sync recorded${data.stateFile ? ` in ${data.stateFile}` : ""}; run ${data.nextCommand ?? "forge workos fga prove --json"}.`);
2924
+ } else if (result.ok && data.action === "prove") {
2925
+ lines.push(`WorkOS FGA proof passed${data.real ? " for real-mode state" : " locally"}; production deploy gates can inspect the FGA state.`);
2926
+ } else if (!result.ok) {
2927
+ lines.push("WorkOS FGA check failed; inspect manifest diagnostics, seed coverage, and FGA state.");
2928
+ for (const action of data.nextActions ?? []) {
2929
+ lines.push(` - ${action}`);
2930
+ }
2931
+ }
2932
+ }
1399
2933
  return `${lines.join("\n")}\n`;
1400
2934
  }