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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +40 -0
- package/docs/changelog.md +29 -0
- package/package.json +1 -1
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/cli/commands.ts +179 -1
- package/src/forge/cli/deploy.ts +34 -1
- package/src/forge/cli/main.ts +4 -0
- package/src/forge/cli/parse.ts +21 -3
- package/src/forge/cli/workos.ts +1547 -13
- package/src/forge/compiler/diagnostics/codes.ts +6 -0
- package/src/forge/compiler/integration/templates/workos.ts +22 -0
- package/src/forge/version.ts +1 -1
package/src/forge/cli/workos.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
|
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:
|
|
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
|
}
|