forgeos 0.1.0-alpha.54 → 0.1.0-alpha.56
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 +43 -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 +1 -0
- package/src/forge/cli/deploy.ts +208 -44
- package/src/forge/cli/main.ts +7 -3
- package/src/forge/cli/parse.ts +13 -5
- package/src/forge/cli/secrets.ts +131 -1
- package/src/forge/cli/workos.ts +45 -26
- package/src/forge/compiler/integration/add.ts +36 -15
- package/src/forge/compiler/integration/templates/workos.ts +52 -16
- package/src/forge/compiler/types/cli.ts +1 -0
- package/src/forge/version.ts +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.56 input=a013874c7856b533aae64f467111c45bd707703fd9db6f0b8115898afc97af53 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
|
|
2
2
|
# AGENTS.md
|
|
3
3
|
|
|
4
4
|
<!-- forge-generated:start -->
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# forgeos
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.56
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add the production deploy golden path as an explicit CLI workflow.
|
|
8
|
+
|
|
9
|
+
- `forge deploy init --target docker` now acts as the canonical Docker
|
|
10
|
+
production bootstrap command for runtime files, `deploy/.env.production`
|
|
11
|
+
guidance, and production handoff docs.
|
|
12
|
+
- `forge deploy readiness --production --json` now summarizes whether an app
|
|
13
|
+
can publish, grouped blockers/warnings, readiness score, and the next
|
|
14
|
+
command an agent should run.
|
|
15
|
+
- `forge env doctor --target local|staging|production --json` now reports the
|
|
16
|
+
effective auth mode, database posture, provider detection, missing env
|
|
17
|
+
names, and safe source metadata without printing secret values.
|
|
18
|
+
- `forge deploy check --production --json` now includes blocking/warning
|
|
19
|
+
summaries and the readiness object used by the deploy gate.
|
|
20
|
+
- `forge deploy verify --production --url ... --json` now validates required
|
|
21
|
+
public auth metadata and probes optional runtime endpoints such as
|
|
22
|
+
`/ready`, `/live/status`, `/outbox/status`, and `/webhooks/workos`.
|
|
23
|
+
- Production docs now present the canonical flow:
|
|
24
|
+
`deploy init -> env doctor -> auth/auth.md/WorkOS proof -> field-test ->
|
|
25
|
+
readiness -> check -> package -> verify`, while keeping WorkOS FGA
|
|
26
|
+
explicitly optional unless the app opts into FGA artifacts.
|
|
27
|
+
|
|
28
|
+
## 0.1.0-alpha.55
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Make WorkOS FGA opt-in instead of part of the default auth adapter path.
|
|
33
|
+
|
|
34
|
+
- `forge add auth workos` now generates the AuthKit/RBAC/permission-claims
|
|
35
|
+
adapter, seed, policies, tenant claim mapping, webhook helpers, and React
|
|
36
|
+
bridge without WorkOS FGA resource helpers by default.
|
|
37
|
+
- `forge add auth workos --with-fga` keeps generating the optional WorkOS
|
|
38
|
+
Authorization/FGA resource graph helpers, `resource_types` seed metadata,
|
|
39
|
+
cache/telemetry helpers, and cross-tenant resource guard.
|
|
40
|
+
- `forge workos doctor` and `forge workos seed` now treat `resource_types`
|
|
41
|
+
and FGA state as optional unless the app has opted into FGA artifacts.
|
|
42
|
+
- `forge deploy check --production` no longer blocks ordinary WorkOS apps on
|
|
43
|
+
`workos-fga-proof`; it still requires FGA sync/proof when FGA helpers or
|
|
44
|
+
FGA state are present.
|
|
45
|
+
|
|
3
46
|
## 0.1.0-alpha.54
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/docs/changelog.md
CHANGED
|
@@ -6,6 +6,35 @@ The canonical source file in the repository is `CHANGELOG.md`.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.1.0-alpha.56
|
|
10
|
+
|
|
11
|
+
- Added `forge deploy init --target docker` as the canonical production Docker
|
|
12
|
+
bootstrap command.
|
|
13
|
+
- Added `forge deploy readiness --production --json` to answer whether an app
|
|
14
|
+
can publish, what blocks production, and which command should run next.
|
|
15
|
+
- Added `forge env doctor --target local|staging|production --json` for safe,
|
|
16
|
+
target-specific env source reporting without exposing secret values.
|
|
17
|
+
- `forge deploy check --production --json` now includes grouped
|
|
18
|
+
blockers/warnings and a readiness summary for agent handoffs.
|
|
19
|
+
- `forge deploy verify --production --url ... --json` now validates required
|
|
20
|
+
public auth metadata and optional operational endpoints.
|
|
21
|
+
- Production docs now describe the canonical flow from deploy init through
|
|
22
|
+
field-test, readiness, package, and verify, with WorkOS FGA kept optional
|
|
23
|
+
unless the app opts into FGA artifacts.
|
|
24
|
+
|
|
25
|
+
## 0.1.0-alpha.55
|
|
26
|
+
|
|
27
|
+
- `forge add auth workos` now defaults to AuthKit/RBAC, permission claims,
|
|
28
|
+
Forge policies, tenant claim mapping, seed, webhooks, and frontend bridge
|
|
29
|
+
without generating WorkOS FGA resource helpers.
|
|
30
|
+
- `forge add auth workos --with-fga` enables the optional WorkOS FGA resource
|
|
31
|
+
graph helpers and `resource_types` seed metadata for apps that need
|
|
32
|
+
resource-level authorization.
|
|
33
|
+
- WorkOS doctor/seed/deploy checks now treat FGA as optional unless the app has
|
|
34
|
+
FGA artifacts or FGA state, so normal WorkOS apps are gated by AuthKit, RBAC,
|
|
35
|
+
hosted seed evidence, policies, and tenant proof instead of mandatory FGA
|
|
36
|
+
proof.
|
|
37
|
+
|
|
9
38
|
## 0.1.0-alpha.54
|
|
10
39
|
|
|
11
40
|
- WorkOS FGA setup writes now produce a paired machine-readable
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.
|
|
1
|
+
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.56","releaseId":"forgeos@0.1.0-alpha.56+unknown","schemaVersion":"0.1.0"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.56 input=a013874c7856b533aae64f467111c45bd707703fd9db6f0b8115898afc97af53 content=a9688092f903ceb0263f9581e3ab71ecc968284ffd530d9112a5467ea10baf63
|
|
2
2
|
export const releaseManifest = {
|
|
3
3
|
"defaultProvider": "local",
|
|
4
4
|
"diagnostics": [],
|
|
@@ -19,7 +19,7 @@ export const releaseManifest = {
|
|
|
19
19
|
"custom"
|
|
20
20
|
],
|
|
21
21
|
"packageName": "forgeos",
|
|
22
|
-
"packageVersion": "0.1.0-alpha.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
22
|
+
"packageVersion": "0.1.0-alpha.56",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.56+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
|
@@ -2863,6 +2863,7 @@ export async function executeCommand(command: ForgeCommand): Promise<number> {
|
|
|
2863
2863
|
workspaceRoot: command.workspaceRoot,
|
|
2864
2864
|
json: command.json,
|
|
2865
2865
|
redacted: command.redacted,
|
|
2866
|
+
target: command.target,
|
|
2866
2867
|
});
|
|
2867
2868
|
|
|
2868
2869
|
if (command.json) {
|
package/src/forge/cli/deploy.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { runAuthCommand } from "./auth.ts";
|
|
|
13
13
|
import { runAuthMdCommand } from "./authmd.ts";
|
|
14
14
|
import { runWorkOSCommand } from "./workos.ts";
|
|
15
15
|
|
|
16
|
-
export type DeploySubcommand = "plan" | "check" | "render" | "package" | "verify";
|
|
16
|
+
export type DeploySubcommand = "plan" | "init" | "check" | "readiness" | "render" | "package" | "verify";
|
|
17
17
|
export type DeployTarget = "docker" | "forge-cloud";
|
|
18
18
|
|
|
19
19
|
export interface DeployCommandOptions {
|
|
@@ -42,6 +42,8 @@ export interface DeployCommandResult {
|
|
|
42
42
|
target: DeployTarget;
|
|
43
43
|
production: boolean;
|
|
44
44
|
checks: DeployCheck[];
|
|
45
|
+
blocking?: string[];
|
|
46
|
+
warnings?: string[];
|
|
45
47
|
files?: string[];
|
|
46
48
|
plan?: {
|
|
47
49
|
summary: string;
|
|
@@ -50,6 +52,7 @@ export interface DeployCommandResult {
|
|
|
50
52
|
notes: string[];
|
|
51
53
|
};
|
|
52
54
|
probes?: Array<{ method: string; url: string; ok: boolean; status?: number; contentType?: string; error?: string; jsonValid?: boolean }>;
|
|
55
|
+
readiness?: DeployReadinessSummary;
|
|
53
56
|
nextActions: string[];
|
|
54
57
|
exitCode: 0 | 1;
|
|
55
58
|
}
|
|
@@ -57,6 +60,27 @@ export interface DeployCommandResult {
|
|
|
57
60
|
interface DeployProbeExpectation {
|
|
58
61
|
contentTypeIncludes?: string;
|
|
59
62
|
json?: boolean;
|
|
63
|
+
required?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DeployReadinessSummary {
|
|
67
|
+
score: number;
|
|
68
|
+
status: "ready" | "blocked" | "warning";
|
|
69
|
+
blocking: string[];
|
|
70
|
+
warnings: string[];
|
|
71
|
+
stages: Array<{
|
|
72
|
+
name: string;
|
|
73
|
+
ok: boolean;
|
|
74
|
+
score: number;
|
|
75
|
+
checks: string[];
|
|
76
|
+
blocking: string[];
|
|
77
|
+
warnings: string[];
|
|
78
|
+
}>;
|
|
79
|
+
answers: {
|
|
80
|
+
canPublish: boolean;
|
|
81
|
+
ifNotWhatIsMissing: string[];
|
|
82
|
+
nextCommand: string | null;
|
|
83
|
+
};
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
function readGeneratedJson<T>(workspaceRoot: string, relative: string): T | null {
|
|
@@ -93,6 +117,74 @@ function unique(values: string[]): string[] {
|
|
|
93
117
|
return [...new Set(values)];
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
function deployStageForCheck(name: string): string {
|
|
121
|
+
if (["generated", "package-lockfile", "frontend-build-script"].includes(name)) return "build";
|
|
122
|
+
if (["database-url"].includes(name)) return "database";
|
|
123
|
+
if (name.startsWith("auth-") || ["production-auth-mode", "auth-issuer", "auth-audience", "auth-jwks", "tenant-claim"].includes(name)) return "auth";
|
|
124
|
+
if (name.startsWith("workos")) return "workos";
|
|
125
|
+
if (name.startsWith("auth-md") || name === "oauth-protected-resource") return "auth metadata";
|
|
126
|
+
if (name.startsWith("field-test")) return "field test";
|
|
127
|
+
if (name === "live-production") return "live/runtime";
|
|
128
|
+
if (name === "deploy-env-sources") return "environment";
|
|
129
|
+
return "production";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function summarizeDeployReadiness(checks: DeployCheck[], nextActions: string[]): DeployReadinessSummary {
|
|
133
|
+
const blocking = checks
|
|
134
|
+
.filter((check) => !check.ok && check.severity === "error")
|
|
135
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
136
|
+
const warnings = checks
|
|
137
|
+
.filter((check) => !check.ok && check.severity === "warning")
|
|
138
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
139
|
+
const stageNames = [
|
|
140
|
+
"build",
|
|
141
|
+
"environment",
|
|
142
|
+
"database",
|
|
143
|
+
"auth",
|
|
144
|
+
"workos",
|
|
145
|
+
"auth metadata",
|
|
146
|
+
"field test",
|
|
147
|
+
"live/runtime",
|
|
148
|
+
"production",
|
|
149
|
+
];
|
|
150
|
+
const stages = stageNames
|
|
151
|
+
.map((stage) => {
|
|
152
|
+
const stageChecks = checks.filter((check) => deployStageForCheck(check.name) === stage);
|
|
153
|
+
const stageBlocking = stageChecks
|
|
154
|
+
.filter((check) => !check.ok && check.severity === "error")
|
|
155
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
156
|
+
const stageWarnings = stageChecks
|
|
157
|
+
.filter((check) => !check.ok && check.severity === "warning")
|
|
158
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
159
|
+
const passed = stageChecks.filter((check) => check.ok || check.severity === "warning").length;
|
|
160
|
+
const score = stageChecks.length === 0 ? 100 : Math.round((passed / stageChecks.length) * 100);
|
|
161
|
+
return {
|
|
162
|
+
name: stage,
|
|
163
|
+
ok: stageBlocking.length === 0,
|
|
164
|
+
score,
|
|
165
|
+
checks: stageChecks.map((check) => check.name),
|
|
166
|
+
blocking: stageBlocking,
|
|
167
|
+
warnings: stageWarnings,
|
|
168
|
+
};
|
|
169
|
+
})
|
|
170
|
+
.filter((stage) => stage.checks.length > 0);
|
|
171
|
+
const passed = checks.filter((check) => check.ok || check.severity === "warning").length;
|
|
172
|
+
const score = checks.length === 0 ? 100 : Math.round((passed / checks.length) * 100);
|
|
173
|
+
const canPublish = blocking.length === 0;
|
|
174
|
+
return {
|
|
175
|
+
score,
|
|
176
|
+
status: blocking.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "ready",
|
|
177
|
+
blocking,
|
|
178
|
+
warnings,
|
|
179
|
+
stages,
|
|
180
|
+
answers: {
|
|
181
|
+
canPublish,
|
|
182
|
+
ifNotWhatIsMissing: blocking,
|
|
183
|
+
nextCommand: canPublish ? "forge deploy verify --production --url https://app.example.com --json" : nextActions[0] ?? null,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
96
188
|
function hasWorkOSIntegration(workspaceRoot: string): boolean {
|
|
97
189
|
const secrets = new Set(requiredSecretNames(workspaceRoot));
|
|
98
190
|
return secrets.has("WORKOS_API_KEY") ||
|
|
@@ -101,6 +193,12 @@ function hasWorkOSIntegration(workspaceRoot: string): boolean {
|
|
|
101
193
|
nodeFileSystem.exists(join(workspaceRoot, "src/policies.workos.ts"));
|
|
102
194
|
}
|
|
103
195
|
|
|
196
|
+
function hasWorkOSFgaIntegration(workspaceRoot: string): boolean {
|
|
197
|
+
return nodeFileSystem.exists(join(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`)) ||
|
|
198
|
+
nodeFileSystem.exists(join(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`)) ||
|
|
199
|
+
nodeFileSystem.exists(join(workspaceRoot, ".workos-fga-state.json"));
|
|
200
|
+
}
|
|
201
|
+
|
|
104
202
|
function readJsonFile<T>(workspaceRoot: string, relative: string): T | null {
|
|
105
203
|
const absolute = join(workspaceRoot, relative);
|
|
106
204
|
if (!nodeFileSystem.exists(absolute)) return null;
|
|
@@ -590,7 +688,7 @@ function databaseReadyCommand(options: DeployCommandOptions): string {
|
|
|
590
688
|
function renderProductionReadme(): string {
|
|
591
689
|
return `# ForgeOS Production Deploy
|
|
592
690
|
|
|
593
|
-
This directory is generated by \`forge deploy package --target docker\`.
|
|
691
|
+
This directory is generated by \`forge deploy init --target docker\` or \`forge deploy package --target docker\`.
|
|
594
692
|
|
|
595
693
|
## 1. Prepare production env
|
|
596
694
|
|
|
@@ -613,13 +711,17 @@ For WorkOS-backed apps, local dry-runs are not enough for production deploy
|
|
|
613
711
|
readiness. Run:
|
|
614
712
|
|
|
615
713
|
\`\`\`bash
|
|
714
|
+
forge workos setup --real --file workos-seed.yml --json
|
|
616
715
|
forge workos prove --real --file workos-seed.yml --json
|
|
617
716
|
\`\`\`
|
|
618
717
|
|
|
619
|
-
This applies or confirms hosted
|
|
620
|
-
writes \`.workos-seed-state.json\`. \`forge deploy
|
|
621
|
-
that state to match the current \`workos-seed.yml\`
|
|
622
|
-
posture as production evidence.
|
|
718
|
+
This applies or confirms hosted redirect/CORS/webhook config and seed data
|
|
719
|
+
through the WorkOS CLI and writes \`.workos-seed-state.json\`. \`forge deploy
|
|
720
|
+
check --production\` requires that state to match the current \`workos-seed.yml\`
|
|
721
|
+
before treating WorkOS posture as production evidence. WorkOS FGA is optional:
|
|
722
|
+
most apps can ship with AuthKit, RBAC permission claims, Forge policies, and
|
|
723
|
+
tenant isolation; use \`forge add auth workos --with-fga\` only when the app needs
|
|
724
|
+
resource-level authorization outside normal role/permission checks.
|
|
623
725
|
|
|
624
726
|
## 2. Prove the app before traffic
|
|
625
727
|
|
|
@@ -627,9 +729,12 @@ posture as production evidence.
|
|
|
627
729
|
forge generate --check --json
|
|
628
730
|
forge check --json
|
|
629
731
|
forge authmd generate --json
|
|
732
|
+
forge env doctor --target production --json
|
|
630
733
|
forge auth prove --scenario multi-tenant --json
|
|
734
|
+
forge workos setup --real --file workos-seed.yml --json
|
|
631
735
|
forge workos prove --real --file workos-seed.yml --json
|
|
632
736
|
forge field-test run --realistic --json
|
|
737
|
+
forge deploy readiness --production --json
|
|
633
738
|
forge deploy check --production --json
|
|
634
739
|
\`\`\`
|
|
635
740
|
|
|
@@ -648,7 +753,7 @@ forge auth prove --prod --token <jwt> --json
|
|
|
648
753
|
forge deploy verify --production --url https://app.example.com --json
|
|
649
754
|
\`\`\`
|
|
650
755
|
|
|
651
|
-
\`forge auth prove --prod\` verifies a real JWT/OIDC token against the configured issuer/audience/JWKS. \`forge deploy verify --production\` probes \`GET /health\`, \`HEAD /auth.md\`, \`GET /auth.md\`, \`HEAD /.well-known/oauth-protected-resource\`, and \`GET /.well-known/oauth-protected-resource\`; it also validates auth metadata content-types and requires the OAuth protected-resource metadata body to be valid JSON.
|
|
756
|
+
\`forge auth prove --prod\` verifies a real JWT/OIDC token against the configured issuer/audience/JWKS. \`forge deploy verify --production\` probes required endpoints \`GET /health\`, \`HEAD /auth.md\`, \`GET /auth.md\`, \`HEAD /.well-known/oauth-protected-resource\`, and \`GET /.well-known/oauth-protected-resource\`; it also validates auth metadata content-types and requires the OAuth protected-resource metadata body to be valid JSON. Optional probes check \`GET /ready\`, \`GET /live/status\`, \`GET /outbox/status\`, and \`HEAD /webhooks/workos\` when the app exposes those operational endpoints.
|
|
652
757
|
`;
|
|
653
758
|
}
|
|
654
759
|
|
|
@@ -663,10 +768,17 @@ function buildPlan(options: DeployCommandOptions): DeployCommandResult {
|
|
|
663
768
|
"forge deploy verify --production --url https://<your-forge-cloud-app> --json",
|
|
664
769
|
]
|
|
665
770
|
: [
|
|
666
|
-
"forge deploy
|
|
771
|
+
"forge deploy init --target docker",
|
|
667
772
|
"cp deploy/.env.production.example deploy/.env.production",
|
|
668
|
-
"forge
|
|
773
|
+
"forge authmd generate --json",
|
|
774
|
+
"forge env doctor --target production --json",
|
|
775
|
+
"forge auth prove --scenario multi-tenant --json",
|
|
776
|
+
"forge workos setup --real --file workos-seed.yml --json",
|
|
777
|
+
"forge workos prove --real --file workos-seed.yml --json",
|
|
669
778
|
FIELD_TEST_PRODUCTION_COMMAND,
|
|
779
|
+
"forge deploy readiness --production --json",
|
|
780
|
+
"forge deploy check --production --json",
|
|
781
|
+
"forge deploy package --target docker",
|
|
670
782
|
"docker compose -f deploy/docker-compose.yml up --build",
|
|
671
783
|
"forge deploy verify --production --url https://app.example.com --json",
|
|
672
784
|
];
|
|
@@ -693,10 +805,12 @@ function buildPlan(options: DeployCommandOptions): DeployCommandResult {
|
|
|
693
805
|
"field-test report exists with runtime/auth/UI probes",
|
|
694
806
|
"runtime /health responds",
|
|
695
807
|
"tenant and policy proof is run before public traffic",
|
|
808
|
+
"readiness score has no blocking items",
|
|
696
809
|
],
|
|
697
810
|
notes: [
|
|
698
811
|
"dev-headers auth is local-only and must not be enabled for public runtime.",
|
|
699
812
|
"Use forge workos doctor/seed when WorkOS is configured.",
|
|
813
|
+
"WorkOS FGA is optional; ordinary WorkOS apps can be production-ready with AuthKit/RBAC permission claims, Forge policies, and tenant isolation.",
|
|
700
814
|
"Use forge test authz or HTTP probes to prove cross-tenant denial.",
|
|
701
815
|
],
|
|
702
816
|
},
|
|
@@ -821,6 +935,7 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
821
935
|
},
|
|
822
936
|
});
|
|
823
937
|
if (hasWorkOSIntegration(options.workspaceRoot)) {
|
|
938
|
+
const workosFgaEnabled = hasWorkOSFgaIntegration(options.workspaceRoot);
|
|
824
939
|
const workosDoctor = await withDeployEnv(options.workspaceRoot, async () =>
|
|
825
940
|
runWorkOSCommand({
|
|
826
941
|
subcommand: "doctor",
|
|
@@ -835,7 +950,7 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
835
950
|
ok: workosDoctor.exitCode === 0,
|
|
836
951
|
severity: options.production ? "error" : "warning",
|
|
837
952
|
message: workosDoctor.exitCode === 0
|
|
838
|
-
?
|
|
953
|
+
? `WorkOS adapter files, seed, claims, policies, and tenant guards are app-aware${workosFgaEnabled ? " with FGA enabled" : ""}`
|
|
839
954
|
: "WorkOS adapter is incomplete for this app; run forge workos doctor --json",
|
|
840
955
|
command: "forge workos doctor --json",
|
|
841
956
|
details: workosDoctor.checks,
|
|
@@ -855,27 +970,37 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
855
970
|
command: "forge workos prove --real --file workos-seed.yml --json",
|
|
856
971
|
details: seedState,
|
|
857
972
|
});
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
973
|
+
if (workosFgaEnabled) {
|
|
974
|
+
const workosFgaDoctor = await withDeployEnv(options.workspaceRoot, async () =>
|
|
975
|
+
runWorkOSCommand({
|
|
976
|
+
subcommand: "fga",
|
|
977
|
+
fgaAction: "doctor",
|
|
978
|
+
workspaceRoot: options.workspaceRoot,
|
|
979
|
+
json: true,
|
|
980
|
+
yes: false,
|
|
981
|
+
dryRun: true,
|
|
982
|
+
real: options.production,
|
|
983
|
+
})
|
|
984
|
+
);
|
|
985
|
+
checks.push({
|
|
986
|
+
name: "workos-fga-proof",
|
|
987
|
+
ok: !options.production || workosFgaDoctor.exitCode === 0,
|
|
988
|
+
severity: options.production ? "error" : "warning",
|
|
989
|
+
message: workosFgaDoctor.exitCode === 0
|
|
990
|
+
? "WorkOS FGA resource graph state matches the current app contract and seed"
|
|
991
|
+
: "WorkOS production deploy requires FGA graph sync/proof evidence matching the current app because FGA helpers are enabled",
|
|
992
|
+
command: "forge workos fga prove --real --file workos-seed.yml --json",
|
|
993
|
+
details: workosFgaDoctor.data,
|
|
994
|
+
});
|
|
995
|
+
} else {
|
|
996
|
+
checks.push({
|
|
997
|
+
name: "workos-fga-optional",
|
|
998
|
+
ok: true,
|
|
999
|
+
severity: "warning",
|
|
1000
|
+
message: "WorkOS FGA is not enabled; production deploy is gated by AuthKit, RBAC permission claims, Forge policies, tenant proof, and hosted seed evidence",
|
|
1001
|
+
command: "forge add auth workos --with-fga",
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
879
1004
|
}
|
|
880
1005
|
const tenantProofRequired = auth.requiresTenant || hasWorkOSIntegration(options.workspaceRoot);
|
|
881
1006
|
const tenantProof = tenantProofRequired
|
|
@@ -954,6 +1079,10 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
954
1079
|
const failureActions = unique(checks.flatMap((check) =>
|
|
955
1080
|
!check.ok && check.command ? expandDeployFailureCommand(check.command) : []
|
|
956
1081
|
));
|
|
1082
|
+
const nextActions = errorFree
|
|
1083
|
+
? ["forge deploy verify --production --url https://app.example.com --json"]
|
|
1084
|
+
: failureActions;
|
|
1085
|
+
const readiness = summarizeDeployReadiness(checks, nextActions);
|
|
957
1086
|
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
958
1087
|
schemaVersion: "0.1.0",
|
|
959
1088
|
ok: errorFree,
|
|
@@ -962,9 +1091,10 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
962
1091
|
target: options.target,
|
|
963
1092
|
production: options.production,
|
|
964
1093
|
checks,
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1094
|
+
blocking: readiness.blocking,
|
|
1095
|
+
warnings: readiness.warnings,
|
|
1096
|
+
readiness,
|
|
1097
|
+
nextActions,
|
|
968
1098
|
exitCode: errorFree ? 0 : 1,
|
|
969
1099
|
});
|
|
970
1100
|
}
|
|
@@ -1003,6 +1133,7 @@ function renderDocker(options: DeployCommandOptions): DeployCommandResult {
|
|
|
1003
1133
|
files: files.map(([file]) => file),
|
|
1004
1134
|
nextActions: [
|
|
1005
1135
|
"cp deploy/.env.production.example deploy/.env.production",
|
|
1136
|
+
"forge deploy readiness --production --json",
|
|
1006
1137
|
"forge deploy check --production --json",
|
|
1007
1138
|
"docker compose -f deploy/docker-compose.yml up --build",
|
|
1008
1139
|
],
|
|
@@ -1070,22 +1201,32 @@ async function verifyUrl(options: DeployCommandOptions): Promise<DeployCommandRe
|
|
|
1070
1201
|
exitCode: 1,
|
|
1071
1202
|
};
|
|
1072
1203
|
}
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1204
|
+
const probeSpecs: Array<{ method: "GET" | "HEAD"; path: string; expectation?: DeployProbeExpectation }> = [
|
|
1205
|
+
{ method: "GET", path: "/health", expectation: { required: true } },
|
|
1206
|
+
{ method: "HEAD", path: "/auth.md", expectation: { contentTypeIncludes: "text/markdown", required: true } },
|
|
1207
|
+
{ method: "GET", path: "/auth.md", expectation: { contentTypeIncludes: "text/markdown", required: true } },
|
|
1208
|
+
{ method: "HEAD", path: "/.well-known/oauth-protected-resource", expectation: { contentTypeIncludes: "application/json", required: true } },
|
|
1209
|
+
{ method: "GET", path: "/.well-known/oauth-protected-resource", expectation: { contentTypeIncludes: "application/json", json: true, required: true } },
|
|
1210
|
+
{ method: "GET", path: "/ready", expectation: { contentTypeIncludes: "application/json" } },
|
|
1211
|
+
{ method: "GET", path: "/live/status", expectation: { contentTypeIncludes: "application/json" } },
|
|
1212
|
+
{ method: "GET", path: "/outbox/status", expectation: { contentTypeIncludes: "application/json" } },
|
|
1213
|
+
{ method: "HEAD", path: "/webhooks/workos" },
|
|
1214
|
+
];
|
|
1215
|
+
const probes = await Promise.all(probeSpecs.map((item) =>
|
|
1216
|
+
probe(item.method, `${base}${item.path}`, item.expectation ?? {})
|
|
1217
|
+
));
|
|
1080
1218
|
const checks = probes.map((item): DeployCheck => ({
|
|
1081
1219
|
name: `${item.method} ${item.url.replace(base, "") || "/"}`,
|
|
1082
1220
|
ok: item.ok,
|
|
1083
|
-
severity:
|
|
1221
|
+
severity: probeSpecs.find((spec) => `${base}${spec.path}` === item.url && spec.method === item.method)?.expectation?.required || item.url.endsWith("/health")
|
|
1222
|
+
? "error"
|
|
1223
|
+
: "warning",
|
|
1084
1224
|
message: item.ok
|
|
1085
1225
|
? `HTTP ${item.status}${item.contentType ? ` ${item.contentType}` : ""}`
|
|
1086
1226
|
: item.error ?? `HTTP ${item.status ?? "failed"}`,
|
|
1087
1227
|
}));
|
|
1088
1228
|
const ok = checks.every((check) => check.ok || check.severity === "warning");
|
|
1229
|
+
const readiness = summarizeDeployReadiness(checks, ok ? [] : ["check runtime logs", "forge deploy check --production --json"]);
|
|
1089
1230
|
return {
|
|
1090
1231
|
schemaVersion: "0.1.0",
|
|
1091
1232
|
ok,
|
|
@@ -1094,16 +1235,33 @@ async function verifyUrl(options: DeployCommandOptions): Promise<DeployCommandRe
|
|
|
1094
1235
|
target: options.target,
|
|
1095
1236
|
production: options.production,
|
|
1096
1237
|
checks,
|
|
1238
|
+
blocking: readiness.blocking,
|
|
1239
|
+
warnings: readiness.warnings,
|
|
1097
1240
|
probes,
|
|
1098
|
-
|
|
1241
|
+
readiness,
|
|
1242
|
+
nextActions: ok
|
|
1243
|
+
? [
|
|
1244
|
+
"forge auth prove --prod --token <jwt> --json",
|
|
1245
|
+
"forge auth prove --scenario multi-tenant --json",
|
|
1246
|
+
"forge handoff --json",
|
|
1247
|
+
]
|
|
1248
|
+
: ["check runtime logs", "forge deploy check --production --json"],
|
|
1099
1249
|
exitCode: ok ? 0 : 1,
|
|
1100
1250
|
};
|
|
1101
1251
|
}
|
|
1102
1252
|
|
|
1103
1253
|
export async function runDeployCommand(options: DeployCommandOptions): Promise<DeployCommandResult> {
|
|
1104
1254
|
if (options.subcommand === "plan") return buildPlan(options);
|
|
1255
|
+
if (options.subcommand === "readiness") {
|
|
1256
|
+
const result = await buildChecks({ ...options, subcommand: "check", production: true });
|
|
1257
|
+
return {
|
|
1258
|
+
...result,
|
|
1259
|
+
action: "readiness",
|
|
1260
|
+
nextActions: result.readiness?.answers.nextCommand ? [result.readiness.answers.nextCommand] : result.nextActions,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1105
1263
|
if (options.subcommand === "check") return buildChecks(options);
|
|
1106
|
-
if (options.subcommand === "render" || options.subcommand === "package") return renderDocker(options);
|
|
1264
|
+
if (options.subcommand === "init" || options.subcommand === "render" || options.subcommand === "package") return renderDocker(options);
|
|
1107
1265
|
return verifyUrl(options);
|
|
1108
1266
|
}
|
|
1109
1267
|
|
|
@@ -1115,7 +1273,13 @@ export function formatDeployHuman(result: DeployCommandResult): string {
|
|
|
1115
1273
|
const lines = [
|
|
1116
1274
|
`deploy ${result.action} ${result.ok ? "ok" : "failed"}`,
|
|
1117
1275
|
`target: ${result.target}`,
|
|
1276
|
+
...(result.readiness ? [
|
|
1277
|
+
`readiness: ${result.readiness.status} (${result.readiness.score}/100)`,
|
|
1278
|
+
`can publish: ${result.readiness.answers.canPublish ? "yes" : "no"}`,
|
|
1279
|
+
] : []),
|
|
1118
1280
|
...result.checks.map((check) => `${check.ok ? "ok" : check.severity === "warning" ? "warn" : "fail"} ${check.name}: ${check.message}`),
|
|
1281
|
+
...(result.blocking?.length ? ["", "Blocking:", ...result.blocking.map((item) => ` ${item}`)] : []),
|
|
1282
|
+
...(result.warnings?.length ? ["", "Warnings:", ...result.warnings.map((item) => ` ${item}`)] : []),
|
|
1119
1283
|
...(result.files?.length ? ["", "Files:", ...result.files.map((file) => ` ${file}`)] : []),
|
|
1120
1284
|
...(result.plan ? ["", result.plan.summary, "", "Commands:", ...result.plan.commands.map((command) => ` ${command}`)] : []),
|
|
1121
1285
|
...(result.nextActions.length ? ["", "Next:", ...result.nextActions.map((action) => ` ${action}`)] : []),
|
package/src/forge/cli/main.ts
CHANGED
|
@@ -20,6 +20,9 @@ function formatHelp(): string {
|
|
|
20
20
|
" forge field-test create vendor-access --auth workos --install --git --json Create a real field-test app",
|
|
21
21
|
" forge field-test run --realistic --templates vendor-access --package-managers npm --json",
|
|
22
22
|
" forge field-test report --json Summarize the machine-readable field-test report",
|
|
23
|
+
" forge deploy init --target docker --json Generate Docker production files, env template, and deploy README",
|
|
24
|
+
" forge deploy readiness --production --json Answer whether the app can publish, what blocks it, and the next command",
|
|
25
|
+
" forge env doctor --target production --json Show production env sources, missing keys, auth mode, database, and provider",
|
|
23
26
|
" forge seed status --json Discover app seed commands from the generated runtime graph",
|
|
24
27
|
" forge seed dev --json Run the app seed command against the local Forge dev runtime",
|
|
25
28
|
" forge seed dev --all-tenants --json Seed every discovered local tenant/persona profile",
|
|
@@ -38,11 +41,12 @@ function formatHelp(): string {
|
|
|
38
41
|
" forge auth check --production --json Fail unless auth is jwt/oidc production-ready",
|
|
39
42
|
" forge authmd check --json Check public/auth.md drift for CI and agent-ready apps",
|
|
40
43
|
" forge workos install --yes --json Delegate AuthKit setup to npx --yes workos@latest install",
|
|
41
|
-
" forge workos doctor --json Check WorkOS AuthKit/
|
|
44
|
+
" forge workos doctor --json Check WorkOS AuthKit/RBAC files, claims, seed, webhook, and tenant guards",
|
|
42
45
|
" forge workos doctor --yes --json Run local checks, then delegate to npx --yes workos@latest doctor",
|
|
43
46
|
" forge workos seed --file workos-seed.yml --dry-run --json Validate WorkOS seed without hosted changes",
|
|
44
|
-
" forge workos prove --file workos-seed.yml --json Prove local WorkOS/AuthKit/
|
|
47
|
+
" forge workos prove --file workos-seed.yml --json Prove local WorkOS/AuthKit/RBAC/seed readiness without dashboard changes",
|
|
45
48
|
" forge workos setup --real --file workos-seed.yml --json Apply redirect/CORS/homepage config and seed WorkOS without dashboard",
|
|
49
|
+
" forge add auth workos --with-fga Add optional WorkOS FGA resource-level authorization helpers",
|
|
46
50
|
" forge workos fga plan --json Derive the WorkOS FGA resource graph and proof scenarios from the app",
|
|
47
51
|
" forge workos fga plan --write --json Write .forge/workos-fga-setup.md for hosted FGA resource type setup",
|
|
48
52
|
" forge workos fga sync --json Record an idempotent local FGA graph state for deploy gates",
|
|
@@ -50,7 +54,7 @@ function formatHelp(): string {
|
|
|
50
54
|
" forge deploy plan --target docker --json Explain production deploy gates and commands",
|
|
51
55
|
" forge deploy check --production --json Gate auth, DB, metadata, generated artifacts, and liveQuery readiness",
|
|
52
56
|
" forge deploy package --target docker Write Docker production deploy files under deploy/",
|
|
53
|
-
" forge deploy verify --production --url https://app.example.com --json Probe
|
|
57
|
+
" forge deploy verify --production --url https://app.example.com --json Probe health, public auth metadata, and optional runtime endpoints",
|
|
54
58
|
" forge release check --allow-missing-local-release --json Gate release readiness without failing on unprepared local artifacts",
|
|
55
59
|
" forge self-host check --prepared-only --json Report compose readiness without creating deploy files",
|
|
56
60
|
" forge delta status --verbose --json Include Delta schema, lock, and aggregate count details",
|
package/src/forge/cli/parse.ts
CHANGED
|
@@ -418,6 +418,7 @@ export type ForgeCommand =
|
|
|
418
418
|
subcommand: EnvSubcommand;
|
|
419
419
|
json: boolean;
|
|
420
420
|
redacted: boolean;
|
|
421
|
+
target?: "local" | "staging" | "production";
|
|
421
422
|
workspaceRoot: string;
|
|
422
423
|
}
|
|
423
424
|
| {
|
|
@@ -602,7 +603,7 @@ const BASELINE_SUBCOMMANDS: BaselineSubcommand[] = ["create", "status"];
|
|
|
602
603
|
const AUTHMD_SUBCOMMANDS: AuthMdSubcommand[] = ["generate", "check"];
|
|
603
604
|
const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed", "setup", "prove", "fga"];
|
|
604
605
|
const WORKOS_FGA_ACTIONS: WorkOSFgaAction[] = ["plan", "sync", "prove", "doctor"];
|
|
605
|
-
const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "check", "render", "package", "verify"];
|
|
606
|
+
const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "init", "check", "readiness", "render", "package", "verify"];
|
|
606
607
|
const FIELD_TEST_SUBCOMMANDS: FieldTestSubcommand[] = ["create", "run", "report"];
|
|
607
608
|
const SECURITY_SUBCOMMANDS: SecuritySubcommand[] = ["prove"];
|
|
608
609
|
const RLS_SUBCOMMANDS: RlsSubcommand[] = ["generate", "check", "apply", "test", "mutate-test"];
|
|
@@ -822,6 +823,7 @@ function parseAddOptions(
|
|
|
822
823
|
parseOptionValue(args, "--sandbox-backend"),
|
|
823
824
|
),
|
|
824
825
|
allowScripts: parseFlag(args, "--allow-scripts"),
|
|
826
|
+
withFga: parseFlag(args, "--with-fga"),
|
|
825
827
|
mode,
|
|
826
828
|
installWorkspace: parseOptionValue(args, "--workspace"),
|
|
827
829
|
packageTarget: frontend && !backend ? "frontend" : backend ? "backend" : undefined,
|
|
@@ -1483,10 +1485,10 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
1483
1485
|
case "deploy": {
|
|
1484
1486
|
const subcommand = rest[0] as DeploySubcommand | undefined;
|
|
1485
1487
|
if (!subcommand || !DEPLOY_SUBCOMMANDS.includes(subcommand)) {
|
|
1486
|
-
errors.push("forge deploy requires subcommand: plan, check, render, package, or verify");
|
|
1488
|
+
errors.push("forge deploy requires subcommand: plan, init, check, readiness, render, package, or verify");
|
|
1487
1489
|
return { command: null, workspaceRoot, errors };
|
|
1488
1490
|
}
|
|
1489
|
-
const targetRaw = parseOptionValue(argv, "--target") ?? (subcommand === "render" || subcommand === "package" ? rest[1] : undefined) ?? "docker";
|
|
1491
|
+
const targetRaw = parseOptionValue(argv, "--target") ?? (subcommand === "init" || subcommand === "render" || subcommand === "package" ? rest[1] : undefined) ?? "docker";
|
|
1490
1492
|
if (targetRaw !== "docker" && targetRaw !== "forge-cloud") {
|
|
1491
1493
|
errors.push("forge deploy --target must be docker or forge-cloud");
|
|
1492
1494
|
}
|
|
@@ -2973,10 +2975,14 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
2973
2975
|
}
|
|
2974
2976
|
case "env": {
|
|
2975
2977
|
const subcommand = rest[0] as EnvSubcommand | undefined;
|
|
2976
|
-
if (!subcommand || !["list", "check", "print"].includes(subcommand)) {
|
|
2977
|
-
errors.push("forge env requires subcommand: list, check, or
|
|
2978
|
+
if (!subcommand || !["list", "check", "print", "doctor"].includes(subcommand)) {
|
|
2979
|
+
errors.push("forge env requires subcommand: list, check, print, or doctor");
|
|
2978
2980
|
return { command: null, workspaceRoot, errors };
|
|
2979
2981
|
}
|
|
2982
|
+
const targetRaw = parseOptionValue(argv, "--target") ?? "local";
|
|
2983
|
+
if (!["local", "staging", "production"].includes(targetRaw)) {
|
|
2984
|
+
errors.push("forge env --target must be local, staging, or production");
|
|
2985
|
+
}
|
|
2980
2986
|
|
|
2981
2987
|
return {
|
|
2982
2988
|
command: {
|
|
@@ -2984,6 +2990,7 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
2984
2990
|
subcommand,
|
|
2985
2991
|
json: parseFlag(argv, "--json"),
|
|
2986
2992
|
redacted: parseFlag(argv, "--redacted"),
|
|
2993
|
+
target: targetRaw as "local" | "staging" | "production",
|
|
2987
2994
|
workspaceRoot,
|
|
2988
2995
|
},
|
|
2989
2996
|
workspaceRoot,
|
|
@@ -3117,6 +3124,7 @@ export function hasUnknownOption(argv: string[]): string | null {
|
|
|
3117
3124
|
"--with-livequery",
|
|
3118
3125
|
"--with-react",
|
|
3119
3126
|
"--with-ui",
|
|
3127
|
+
"--with-fga",
|
|
3120
3128
|
"--with-tests",
|
|
3121
3129
|
"--with-create-form",
|
|
3122
3130
|
"--write",
|
package/src/forge/cli/secrets.ts
CHANGED
|
@@ -219,13 +219,141 @@ export function formatSecretsJson(result: SecretsCommandResult): string {
|
|
|
219
219
|
return `${JSON.stringify(result, null, 2)}\n`;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
export type EnvSubcommand = "list" | "check" | "print";
|
|
222
|
+
export type EnvSubcommand = "list" | "check" | "print" | "doctor";
|
|
223
|
+
export type EnvDoctorTarget = "local" | "staging" | "production";
|
|
223
224
|
|
|
224
225
|
export interface EnvCommandOptions {
|
|
225
226
|
subcommand: EnvSubcommand;
|
|
226
227
|
workspaceRoot: string;
|
|
227
228
|
json: boolean;
|
|
228
229
|
redacted?: boolean;
|
|
230
|
+
target?: EnvDoctorTarget;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ENV_DOCTOR_KEYS = [
|
|
234
|
+
"DATABASE_URL",
|
|
235
|
+
"FORGE_AUTH_MODE",
|
|
236
|
+
"FORGE_AUTH_ISSUER",
|
|
237
|
+
"FORGE_AUTH_AUDIENCE",
|
|
238
|
+
"FORGE_AUTH_JWKS_URI",
|
|
239
|
+
"FORGE_AUTH_DISCOVERY_URL",
|
|
240
|
+
"WORKOS_API_KEY",
|
|
241
|
+
"WORKOS_CLIENT_ID",
|
|
242
|
+
"WORKOS_COOKIE_PASSWORD",
|
|
243
|
+
"WORKOS_REDIRECT_URI",
|
|
244
|
+
"WORKOS_POST_LOGIN_REDIRECT_URI",
|
|
245
|
+
"WORKOS_POST_LOGOUT_REDIRECT_URI",
|
|
246
|
+
"WORKOS_WEBHOOK_SECRET",
|
|
247
|
+
] as const;
|
|
248
|
+
|
|
249
|
+
function parseEnvFile(workspaceRoot: string, relative: string): Record<string, string> {
|
|
250
|
+
const path = join(workspaceRoot, relative);
|
|
251
|
+
if (!nodeFileSystem.exists(path)) return {};
|
|
252
|
+
const values: Record<string, string> = {};
|
|
253
|
+
for (const rawLine of (nodeFileSystem.readText(path) ?? "").split(/\r?\n/)) {
|
|
254
|
+
const line = rawLine.trim();
|
|
255
|
+
if (!line || line.startsWith("#")) continue;
|
|
256
|
+
const eq = line.indexOf("=");
|
|
257
|
+
if (eq <= 0) continue;
|
|
258
|
+
const rawValue = line.slice(eq + 1).trim();
|
|
259
|
+
values[line.slice(0, eq).trim()] = rawValue.replace(/^["']|["']$/g, "");
|
|
260
|
+
}
|
|
261
|
+
return values;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function envDoctorFiles(target: EnvDoctorTarget): string[] {
|
|
265
|
+
if (target === "production") return ["deploy/.env.production"];
|
|
266
|
+
if (target === "staging") return ["deploy/.env.staging", ".env.staging"];
|
|
267
|
+
return [".env", ".env.local"];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function hasWorkOSAppArtifacts(workspaceRoot: string): boolean {
|
|
271
|
+
return nodeFileSystem.exists(join(workspaceRoot, "workos-seed.yml")) ||
|
|
272
|
+
nodeFileSystem.exists(join(workspaceRoot, "src/policies.workos.ts")) ||
|
|
273
|
+
nodeFileSystem.exists(join(workspaceRoot, "src/forge/_generated/integrations/workos/auth-routes.ts"));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function envDoctor(options: EnvCommandOptions): SecretsCommandResult {
|
|
277
|
+
const target = options.target ?? "local";
|
|
278
|
+
const files = envDoctorFiles(target).map((path) => {
|
|
279
|
+
const values = parseEnvFile(options.workspaceRoot, path);
|
|
280
|
+
return {
|
|
281
|
+
path,
|
|
282
|
+
present: nodeFileSystem.exists(join(options.workspaceRoot, path)),
|
|
283
|
+
keys: Object.keys(values).filter((key) => ENV_DOCTOR_KEYS.includes(key as (typeof ENV_DOCTOR_KEYS)[number])).sort(),
|
|
284
|
+
values,
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
const processValues = Object.fromEntries(
|
|
288
|
+
ENV_DOCTOR_KEYS.filter((key) => Boolean(process.env[key])).map((key) => [key, process.env[key]!]),
|
|
289
|
+
);
|
|
290
|
+
const effective = {
|
|
291
|
+
...Object.assign({}, ...files.map((file) => file.values)),
|
|
292
|
+
...processValues,
|
|
293
|
+
} as Record<string, string>;
|
|
294
|
+
const required = target === "production"
|
|
295
|
+
? ["DATABASE_URL", "FORGE_AUTH_MODE", "FORGE_AUTH_ISSUER", "FORGE_AUTH_AUDIENCE"]
|
|
296
|
+
: ["FORGE_AUTH_MODE"];
|
|
297
|
+
const missing = required.filter((key) => !effective[key]);
|
|
298
|
+
const authMode = effective.FORGE_AUTH_MODE ?? "dev-headers";
|
|
299
|
+
const productionAuth = authMode === "jwt" || authMode === "oidc";
|
|
300
|
+
const database = effective.DATABASE_URL
|
|
301
|
+
? "postgres"
|
|
302
|
+
: target === "production"
|
|
303
|
+
? "missing"
|
|
304
|
+
: "local-dev";
|
|
305
|
+
const workosDetected = hasWorkOSAppArtifacts(options.workspaceRoot) ||
|
|
306
|
+
Boolean(effective.WORKOS_CLIENT_ID || effective.WORKOS_API_KEY);
|
|
307
|
+
const provider = workosDetected ? "workos" : "none";
|
|
308
|
+
const hasJwtSource = Boolean(effective.FORGE_AUTH_JWKS_URI || effective.FORGE_AUTH_DISCOVERY_URL);
|
|
309
|
+
const blockers = [
|
|
310
|
+
...missing.filter((key) => key !== "DATABASE_URL").map((key) => `${key} missing`),
|
|
311
|
+
...(target === "production" && !productionAuth ? [`FORGE_AUTH_MODE=${authMode} is not production auth`] : []),
|
|
312
|
+
...(target === "production" && database === "missing" ? ["DATABASE_URL missing"] : []),
|
|
313
|
+
...(target === "production" && productionAuth && !hasJwtSource
|
|
314
|
+
? ["FORGE_AUTH_JWKS_URI or FORGE_AUTH_DISCOVERY_URL missing"]
|
|
315
|
+
: []),
|
|
316
|
+
...(target === "production" && workosDetected && !effective.WORKOS_CLIENT_ID ? ["WORKOS_CLIENT_ID missing"] : []),
|
|
317
|
+
...(target === "production" && workosDetected && !effective.WORKOS_API_KEY ? ["WORKOS_API_KEY missing"] : []),
|
|
318
|
+
];
|
|
319
|
+
const warnings = [
|
|
320
|
+
...(target === "production" && files.every((file) => !file.present)
|
|
321
|
+
? ["deploy/.env.production not found; process.env alone may be fine in CI but is easy for agents to miss"]
|
|
322
|
+
: []),
|
|
323
|
+
...(workosDetected && !effective.WORKOS_COOKIE_PASSWORD ? ["WORKOS_COOKIE_PASSWORD missing"] : []),
|
|
324
|
+
...(workosDetected && !effective.WORKOS_REDIRECT_URI ? ["WORKOS_REDIRECT_URI missing"] : []),
|
|
325
|
+
];
|
|
326
|
+
return {
|
|
327
|
+
exitCode: blockers.length === 0 ? 0 : 1,
|
|
328
|
+
data: {
|
|
329
|
+
schemaVersion: "0.1.0",
|
|
330
|
+
kind: "env-doctor",
|
|
331
|
+
ok: blockers.length === 0,
|
|
332
|
+
target,
|
|
333
|
+
authMode,
|
|
334
|
+
productionAuth,
|
|
335
|
+
database,
|
|
336
|
+
provider,
|
|
337
|
+
sources: [
|
|
338
|
+
{
|
|
339
|
+
path: "process.env",
|
|
340
|
+
present: Object.keys(processValues).length > 0,
|
|
341
|
+
keys: Object.keys(processValues).sort(),
|
|
342
|
+
values: undefined,
|
|
343
|
+
},
|
|
344
|
+
...files.map(({ path, present, keys }) => ({ path, present, keys })),
|
|
345
|
+
],
|
|
346
|
+
present: ENV_DOCTOR_KEYS.filter((key) => Boolean(effective[key])).sort(),
|
|
347
|
+
missing,
|
|
348
|
+
blockers,
|
|
349
|
+
warnings,
|
|
350
|
+
nextActions: blockers.length === 0
|
|
351
|
+
? ["forge deploy readiness --production --json"]
|
|
352
|
+
: target === "production"
|
|
353
|
+
? ["cp deploy/.env.production.example deploy/.env.production", "forge env doctor --target production --json"]
|
|
354
|
+
: ["forge env doctor --target local --json"],
|
|
355
|
+
},
|
|
356
|
+
};
|
|
229
357
|
}
|
|
230
358
|
|
|
231
359
|
export async function runEnvCommand(options: EnvCommandOptions): Promise<SecretsCommandResult> {
|
|
@@ -233,6 +361,8 @@ export async function runEnvCommand(options: EnvCommandOptions): Promise<Secrets
|
|
|
233
361
|
const schema = loadEnvSchema(options.workspaceRoot);
|
|
234
362
|
|
|
235
363
|
switch (options.subcommand) {
|
|
364
|
+
case "doctor":
|
|
365
|
+
return envDoctor(options);
|
|
236
366
|
case "list":
|
|
237
367
|
if (!schema) {
|
|
238
368
|
return {
|
package/src/forge/cli/workos.ts
CHANGED
|
@@ -85,6 +85,12 @@ function exists(root: string, path: string): boolean {
|
|
|
85
85
|
return existsSync(join(root, path));
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function hasWorkOSFgaArtifacts(root: string): boolean {
|
|
89
|
+
return exists(root, `${GENERATED_DIR}/integrations/workos/fga.ts`) ||
|
|
90
|
+
exists(root, `${GENERATED_DIR}/integrations/workos/resource-map.ts`) ||
|
|
91
|
+
exists(root, WORKOS_FGA_STATE_FILE);
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
function readJson(root: string, path: string): unknown | null {
|
|
89
95
|
const absolute = join(root, path);
|
|
90
96
|
if (!existsSync(absolute)) {
|
|
@@ -288,7 +294,6 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
|
|
|
288
294
|
|
|
289
295
|
if (permissions.size === 0) diagnostics.push("no permission slugs found");
|
|
290
296
|
if (roles.size === 0) diagnostics.push("no role slugs found");
|
|
291
|
-
if (resourceTypes.size === 0) diagnostics.push("no resource_types slugs found");
|
|
292
297
|
if (organizations.size === 0) diagnostics.push("no organizations found");
|
|
293
298
|
|
|
294
299
|
return {
|
|
@@ -1742,6 +1747,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
1742
1747
|
const authRoutes = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/auth-routes.ts`);
|
|
1743
1748
|
const fga = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`);
|
|
1744
1749
|
const resourceMap = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`);
|
|
1750
|
+
const fgaEnabled = hasWorkOSFgaArtifacts(workspaceRoot);
|
|
1745
1751
|
const httpHandler = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/http-handler.ts`);
|
|
1746
1752
|
const policies = readText(workspaceRoot, "src/policies.workos.ts");
|
|
1747
1753
|
const session = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/session.ts`);
|
|
@@ -1842,7 +1848,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
1842
1848
|
name: "seed-file",
|
|
1843
1849
|
ok: seed.exists,
|
|
1844
1850
|
detail: seed.exists
|
|
1845
|
-
? `${seed.path} exists with ${seed.permissions.length} permission(s), ${seed.roles.length} role(s), ${seed.resourceTypes.length} resource type(s)`
|
|
1851
|
+
? `${seed.path} exists with ${seed.permissions.length} permission(s), ${seed.roles.length} role(s), ${seed.resourceTypes.length} optional FGA resource type(s)`
|
|
1846
1852
|
: `${DEFAULT_SEED_FILE} or ${GENERATED_SEED_FILE} is required`,
|
|
1847
1853
|
},
|
|
1848
1854
|
{
|
|
@@ -1871,11 +1877,13 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
1871
1877
|
},
|
|
1872
1878
|
{
|
|
1873
1879
|
name: "seed-resource-types",
|
|
1874
|
-
ok: seed.resourceTypes.length > 0 &&
|
|
1875
|
-
(expectedResourceTypes.length === 0 || missingSeedResources.length === 0),
|
|
1876
|
-
detail:
|
|
1877
|
-
?
|
|
1878
|
-
:
|
|
1880
|
+
ok: !fgaEnabled || (seed.resourceTypes.length > 0 &&
|
|
1881
|
+
(expectedResourceTypes.length === 0 || missingSeedResources.length === 0)),
|
|
1882
|
+
detail: !fgaEnabled
|
|
1883
|
+
? "FGA is not enabled; resource_types are optional for AuthKit/RBAC apps"
|
|
1884
|
+
: missingSeedResources.length === 0
|
|
1885
|
+
? `seed resource_types cover app graph: ${expectedResourceTypes.length > 0 ? expectedResourceTypes.join(", ") : seed.resourceTypes.join(", ")}`
|
|
1886
|
+
: `seed missing resource_type(s) for app graph: ${missingSeedResources.join(", ")}`,
|
|
1879
1887
|
},
|
|
1880
1888
|
{
|
|
1881
1889
|
name: "seed-auth-config",
|
|
@@ -1897,21 +1905,25 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
1897
1905
|
},
|
|
1898
1906
|
{
|
|
1899
1907
|
name: "fga-plan",
|
|
1900
|
-
ok: fgaManifest.diagnostics.length === 0,
|
|
1901
|
-
detail:
|
|
1902
|
-
?
|
|
1903
|
-
:
|
|
1908
|
+
ok: !fgaEnabled || fgaManifest.diagnostics.length === 0,
|
|
1909
|
+
detail: !fgaEnabled
|
|
1910
|
+
? "FGA is not enabled; run forge add auth workos --with-fga for resource-level WorkOS Authorization"
|
|
1911
|
+
: fgaManifest.diagnostics.length === 0
|
|
1912
|
+
? `FGA plan covers ${fgaManifest.resourceTypes.length} resource type(s), ${fgaManifest.resources.length} resource(s), and ${fgaManifest.proofScenarios.length} proof scenario(s)`
|
|
1913
|
+
: `FGA plan has gap(s): ${fgaManifest.diagnostics.join("; ")}`,
|
|
1904
1914
|
},
|
|
1905
1915
|
{
|
|
1906
1916
|
name: "fga-state",
|
|
1907
1917
|
ok: true,
|
|
1908
|
-
detail: !
|
|
1909
|
-
?
|
|
1910
|
-
: fgaState.
|
|
1911
|
-
? `${WORKOS_FGA_STATE_FILE}
|
|
1912
|
-
: fgaState.
|
|
1913
|
-
? `${WORKOS_FGA_STATE_FILE}
|
|
1914
|
-
:
|
|
1918
|
+
detail: !fgaEnabled
|
|
1919
|
+
? "FGA is not enabled; no FGA state file is required"
|
|
1920
|
+
: !fgaState.exists
|
|
1921
|
+
? `${WORKOS_FGA_STATE_FILE} not found; run forge workos fga sync --json before production deploy`
|
|
1922
|
+
: fgaState.matchesManifestHash
|
|
1923
|
+
? `${WORKOS_FGA_STATE_FILE} matches current FGA manifest${fgaState.mode === "real" ? " in real mode" : ""}`
|
|
1924
|
+
: fgaState.valid
|
|
1925
|
+
? `${WORKOS_FGA_STATE_FILE} exists but does not match current FGA manifest; rerun forge workos fga sync --json`
|
|
1926
|
+
: `${WORKOS_FGA_STATE_FILE} exists but is invalid: ${fgaState.diagnostics.join("; ")}`,
|
|
1915
1927
|
},
|
|
1916
1928
|
{
|
|
1917
1929
|
name: "authkit-routes",
|
|
@@ -1935,7 +1947,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
1935
1947
|
},
|
|
1936
1948
|
{
|
|
1937
1949
|
name: "fga-bridge",
|
|
1938
|
-
ok: includesAll(resourceMap, [
|
|
1950
|
+
ok: !fgaEnabled || (includesAll(resourceMap, [
|
|
1939
1951
|
"canWorkOS",
|
|
1940
1952
|
"assertWorkOSResourceTenant",
|
|
1941
1953
|
"FORGE_WORKOS_CROSS_TENANT_RESOURCE",
|
|
@@ -1945,8 +1957,10 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
1945
1957
|
"permissionSlug",
|
|
1946
1958
|
"resourceExternalId",
|
|
1947
1959
|
]) &&
|
|
1948
|
-
includesAll(fga, ["forgeWorkOSResourceTypes"]),
|
|
1949
|
-
detail:
|
|
1960
|
+
includesAll(fga, ["forgeWorkOSResourceTypes"])),
|
|
1961
|
+
detail: fgaEnabled
|
|
1962
|
+
? "WorkOS FGA resource-map bridge exists with sync, cache, telemetry, official check shape, and cross-tenant guard"
|
|
1963
|
+
: "FGA bridge is optional and not enabled for this WorkOS AuthKit/RBAC app",
|
|
1950
1964
|
},
|
|
1951
1965
|
{
|
|
1952
1966
|
name: "policies",
|
|
@@ -2276,6 +2290,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
2276
2290
|
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
2277
2291
|
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
2278
2292
|
const unusedSeedPermissions = seed.permissions.filter((permission) => !activePermissions.includes(permission));
|
|
2293
|
+
const fgaEnabled = hasWorkOSFgaArtifacts(options.workspaceRoot);
|
|
2279
2294
|
const checks = [
|
|
2280
2295
|
{
|
|
2281
2296
|
name: "seed-file",
|
|
@@ -2285,7 +2300,9 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
2285
2300
|
{
|
|
2286
2301
|
name: "seed-yaml-shape",
|
|
2287
2302
|
ok: seed.valid,
|
|
2288
|
-
detail: seed.valid
|
|
2303
|
+
detail: seed.valid
|
|
2304
|
+
? `seed contains permissions, roles, organizations${seed.resourceTypes.length > 0 ? ", and optional FGA resource_types" : ""}`
|
|
2305
|
+
: seed.diagnostics.join("; "),
|
|
2289
2306
|
},
|
|
2290
2307
|
{
|
|
2291
2308
|
name: "seed-policy-coverage",
|
|
@@ -2305,10 +2322,12 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
2305
2322
|
},
|
|
2306
2323
|
{
|
|
2307
2324
|
name: "seed-resource-coverage",
|
|
2308
|
-
ok: missingSeedResources.length === 0,
|
|
2309
|
-
detail:
|
|
2310
|
-
?
|
|
2311
|
-
:
|
|
2325
|
+
ok: !fgaEnabled || missingSeedResources.length === 0,
|
|
2326
|
+
detail: !fgaEnabled
|
|
2327
|
+
? "FGA is not enabled; seed resource type coverage is not required"
|
|
2328
|
+
: missingSeedResources.length === 0
|
|
2329
|
+
? `seed covers app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
|
|
2330
|
+
: `seed missing resource type(s): ${missingSeedResources.join(", ")}`,
|
|
2312
2331
|
},
|
|
2313
2332
|
{
|
|
2314
2333
|
name: "seed-hosted-config",
|
|
@@ -3,6 +3,7 @@ import { nodeFileSystem } from "../fs/index.ts";
|
|
|
3
3
|
import type { AddOptions } from "../types/cli.ts";
|
|
4
4
|
import type { Diagnostic } from "../types/diagnostic.ts";
|
|
5
5
|
import type { Dependency } from "../types/package-graph.ts";
|
|
6
|
+
import type { IntegrationRecipe } from "../types/integration.ts";
|
|
6
7
|
import type { ClassifiedPackage } from "../classifier/runtime-matrix.ts";
|
|
7
8
|
import { buildAppGraph } from "../app-graph/build.ts";
|
|
8
9
|
import { classify } from "../classifier/classify.ts";
|
|
@@ -79,7 +80,7 @@ export interface ForgeAddResult {
|
|
|
79
80
|
failureKind?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
function recipeResultMetadata(recipe:
|
|
83
|
+
function recipeResultMetadata(recipe: IntegrationRecipe): Pick<
|
|
83
84
|
ForgeAddResult,
|
|
84
85
|
"recipeVersion" | "recipePackages" | "requiredSecrets" | "optionalSecrets"
|
|
85
86
|
> {
|
|
@@ -91,6 +92,24 @@ function recipeResultMetadata(recipe: NonNullable<ReturnType<typeof resolveRecip
|
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
const WORKOS_FGA_INTEGRATION_FILES = new Set([
|
|
96
|
+
"workos/fga.ts",
|
|
97
|
+
"workos/resource-map.ts",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function applyWorkOSRecipeProfile(
|
|
101
|
+
recipe: NonNullable<ReturnType<typeof resolveRecipe>>,
|
|
102
|
+
options: Pick<ForgeAddOptions, "withFga">,
|
|
103
|
+
): IntegrationRecipe {
|
|
104
|
+
if (recipe.alias !== "workos" || options.withFga) {
|
|
105
|
+
return recipe;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
...recipe,
|
|
109
|
+
integrations: recipe.integrations?.filter((file) => !WORKOS_FGA_INTEGRATION_FILES.has(file)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
function addExplanation(result: ForgeAddResult): string {
|
|
95
114
|
if (result.mode === "package") {
|
|
96
115
|
const location = result.target && result.target !== "root" ? `${result.target}/package.json` : "package.json";
|
|
@@ -726,7 +745,7 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
|
|
|
726
745
|
}
|
|
727
746
|
|
|
728
747
|
async function analyzeRecipePackages(
|
|
729
|
-
recipe:
|
|
748
|
+
recipe: IntegrationRecipe,
|
|
730
749
|
ctx: ReturnType<typeof discover>,
|
|
731
750
|
installRoot: string,
|
|
732
751
|
options: ForgeAddOptions,
|
|
@@ -783,7 +802,7 @@ async function analyzeRecipePackages(
|
|
|
783
802
|
|
|
784
803
|
async function buildAddPlan(
|
|
785
804
|
alias: string,
|
|
786
|
-
recipe:
|
|
805
|
+
recipe: IntegrationRecipe,
|
|
787
806
|
ctx: ReturnType<typeof discover>,
|
|
788
807
|
installRoot: string,
|
|
789
808
|
options: ForgeAddOptions,
|
|
@@ -945,6 +964,8 @@ export async function forgeAdd(
|
|
|
945
964
|
});
|
|
946
965
|
}
|
|
947
966
|
|
|
967
|
+
const effectiveRecipe = applyWorkOSRecipeProfile(recipe, effectiveOptions);
|
|
968
|
+
|
|
948
969
|
const pm =
|
|
949
970
|
options.pmAdapter ??
|
|
950
971
|
detectAndCreatePackageManagerAdapter(options.workspaceRoot);
|
|
@@ -955,7 +976,7 @@ export async function forgeAdd(
|
|
|
955
976
|
|
|
956
977
|
try {
|
|
957
978
|
const dryRun = await pm.dryRunAddWithPath(
|
|
958
|
-
|
|
979
|
+
effectiveRecipe.packages.map((pkg) => pkg.packageName).join(" "),
|
|
959
980
|
{
|
|
960
981
|
cwd: options.workspaceRoot,
|
|
961
982
|
ignoreScripts: !options.allowScripts,
|
|
@@ -966,7 +987,7 @@ export async function forgeAdd(
|
|
|
966
987
|
const fallback = dryRunRecipeFallbackMessage(normalized);
|
|
967
988
|
const { emitPlan, warnings, errors } = await buildAddPlan(
|
|
968
989
|
normalized,
|
|
969
|
-
|
|
990
|
+
effectiveRecipe,
|
|
970
991
|
ctx,
|
|
971
992
|
installRoot,
|
|
972
993
|
effectiveOptions,
|
|
@@ -982,7 +1003,7 @@ export async function forgeAdd(
|
|
|
982
1003
|
return finalizeAddResult({
|
|
983
1004
|
alias: normalized,
|
|
984
1005
|
mode: "integration",
|
|
985
|
-
...recipeResultMetadata(
|
|
1006
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
986
1007
|
changed: [...emitPlan.files.map((file) => file.path), "forge.lock"],
|
|
987
1008
|
unchanged: [],
|
|
988
1009
|
warnings,
|
|
@@ -994,7 +1015,7 @@ export async function forgeAdd(
|
|
|
994
1015
|
|
|
995
1016
|
const { emitPlan, warnings, errors } = await buildAddPlan(
|
|
996
1017
|
normalized,
|
|
997
|
-
|
|
1018
|
+
effectiveRecipe,
|
|
998
1019
|
ctx,
|
|
999
1020
|
installRoot,
|
|
1000
1021
|
effectiveOptions,
|
|
@@ -1003,7 +1024,7 @@ export async function forgeAdd(
|
|
|
1003
1024
|
return finalizeAddResult({
|
|
1004
1025
|
alias: normalized,
|
|
1005
1026
|
mode: "integration",
|
|
1006
|
-
...recipeResultMetadata(
|
|
1027
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
1007
1028
|
changed: [...emitPlan.files.map((file) => file.path), "forge.lock"],
|
|
1008
1029
|
unchanged: [],
|
|
1009
1030
|
warnings,
|
|
@@ -1023,7 +1044,7 @@ export async function forgeAdd(
|
|
|
1023
1044
|
frontendWorkspace ? [packageJsonRelativeFor(frontendWorkspace)] : [],
|
|
1024
1045
|
);
|
|
1025
1046
|
const preinstalledWarnings: Diagnostic[] = [];
|
|
1026
|
-
for (const pkg of
|
|
1047
|
+
for (const pkg of effectiveRecipe.packages) {
|
|
1027
1048
|
const rootPackageJson = join(options.workspaceRoot, "package.json");
|
|
1028
1049
|
await addPackageOrContinueIfInstalled({
|
|
1029
1050
|
pm,
|
|
@@ -1055,7 +1076,7 @@ export async function forgeAdd(
|
|
|
1055
1076
|
const ctx = discover({ workspaceRoot: options.workspaceRoot });
|
|
1056
1077
|
const { emitPlan, warnings, errors: analyzeErrors } = await buildAddPlan(
|
|
1057
1078
|
normalized,
|
|
1058
|
-
|
|
1079
|
+
effectiveRecipe,
|
|
1059
1080
|
ctx,
|
|
1060
1081
|
options.workspaceRoot,
|
|
1061
1082
|
options,
|
|
@@ -1066,7 +1087,7 @@ export async function forgeAdd(
|
|
|
1066
1087
|
return finalizeAddResult({
|
|
1067
1088
|
alias: normalized,
|
|
1068
1089
|
mode: "integration",
|
|
1069
|
-
...recipeResultMetadata(
|
|
1090
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
1070
1091
|
changed: [],
|
|
1071
1092
|
unchanged: [],
|
|
1072
1093
|
warnings,
|
|
@@ -1093,7 +1114,7 @@ export async function forgeAdd(
|
|
|
1093
1114
|
return finalizeAddResult({
|
|
1094
1115
|
alias: normalized,
|
|
1095
1116
|
mode: "integration",
|
|
1096
|
-
...recipeResultMetadata(
|
|
1117
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
1097
1118
|
changed: [],
|
|
1098
1119
|
unchanged: [],
|
|
1099
1120
|
warnings: warningsCombined,
|
|
@@ -1112,7 +1133,7 @@ export async function forgeAdd(
|
|
|
1112
1133
|
return finalizeAddResult({
|
|
1113
1134
|
alias: normalized,
|
|
1114
1135
|
mode: "integration",
|
|
1115
|
-
...recipeResultMetadata(
|
|
1136
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
1116
1137
|
changed: [],
|
|
1117
1138
|
unchanged: [],
|
|
1118
1139
|
warnings: warningsCombined,
|
|
@@ -1148,7 +1169,7 @@ export async function forgeAdd(
|
|
|
1148
1169
|
return finalizeAddResult({
|
|
1149
1170
|
alias: normalized,
|
|
1150
1171
|
mode: "integration",
|
|
1151
|
-
...recipeResultMetadata(
|
|
1172
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
1152
1173
|
changed: [
|
|
1153
1174
|
...changedPackageManagerFiles(options.workspaceRoot, packageManagerBefore),
|
|
1154
1175
|
...emitResult.changed,
|
|
@@ -1178,7 +1199,7 @@ export async function forgeAdd(
|
|
|
1178
1199
|
return finalizeAddResult({
|
|
1179
1200
|
alias: normalized,
|
|
1180
1201
|
mode: "integration",
|
|
1181
|
-
...recipeResultMetadata(
|
|
1202
|
+
...recipeResultMetadata(effectiveRecipe),
|
|
1182
1203
|
changed: [],
|
|
1183
1204
|
unchanged: [],
|
|
1184
1205
|
warnings: [],
|
|
@@ -125,6 +125,13 @@ function rolePermissions(permissions: string[]): Record<string, string[]> {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function workosFgaEnabled(input: IntegrationTemplateInput): boolean {
|
|
129
|
+
return Boolean(
|
|
130
|
+
input.recipe.integrations?.includes("workos/fga.ts") ||
|
|
131
|
+
input.recipe.integrations?.includes("workos/resource-map.ts"),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
128
135
|
export function renderWorkosServerAdapter(_input: IntegrationTemplateInput): string {
|
|
129
136
|
return [
|
|
130
137
|
"/** Forge generated WorkOS server adapter — action/workflow/endpoint/server contexts only. */",
|
|
@@ -1059,7 +1066,18 @@ export function renderWorkosSeed(_input: IntegrationTemplateInput): string {
|
|
|
1059
1066
|
export function renderWorkosSeedYaml(input: IntegrationTemplateInput): string {
|
|
1060
1067
|
const permissions = permissionsFromApp(input);
|
|
1061
1068
|
const roles = rolePermissions(permissions);
|
|
1062
|
-
const resourceTypes = resourceTypesFromApp(input);
|
|
1069
|
+
const resourceTypes = workosFgaEnabled(input) ? resourceTypesFromApp(input) : [];
|
|
1070
|
+
const resourceTypeSection = resourceTypes.length > 0
|
|
1071
|
+
? [
|
|
1072
|
+
"",
|
|
1073
|
+
"resource_types:",
|
|
1074
|
+
...resourceTypes.flatMap((resource) => [
|
|
1075
|
+
` - slug: '${resource.slug}'`,
|
|
1076
|
+
` name: '${resource.name}'`,
|
|
1077
|
+
...(resource.parent ? [` parent: '${resource.parent}'`] : []),
|
|
1078
|
+
]),
|
|
1079
|
+
]
|
|
1080
|
+
: [];
|
|
1063
1081
|
return [
|
|
1064
1082
|
"# Forge generated WorkOS seed file.",
|
|
1065
1083
|
"# Review names, domains, redirect URIs, and origins before applying to a real WorkOS environment.",
|
|
@@ -1068,13 +1086,7 @@ export function renderWorkosSeedYaml(input: IntegrationTemplateInput): string {
|
|
|
1068
1086
|
` - name: '${toTitle(permission)}'`,
|
|
1069
1087
|
` slug: '${permission}'`,
|
|
1070
1088
|
]),
|
|
1071
|
-
|
|
1072
|
-
"resource_types:",
|
|
1073
|
-
...resourceTypes.flatMap((resource) => [
|
|
1074
|
-
` - slug: '${resource.slug}'`,
|
|
1075
|
-
` name: '${resource.name}'`,
|
|
1076
|
-
...(resource.parent ? [` parent: '${resource.parent}'`] : []),
|
|
1077
|
-
]),
|
|
1089
|
+
...resourceTypeSection,
|
|
1078
1090
|
"",
|
|
1079
1091
|
"roles:",
|
|
1080
1092
|
...Object.entries(roles).flatMap(([role, rolePermissions]) => [
|
|
@@ -1210,6 +1222,34 @@ export function renderWorkosTestkit(input: IntegrationTemplateInput): string {
|
|
|
1210
1222
|
}
|
|
1211
1223
|
|
|
1212
1224
|
export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
1225
|
+
const fgaEnabled = workosFgaEnabled(input);
|
|
1226
|
+
const fgaCommandLines = fgaEnabled
|
|
1227
|
+
? [
|
|
1228
|
+
"forge workos fga plan --file workos-seed.yml --write --json",
|
|
1229
|
+
"forge workos fga sync --file workos-seed.yml --json",
|
|
1230
|
+
"forge workos fga prove --file workos-seed.yml --json",
|
|
1231
|
+
]
|
|
1232
|
+
: [
|
|
1233
|
+
"# Optional when resource-level WorkOS Authorization is required:",
|
|
1234
|
+
"forge add auth workos --with-fga",
|
|
1235
|
+
];
|
|
1236
|
+
const fgaSection = fgaEnabled
|
|
1237
|
+
? [
|
|
1238
|
+
"## FGA strategy",
|
|
1239
|
+
"",
|
|
1240
|
+
"Use JWT permissions for organization-wide checks. Use the WorkOS Authorization API for resource-level checks such as project, workspace, or task access. Keep those API calls in actions, workflows, endpoints, or server code; commands and queries should consume validated auth/policy context rather than importing the WorkOS SDK.",
|
|
1241
|
+
"",
|
|
1242
|
+
"Use `syncWorkOSResourceGraph(...)` after app resources are created or renamed to mirror the Forge resource graph into WorkOS FGA resources. Use `canWorkOS(...)` with `ForgeWorkOSFgaDecisionCache` for resource-level checks; it calls WorkOS `authorization.check` with `organizationMembershipId`, `permissionSlug`, `resourceTypeSlug`, and `resourceExternalId`, emits optional telemetry, and denies by default on provider errors unless `fallback: \"throw\"` is set.",
|
|
1243
|
+
"",
|
|
1244
|
+
"Use `assertWorkOSResourceTenant(...)` before resource-level FGA checks whenever the resource came from user input or an external id. This keeps Acme resources from being checked under Globex membership context.",
|
|
1245
|
+
]
|
|
1246
|
+
: [
|
|
1247
|
+
"## Authorization strategy",
|
|
1248
|
+
"",
|
|
1249
|
+
"The default WorkOS recipe uses AuthKit, organization claims, roles, permissions, Forge policies, and tenant isolation. That is enough for most SaaS apps.",
|
|
1250
|
+
"",
|
|
1251
|
+
"FGA is optional. Add it only when your app needs resource-level authorization beyond organization roles and permission claims, for example per-project, per-document, or inherited resource access.",
|
|
1252
|
+
];
|
|
1213
1253
|
return [
|
|
1214
1254
|
"# WorkOS integration",
|
|
1215
1255
|
"",
|
|
@@ -1222,7 +1262,8 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
|
1222
1262
|
"- Installs `@workos-inc/node` for server/action/workflow/endpoint code.",
|
|
1223
1263
|
"- Installs `@workos-inc/authkit-react` and generates a Vite React bridge when a `web/package.json` workspace exists.",
|
|
1224
1264
|
"- Registers WorkOS secret names without storing values.",
|
|
1225
|
-
"- Generates an AuthKit/
|
|
1265
|
+
"- Generates an AuthKit/RBAC checklist and a `workos-seed.yml` for roles, permissions, organizations, redirect URIs, and CORS origins.",
|
|
1266
|
+
...(fgaEnabled ? ["- Generates optional WorkOS FGA resource graph helpers and resource type seed metadata."] : []),
|
|
1226
1267
|
"- Generates framework-agnostic AuthKit route/session helpers for `/login`, `/callback`, `/logout`, and `/session`.",
|
|
1227
1268
|
"- Generates a framework-agnostic `Request -> Response` webhook handler for `POST /webhooks/workos`.",
|
|
1228
1269
|
"- Marks WorkOS SDK imports as forbidden from commands, queries, liveQueries, client, shared, and edge code.",
|
|
@@ -1238,6 +1279,7 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
|
1238
1279
|
"forge workos seed --file workos-seed.yml --json",
|
|
1239
1280
|
"forge workos prove --file workos-seed.yml --json",
|
|
1240
1281
|
"forge workos setup --real --file workos-seed.yml --json",
|
|
1282
|
+
...fgaCommandLines,
|
|
1241
1283
|
"forge auth check --json",
|
|
1242
1284
|
"forge auth prove --json",
|
|
1243
1285
|
"forge check --json",
|
|
@@ -1270,13 +1312,7 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
|
1270
1312
|
"",
|
|
1271
1313
|
"Mount `handleWorkOSAuthRequest(request, { workos, config, resolveOrganization })` for `/login`, `/callback`, `/logout`, and `/session`. The callback exchanges the AuthKit authorization code, signs an HttpOnly session cookie, and exposes Forge-normalized claims through `/session`.",
|
|
1272
1314
|
"",
|
|
1273
|
-
|
|
1274
|
-
"",
|
|
1275
|
-
"Use JWT permissions for organization-wide checks. Use the WorkOS Authorization API for resource-level checks such as project, workspace, or task access. Keep those API calls in actions, workflows, endpoints, or server code; commands and queries should consume validated auth/policy context rather than importing the WorkOS SDK.",
|
|
1276
|
-
"",
|
|
1277
|
-
"Use `syncWorkOSResourceGraph(...)` after app resources are created or renamed to mirror the Forge resource graph into WorkOS FGA resources. Use `canWorkOS(...)` with `ForgeWorkOSFgaDecisionCache` for resource-level checks; it calls WorkOS `authorization.check` with `organizationMembershipId`, `permissionSlug`, `resourceTypeSlug`, and `resourceExternalId`, emits optional telemetry, and denies by default on provider errors unless `fallback: \"throw\"` is set.",
|
|
1278
|
-
"",
|
|
1279
|
-
"Use `assertWorkOSResourceTenant(...)` before resource-level FGA checks whenever the resource came from user input or an external id. This keeps Acme resources from being checked under Globex membership context.",
|
|
1315
|
+
...fgaSection,
|
|
1280
1316
|
"",
|
|
1281
1317
|
"## Webhook route",
|
|
1282
1318
|
"",
|
|
@@ -63,6 +63,7 @@ export interface AddOptions extends CliCommonOptions {
|
|
|
63
63
|
runtimeInspect: boolean;
|
|
64
64
|
sandboxBackend: SandboxBackend;
|
|
65
65
|
allowScripts: boolean;
|
|
66
|
+
withFga?: boolean;
|
|
66
67
|
mode?: "auto" | "integration" | "package";
|
|
67
68
|
installWorkspace?: string;
|
|
68
69
|
packageTarget?: "frontend" | "backend";
|
package/src/forge/version.ts
CHANGED