forgeos 0.1.0-alpha.39 → 0.1.0-alpha.40
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 +25 -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/authmd.ts +62 -0
- package/src/forge/cli/dev.ts +29 -1
- package/src/forge/cli/field-test.ts +1 -0
- package/src/forge/cli/main.ts +1 -0
- package/src/forge/cli/parse.ts +4 -2
- package/src/forge/cli/workos.ts +361 -4
- package/src/forge/compiler/integration/add.ts +84 -3
- package/src/forge/compiler/integration/plan.ts +8 -1
- package/src/forge/compiler/integration/templates/render.ts +1 -0
- package/src/forge/compiler/integration/templates/workos.ts +94 -4
- package/src/forge/compiler/recipes/definitions.ts +1 -1
- package/src/forge/dev/server.ts +16 -0
- package/src/forge/ui/index.ts +35 -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.40 input=34767fc77e63b67b94ae661c501b2aa362dd8234ba16c12cf84a55da5e94de28 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
|
|
2
2
|
# AGENTS.md
|
|
3
3
|
|
|
4
4
|
<!-- forge-generated:start -->
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# forgeos
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.40
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Complete the WorkOS/AuthKit production-like app setup loop.
|
|
8
|
+
|
|
9
|
+
- Add `forge workos setup --real --file workos-seed.yml --json` as the
|
|
10
|
+
explicit no-dashboard apply path for WorkOS hosted redirect URI, CORS,
|
|
11
|
+
homepage, webhook, and seed configuration.
|
|
12
|
+
- Make `forge workos doctor` validate production OIDC/JWT readiness,
|
|
13
|
+
browser AuthKit environment variables, the frontend AuthKit package, and
|
|
14
|
+
actual AuthKit provider mounting in the web app shell.
|
|
15
|
+
- Generate a Vite React WorkOS bridge that wraps `AuthKitProvider`, forwards
|
|
16
|
+
`getAccessToken()` into `ForgeProvider`, preserves `forgeUrl`, and handles
|
|
17
|
+
the `/login` sign-in endpoint flow.
|
|
18
|
+
- Teach `forge add auth workos` to install `@workos-inc/authkit-react` in a
|
|
19
|
+
detected `web/` workspace and automatically rewrite the default Forge Vite
|
|
20
|
+
root from local `devAuth` to `ForgeWorkOSAuthProvider`.
|
|
21
|
+
- Keep custom web roots safe by leaving them unchanged and emitting actionable
|
|
22
|
+
doctor/UI-audit guidance when AuthKit still needs to be mounted manually.
|
|
23
|
+
- Expose production auth readiness in `/health` and make `authmd check` fail
|
|
24
|
+
when OIDC/JWT mode is enabled without issuer/JWKS configuration.
|
|
25
|
+
- Improve `forge dev` lifecycle diagnostics and port-busy recovery hints for
|
|
26
|
+
agent-run app previews.
|
|
27
|
+
|
|
3
28
|
## 0.1.0-alpha.39
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
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.40","releaseId":"forgeos@0.1.0-alpha.40+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.40 input=34767fc77e63b67b94ae661c501b2aa362dd8234ba16c12cf84a55da5e94de28 content=140291417df07bee6f2a7973ba1655fa53741dcb94b0447fc7e46d2a7a8f5d2b
|
|
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.40",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.40+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
package/src/forge/cli/authmd.ts
CHANGED
|
@@ -167,6 +167,59 @@ function metadataPathFor(markdownPath: string): string {
|
|
|
167
167
|
: `${dirname(markdownPath)}/.well-known/oauth-protected-resource`;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
function parseEnvText(text: string): Record<string, string> {
|
|
171
|
+
const values: Record<string, string> = {};
|
|
172
|
+
for (const line of text.split(/\r?\n/)) {
|
|
173
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
|
174
|
+
if (!match) continue;
|
|
175
|
+
values[match[1]!] = (match[2] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
176
|
+
}
|
|
177
|
+
return values;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readRuntimeEnv(workspaceRoot: string): Record<string, string> {
|
|
181
|
+
const read = (path: string) => {
|
|
182
|
+
const absolute = join(workspaceRoot, path);
|
|
183
|
+
return existsSync(absolute) ? parseEnvText(readFileSync(absolute, "utf8")) : {};
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
...read(".env"),
|
|
187
|
+
...read(".env.local"),
|
|
188
|
+
...Object.fromEntries(
|
|
189
|
+
Object.entries(process.env)
|
|
190
|
+
.filter(([key]) => key.startsWith("FORGE_") || key.startsWith("WORKOS_") || key.startsWith("VITE_WORKOS_"))
|
|
191
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function valuePresent(env: Record<string, string>, name: string): boolean {
|
|
197
|
+
return typeof env[name] === "string" && env[name]!.trim().length > 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function authEnvDiagnostics(workspaceRoot: string): AuthMdCommandResult["diagnostics"] {
|
|
201
|
+
const env = readRuntimeEnv(workspaceRoot);
|
|
202
|
+
const mode = env.FORGE_AUTH_MODE;
|
|
203
|
+
if (mode !== "oidc" && mode !== "jwt") {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const diagnostics: AuthMdCommandResult["diagnostics"] = [];
|
|
207
|
+
if (!valuePresent(env, "FORGE_AUTH_ISSUER")) {
|
|
208
|
+
diagnostics.push({
|
|
209
|
+
code: "FORGE_AUTHMD_AUTH_ENV_MISSING",
|
|
210
|
+
message: "FORGE_AUTH_MODE uses production auth, but FORGE_AUTH_ISSUER is missing; for hosted WorkOS use https://api.workos.com",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (!valuePresent(env, "FORGE_AUTH_JWKS_URI")) {
|
|
214
|
+
const clientId = env.WORKOS_CLIENT_ID || env.VITE_WORKOS_CLIENT_ID || "<WORKOS_CLIENT_ID>";
|
|
215
|
+
diagnostics.push({
|
|
216
|
+
code: "FORGE_AUTHMD_AUTH_ENV_MISSING",
|
|
217
|
+
message: `FORGE_AUTH_MODE uses production auth, but FORGE_AUTH_JWKS_URI is missing; for hosted WorkOS use https://api.workos.com/sso/jwks/${clientId}`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return diagnostics;
|
|
221
|
+
}
|
|
222
|
+
|
|
170
223
|
function renderAuthMd(workspaceRoot: string): AuthMdCommandResult & { content: string; metadataContent: string } {
|
|
171
224
|
const contract = readGeneratedJson<AgentContractLike>(
|
|
172
225
|
workspaceRoot,
|
|
@@ -340,6 +393,15 @@ export function runAuthMdCommand(options: AuthMdCommandOptions): AuthMdCommandRe
|
|
|
340
393
|
}
|
|
341
394
|
|
|
342
395
|
if (options.subcommand === "check") {
|
|
396
|
+
const envDiagnostics = authEnvDiagnostics(options.workspaceRoot);
|
|
397
|
+
if (envDiagnostics.length > 0) {
|
|
398
|
+
return {
|
|
399
|
+
...result,
|
|
400
|
+
ok: false,
|
|
401
|
+
diagnostics: envDiagnostics,
|
|
402
|
+
exitCode: 1,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
343
405
|
return {
|
|
344
406
|
...result,
|
|
345
407
|
ok: !changed,
|
package/src/forge/cli/dev.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, openSync } from "node:fs";
|
|
2
|
+
import { closeSync, readFileSync, readlinkSync, openSync } from "node:fs";
|
|
3
3
|
import { createServer as createNetServer } from "node:net";
|
|
4
4
|
import { run } from "../compiler/orchestrator/run.ts";
|
|
5
5
|
import { basename, join } from "node:path";
|
|
@@ -213,12 +213,30 @@ function removeDevPidFile(workspaceRoot: string): void {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
function processDetails(pid: number): { pid: number; cwd?: string; command?: string } {
|
|
217
|
+
const details: { pid: number; cwd?: string; command?: string } = { pid };
|
|
218
|
+
try {
|
|
219
|
+
details.cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
220
|
+
} catch {
|
|
221
|
+
// /proc is Linux-only; process metadata is best-effort.
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
|
225
|
+
const command = raw.split("\0").filter(Boolean).join(" ");
|
|
226
|
+
if (command) details.command = command;
|
|
227
|
+
} catch {
|
|
228
|
+
// /proc is Linux-only; process metadata is best-effort.
|
|
229
|
+
}
|
|
230
|
+
return details;
|
|
231
|
+
}
|
|
232
|
+
|
|
216
233
|
function printDevLifecycleResult(input: {
|
|
217
234
|
options: DevCommandOptions;
|
|
218
235
|
ok: boolean;
|
|
219
236
|
action: "detach" | "status" | "stop";
|
|
220
237
|
running: boolean;
|
|
221
238
|
pid?: number;
|
|
239
|
+
process?: { pid: number; cwd?: string; command?: string };
|
|
222
240
|
logFile: string;
|
|
223
241
|
message: string;
|
|
224
242
|
exitCode: 0 | 1;
|
|
@@ -228,6 +246,7 @@ function printDevLifecycleResult(input: {
|
|
|
228
246
|
action: input.action,
|
|
229
247
|
running: input.running,
|
|
230
248
|
...(input.pid !== undefined ? { pid: input.pid } : {}),
|
|
249
|
+
...(input.process ? { process: input.process } : {}),
|
|
231
250
|
logFile: input.logFile,
|
|
232
251
|
message: input.message,
|
|
233
252
|
nextActions: input.running
|
|
@@ -240,6 +259,8 @@ function printDevLifecycleResult(input: {
|
|
|
240
259
|
} else {
|
|
241
260
|
process.stdout.write(`${input.message}\n`);
|
|
242
261
|
if (input.pid !== undefined) process.stdout.write(`pid: ${input.pid}\n`);
|
|
262
|
+
if (input.process?.cwd) process.stdout.write(`cwd: ${input.process.cwd}\n`);
|
|
263
|
+
if (input.process?.command) process.stdout.write(`command: ${input.process.command}\n`);
|
|
243
264
|
process.stdout.write(`log: ${input.logFile}\n`);
|
|
244
265
|
}
|
|
245
266
|
return { exitCode: input.exitCode };
|
|
@@ -278,6 +299,7 @@ function runDevStatus(options: DevCommandOptions): DevCommandResult {
|
|
|
278
299
|
action: "status",
|
|
279
300
|
running,
|
|
280
301
|
...(pid !== null ? { pid } : {}),
|
|
302
|
+
...(pid !== null && running ? { process: processDetails(pid) } : {}),
|
|
281
303
|
logFile: paths.logFile,
|
|
282
304
|
message: running ? "forge dev detached server is running" : "forge dev detached server is not running",
|
|
283
305
|
exitCode: 0,
|
|
@@ -295,6 +317,7 @@ function runDevStop(options: DevCommandOptions): DevCommandResult {
|
|
|
295
317
|
action: "stop",
|
|
296
318
|
running: false,
|
|
297
319
|
...(pid !== null ? { pid } : {}),
|
|
320
|
+
...(pid !== null ? { process: processDetails(pid) } : {}),
|
|
298
321
|
logFile: paths.logFile,
|
|
299
322
|
message: "no detached forge dev server was running",
|
|
300
323
|
exitCode: 0,
|
|
@@ -308,6 +331,7 @@ function runDevStop(options: DevCommandOptions): DevCommandResult {
|
|
|
308
331
|
action: "stop",
|
|
309
332
|
running: false,
|
|
310
333
|
pid,
|
|
334
|
+
process: processDetails(pid),
|
|
311
335
|
logFile: paths.logFile,
|
|
312
336
|
message: "stopped detached forge dev server",
|
|
313
337
|
exitCode: 0,
|
|
@@ -336,6 +360,7 @@ function runDevDetach(options: DevCommandOptions): DevCommandResult {
|
|
|
336
360
|
action: "detach",
|
|
337
361
|
running: true,
|
|
338
362
|
pid: existingPid,
|
|
363
|
+
process: processDetails(existingPid),
|
|
339
364
|
logFile: paths.logFile,
|
|
340
365
|
message: "forge dev detached server is already running",
|
|
341
366
|
exitCode: 0,
|
|
@@ -382,6 +407,7 @@ function runDevDetach(options: DevCommandOptions): DevCommandResult {
|
|
|
382
407
|
action: "detach",
|
|
383
408
|
running: true,
|
|
384
409
|
pid: child.pid,
|
|
410
|
+
process: processDetails(child.pid),
|
|
385
411
|
logFile: paths.logFile,
|
|
386
412
|
message: "started detached forge dev server",
|
|
387
413
|
exitCode: 0,
|
|
@@ -474,6 +500,8 @@ function classifyDevStartFailure(input: {
|
|
|
474
500
|
/port\s+\d+\s+.*in use/i.test(input.rawMessage);
|
|
475
501
|
if (busy) {
|
|
476
502
|
const suggestedCommands = [
|
|
503
|
+
"forge dev status --json",
|
|
504
|
+
"forge dev stop --json",
|
|
477
505
|
`forge dev --port 0${input.webPort ? ` --web-port ${input.webPort}` : ""} --json`,
|
|
478
506
|
"forge doctor windows --json",
|
|
479
507
|
];
|
|
@@ -198,6 +198,7 @@ async function createFieldTestApp(options: FieldTestCommandOptions): Promise<Fie
|
|
|
198
198
|
`${options.packageManager} run forge -- authmd check --json`,
|
|
199
199
|
`${options.packageManager} run forge -- workos doctor --json`,
|
|
200
200
|
`${options.packageManager} run forge -- workos seed --file workos-seed.yml --dry-run --json`,
|
|
201
|
+
`${options.packageManager} run forge -- workos setup --file workos-seed.yml --json`,
|
|
201
202
|
]
|
|
202
203
|
: []),
|
|
203
204
|
`${options.packageManager} run forge -- dev --once --json`,
|
package/src/forge/cli/main.ts
CHANGED
|
@@ -37,6 +37,7 @@ function formatHelp(): string {
|
|
|
37
37
|
" forge workos doctor --json Check WorkOS AuthKit/FGA files, claims, seed, webhook, and tenant guards",
|
|
38
38
|
" forge workos doctor --yes --json Run local checks, then delegate to npx --yes workos@latest doctor",
|
|
39
39
|
" forge workos seed --file workos-seed.yml --dry-run --json Validate WorkOS seed without hosted changes",
|
|
40
|
+
" forge workos setup --real --file workos-seed.yml --json Apply redirect/CORS/homepage config and seed WorkOS without dashboard",
|
|
40
41
|
" forge deploy plan --target docker --json Explain production deploy gates and commands",
|
|
41
42
|
" forge deploy check --production --json Gate auth, DB, metadata, generated artifacts, and liveQuery readiness",
|
|
42
43
|
" forge deploy render docker Write Docker production deploy files under deploy/",
|
package/src/forge/cli/parse.ts
CHANGED
|
@@ -155,6 +155,7 @@ export type ForgeCommand =
|
|
|
155
155
|
file?: string;
|
|
156
156
|
yes: boolean;
|
|
157
157
|
dryRun: boolean;
|
|
158
|
+
real?: boolean;
|
|
158
159
|
workspaceRoot: string;
|
|
159
160
|
}
|
|
160
161
|
| {
|
|
@@ -575,7 +576,7 @@ const AUTH_SUBCOMMANDS: AuthSubcommand[] = [
|
|
|
575
576
|
];
|
|
576
577
|
const BASELINE_SUBCOMMANDS: BaselineSubcommand[] = ["create", "status"];
|
|
577
578
|
const AUTHMD_SUBCOMMANDS: AuthMdSubcommand[] = ["generate", "check"];
|
|
578
|
-
const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed"];
|
|
579
|
+
const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed", "setup"];
|
|
579
580
|
const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "check", "render", "verify"];
|
|
580
581
|
const FIELD_TEST_SUBCOMMANDS: FieldTestSubcommand[] = ["create", "run", "report"];
|
|
581
582
|
const SECURITY_SUBCOMMANDS: SecuritySubcommand[] = ["prove"];
|
|
@@ -1422,7 +1423,7 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
1422
1423
|
case "workos": {
|
|
1423
1424
|
const subcommand = rest[0] as WorkOSSubcommand | undefined;
|
|
1424
1425
|
if (!subcommand || !WORKOS_SUBCOMMANDS.includes(subcommand)) {
|
|
1425
|
-
errors.push("forge workos requires subcommand: install, doctor, or
|
|
1426
|
+
errors.push("forge workos requires subcommand: install, doctor, seed, or setup");
|
|
1426
1427
|
return { command: null, workspaceRoot, errors };
|
|
1427
1428
|
}
|
|
1428
1429
|
return {
|
|
@@ -1433,6 +1434,7 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
1433
1434
|
file: parseOptionValue(argv, "--file"),
|
|
1434
1435
|
yes: parseFlag(argv, "--yes"),
|
|
1435
1436
|
dryRun: parseFlag(argv, "--dry-run"),
|
|
1437
|
+
real: parseFlag(argv, "--real"),
|
|
1436
1438
|
workspaceRoot,
|
|
1437
1439
|
},
|
|
1438
1440
|
workspaceRoot,
|
package/src/forge/cli/workos.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
|
|
6
6
|
import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
|
|
7
7
|
|
|
8
|
-
export type WorkOSSubcommand = "install" | "doctor" | "seed";
|
|
8
|
+
export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup";
|
|
9
9
|
|
|
10
10
|
export interface WorkOSCommandOptions {
|
|
11
11
|
subcommand: WorkOSSubcommand;
|
|
@@ -14,6 +14,7 @@ export interface WorkOSCommandOptions {
|
|
|
14
14
|
file?: string;
|
|
15
15
|
yes: boolean;
|
|
16
16
|
dryRun: boolean;
|
|
17
|
+
real?: boolean;
|
|
17
18
|
commandRunner?: WorkOSCommandRunner;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -25,7 +26,7 @@ export interface WorkOSCheck {
|
|
|
25
26
|
|
|
26
27
|
export interface WorkOSCommandResult {
|
|
27
28
|
ok: boolean;
|
|
28
|
-
kind: "workos-install" | "workos-doctor" | "workos-seed";
|
|
29
|
+
kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup";
|
|
29
30
|
checks: WorkOSCheck[];
|
|
30
31
|
command?: string[];
|
|
31
32
|
applied?: boolean;
|
|
@@ -114,6 +115,10 @@ export interface WorkOSSeedSummary {
|
|
|
114
115
|
resourceTypes: string[];
|
|
115
116
|
organizations: string[];
|
|
116
117
|
domains: string[];
|
|
118
|
+
redirectUris: string[];
|
|
119
|
+
corsOrigins: string[];
|
|
120
|
+
homepageUrl?: string;
|
|
121
|
+
webhookEndpoints: Array<{ url: string; events: string[] }>;
|
|
117
122
|
diagnostics: string[];
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -139,8 +144,14 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
|
|
|
139
144
|
const resourceTypes = new Set<string>();
|
|
140
145
|
const organizations = new Set<string>();
|
|
141
146
|
const domains = new Set<string>();
|
|
147
|
+
const redirectUris = new Set<string>();
|
|
148
|
+
const corsOrigins = new Set<string>();
|
|
149
|
+
let homepageUrl: string | undefined;
|
|
150
|
+
const webhookEndpoints: Array<{ url: string; events: string[] }> = [];
|
|
142
151
|
const diagnostics: string[] = [];
|
|
143
152
|
let section = "";
|
|
153
|
+
let configKey = "";
|
|
154
|
+
let currentWebhook: { url: string; events: string[] } | undefined;
|
|
144
155
|
|
|
145
156
|
if (!raw.trim()) {
|
|
146
157
|
return {
|
|
@@ -152,6 +163,9 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
|
|
|
152
163
|
resourceTypes: [],
|
|
153
164
|
organizations: [],
|
|
154
165
|
domains: [],
|
|
166
|
+
redirectUris: [],
|
|
167
|
+
corsOrigins: [],
|
|
168
|
+
webhookEndpoints: [],
|
|
155
169
|
diagnostics: [`${seedPath} is missing or empty`],
|
|
156
170
|
};
|
|
157
171
|
}
|
|
@@ -160,9 +174,47 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
|
|
|
160
174
|
const rootSection = /^([a-zA-Z_][\w-]*):\s*$/.exec(line);
|
|
161
175
|
if (rootSection) {
|
|
162
176
|
section = rootSection[1]!;
|
|
177
|
+
configKey = "";
|
|
178
|
+
currentWebhook = undefined;
|
|
163
179
|
continue;
|
|
164
180
|
}
|
|
165
181
|
|
|
182
|
+
if (section === "config") {
|
|
183
|
+
const configEntry = /^\s{2}([a-zA-Z_][\w-]*):\s*(.*)$/.exec(line);
|
|
184
|
+
if (configEntry) {
|
|
185
|
+
configKey = configEntry[1]!;
|
|
186
|
+
const rest = configEntry[2] ?? "";
|
|
187
|
+
if (configKey === "redirect_uris") {
|
|
188
|
+
for (const value of quotedValues(rest)) redirectUris.add(value);
|
|
189
|
+
} else if (configKey === "cors_origins") {
|
|
190
|
+
for (const value of quotedValues(rest)) corsOrigins.add(value);
|
|
191
|
+
} else if (configKey === "homepage_url") {
|
|
192
|
+
homepageUrl = quotedValues(rest)[0] ?? (rest.trim().replace(/^["']|["']$/g, "") || homepageUrl);
|
|
193
|
+
} else if (configKey === "webhook_endpoints") {
|
|
194
|
+
currentWebhook = undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const bulletValue = /^\s*-\s*["']?([^"'\]\s]+)["']?\s*$/.exec(line)?.[1];
|
|
198
|
+
if (bulletValue && configKey === "redirect_uris") {
|
|
199
|
+
redirectUris.add(bulletValue);
|
|
200
|
+
} else if (bulletValue && configKey === "cors_origins") {
|
|
201
|
+
corsOrigins.add(bulletValue);
|
|
202
|
+
} else if (bulletValue && configKey === "webhook_events" && currentWebhook) {
|
|
203
|
+
currentWebhook.events.push(bulletValue);
|
|
204
|
+
}
|
|
205
|
+
const webhookUrl = /^\s*-\s*url:\s*["']?([^"']+)["']?\s*$/.exec(line)?.[1] ??
|
|
206
|
+
/^\s{4}url:\s*["']?([^"']+)["']?\s*$/.exec(line)?.[1];
|
|
207
|
+
if (webhookUrl) {
|
|
208
|
+
currentWebhook = { url: webhookUrl.trim(), events: [] };
|
|
209
|
+
webhookEndpoints.push(currentWebhook);
|
|
210
|
+
configKey = "webhook_endpoints";
|
|
211
|
+
}
|
|
212
|
+
if (/^\s{4}events:\s*/.test(line) && currentWebhook) {
|
|
213
|
+
configKey = "webhook_events";
|
|
214
|
+
for (const value of quotedValues(line)) currentWebhook.events.push(value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
166
218
|
const slug = parseSlug(line);
|
|
167
219
|
if (slug) {
|
|
168
220
|
if (section === "permissions") {
|
|
@@ -205,10 +257,48 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
|
|
|
205
257
|
resourceTypes: uniqueSorted(resourceTypes),
|
|
206
258
|
organizations: uniqueSorted(organizations),
|
|
207
259
|
domains: uniqueSorted(domains),
|
|
260
|
+
redirectUris: uniqueSorted(redirectUris),
|
|
261
|
+
corsOrigins: uniqueSorted(corsOrigins),
|
|
262
|
+
...(homepageUrl ? { homepageUrl } : {}),
|
|
263
|
+
webhookEndpoints: webhookEndpoints.map((endpoint) => ({
|
|
264
|
+
url: endpoint.url,
|
|
265
|
+
events: uniqueSorted(endpoint.events),
|
|
266
|
+
})),
|
|
208
267
|
diagnostics,
|
|
209
268
|
};
|
|
210
269
|
}
|
|
211
270
|
|
|
271
|
+
function parseEnvText(text: string): Record<string, string> {
|
|
272
|
+
const values: Record<string, string> = {};
|
|
273
|
+
for (const line of text.split(/\r?\n/)) {
|
|
274
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
|
275
|
+
if (!match) continue;
|
|
276
|
+
const raw = match[2] ?? "";
|
|
277
|
+
values[match[1]!] = raw.trim().replace(/^["']|["']$/g, "");
|
|
278
|
+
}
|
|
279
|
+
return values;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readRealEnv(workspaceRoot: string): Record<string, string> {
|
|
283
|
+
return {
|
|
284
|
+
...parseEnvText(readRawText(workspaceRoot, ".env")),
|
|
285
|
+
...parseEnvText(readRawText(workspaceRoot, ".env.local")),
|
|
286
|
+
...Object.fromEntries(
|
|
287
|
+
Object.entries(process.env)
|
|
288
|
+
.filter(([key]) => key.startsWith("FORGE_") || key.startsWith("WORKOS_") || key.startsWith("VITE_WORKOS_"))
|
|
289
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
290
|
+
),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasValue(env: Record<string, string>, name: string): boolean {
|
|
295
|
+
return typeof env[name] === "string" && env[name]!.trim().length > 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function workosJwksUri(clientId: string | undefined): string {
|
|
299
|
+
return clientId ? `https://api.workos.com/sso/jwks/${clientId}` : "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
|
|
300
|
+
}
|
|
301
|
+
|
|
212
302
|
export function collectPolicyPermissions(workspaceRoot: string): string[] {
|
|
213
303
|
const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
|
|
214
304
|
policies?: Array<{ permissions?: string[] }>;
|
|
@@ -287,11 +377,26 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
287
377
|
...(packageJson?.dependencies ?? {}),
|
|
288
378
|
...(packageJson?.devDependencies ?? {}),
|
|
289
379
|
};
|
|
380
|
+
const webPackageJson = readJson(workspaceRoot, "web/package.json") as {
|
|
381
|
+
dependencies?: Record<string, string>;
|
|
382
|
+
devDependencies?: Record<string, string>;
|
|
383
|
+
} | null;
|
|
384
|
+
const webDeps = {
|
|
385
|
+
...(webPackageJson?.dependencies ?? {}),
|
|
386
|
+
...(webPackageJson?.devDependencies ?? {}),
|
|
387
|
+
};
|
|
388
|
+
const hasWeb = webPackageJson !== null;
|
|
290
389
|
const seed = parseSeedFile(workspaceRoot);
|
|
291
390
|
const activePermissions = collectPolicyPermissions(workspaceRoot);
|
|
292
391
|
const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
|
|
293
392
|
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
294
393
|
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
394
|
+
const realEnv = readRealEnv(workspaceRoot);
|
|
395
|
+
const authMode = realEnv.FORGE_AUTH_MODE;
|
|
396
|
+
const productionAuthEnabled = authMode === "oidc" || authMode === "jwt";
|
|
397
|
+
const issuerConfigured = hasValue(realEnv, "FORGE_AUTH_ISSUER");
|
|
398
|
+
const jwksConfigured = hasValue(realEnv, "FORGE_AUTH_JWKS_URI");
|
|
399
|
+
const clientId = realEnv.WORKOS_CLIENT_ID || realEnv.VITE_WORKOS_CLIENT_ID;
|
|
295
400
|
const authRoutes = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/auth-routes.ts`);
|
|
296
401
|
const fga = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`);
|
|
297
402
|
const resourceMap = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`);
|
|
@@ -299,6 +404,11 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
299
404
|
const policies = readText(workspaceRoot, "src/policies.workos.ts");
|
|
300
405
|
const session = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/session.ts`);
|
|
301
406
|
const webhook = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/webhook.ts`);
|
|
407
|
+
const generatedFrontendAuthBridge = readText(workspaceRoot, "web/src/lib/workos-auth.tsx");
|
|
408
|
+
const frontendAppShell = [
|
|
409
|
+
readText(workspaceRoot, "web/src/main.tsx"),
|
|
410
|
+
readText(workspaceRoot, "web/src/App.tsx"),
|
|
411
|
+
].join("\n");
|
|
302
412
|
|
|
303
413
|
return [
|
|
304
414
|
{
|
|
@@ -318,8 +428,53 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
318
428
|
},
|
|
319
429
|
{
|
|
320
430
|
name: "env-example",
|
|
321
|
-
ok: exists(workspaceRoot, ".env.example")
|
|
322
|
-
|
|
431
|
+
ok: exists(workspaceRoot, ".env.example") &&
|
|
432
|
+
includesAll(readRawText(workspaceRoot, ".env.example"), [
|
|
433
|
+
"FORGE_AUTH_MODE=oidc",
|
|
434
|
+
"FORGE_AUTH_ISSUER=https://api.workos.com",
|
|
435
|
+
"FORGE_AUTH_JWKS_URI=",
|
|
436
|
+
"VITE_WORKOS_CLIENT_ID=",
|
|
437
|
+
"VITE_WORKOS_REDIRECT_URI=",
|
|
438
|
+
]),
|
|
439
|
+
detail: ".env.example exists with Forge OIDC and browser AuthKit variables",
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "production-auth-readiness",
|
|
443
|
+
ok: !productionAuthEnabled || (issuerConfigured && jwksConfigured),
|
|
444
|
+
detail: !productionAuthEnabled
|
|
445
|
+
? "production OIDC/JWT env not enabled in .env/.env.local"
|
|
446
|
+
: issuerConfigured && jwksConfigured
|
|
447
|
+
? "FORGE_AUTH_MODE uses production auth and issuer/JWKS are configured"
|
|
448
|
+
: `FORGE_AUTH_MODE=${authMode} requires FORGE_AUTH_ISSUER=https://api.workos.com and FORGE_AUTH_JWKS_URI=${workosJwksUri(clientId)}`,
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "browser-authkit-env",
|
|
452
|
+
ok: !hasWeb || (hasValue(realEnv, "VITE_WORKOS_CLIENT_ID") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_CLIENT_ID=")) &&
|
|
453
|
+
(hasValue(realEnv, "VITE_WORKOS_REDIRECT_URI") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_REDIRECT_URI=")),
|
|
454
|
+
detail: hasWeb
|
|
455
|
+
? "web workspace has VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI guidance for AuthKit React"
|
|
456
|
+
: "no web workspace detected",
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: "browser-authkit-package",
|
|
460
|
+
ok: !hasWeb || "@workos-inc/authkit-react" in webDeps,
|
|
461
|
+
detail: hasWeb
|
|
462
|
+
? "@workos-inc/authkit-react is present in web/package.json"
|
|
463
|
+
: "no web workspace detected",
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "browser-authkit-bridge",
|
|
467
|
+
ok: !hasWeb || includesAll(generatedFrontendAuthBridge, ["AuthKitProvider", "getAccessToken", "ForgeProvider"]),
|
|
468
|
+
detail: hasWeb
|
|
469
|
+
? "generated web/src/lib/workos-auth.tsx bridge provides AuthKitProvider and ForgeProvider token wiring"
|
|
470
|
+
: "no web workspace detected",
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: "browser-authkit-provider",
|
|
474
|
+
ok: !hasWeb || includesAll(frontendAppShell, ["AuthKitProvider", "getAccessToken", "ForgeProvider"]),
|
|
475
|
+
detail: hasWeb
|
|
476
|
+
? "web app shell mounts AuthKitProvider and forwards getAccessToken to ForgeProvider"
|
|
477
|
+
: "no web workspace detected",
|
|
323
478
|
},
|
|
324
479
|
{
|
|
325
480
|
name: "seed-file",
|
|
@@ -351,6 +506,13 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
351
506
|
? `seed resource_types cover app graph: ${expectedResourceTypes.length > 0 ? expectedResourceTypes.join(", ") : seed.resourceTypes.join(", ")}`
|
|
352
507
|
: `seed missing resource_type(s) for app graph: ${missingSeedResources.join(", ")}`,
|
|
353
508
|
},
|
|
509
|
+
{
|
|
510
|
+
name: "seed-auth-config",
|
|
511
|
+
ok: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl),
|
|
512
|
+
detail: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl)
|
|
513
|
+
? `seed config contains ${seed.redirectUris.length} redirect URI(s), ${seed.corsOrigins.length} CORS origin(s), and homepage URL`
|
|
514
|
+
: "seed config should include redirect_uris, cors_origins, and homepage_url for no-dashboard setup",
|
|
515
|
+
},
|
|
354
516
|
{
|
|
355
517
|
name: "authkit-routes",
|
|
356
518
|
ok: includesAll(authRoutes, ["handleWorkOSAuthRequest", "/login", "/callback", "/logout", "/session"]),
|
|
@@ -432,6 +594,7 @@ function seedData(input: {
|
|
|
432
594
|
dryRun?: boolean;
|
|
433
595
|
seedFileSanitized?: boolean;
|
|
434
596
|
seedAlreadyApplied?: boolean;
|
|
597
|
+
configActions?: WorkOSConfigActionResult[];
|
|
435
598
|
nextCommand?: string;
|
|
436
599
|
}): Record<string, unknown> {
|
|
437
600
|
return {
|
|
@@ -442,10 +605,95 @@ function seedData(input: {
|
|
|
442
605
|
dryRun: input.dryRun ?? false,
|
|
443
606
|
seedFileSanitized: input.seedFileSanitized ?? false,
|
|
444
607
|
seedAlreadyApplied: input.seedAlreadyApplied ?? false,
|
|
608
|
+
configActions: input.configActions ?? [],
|
|
445
609
|
...(input.nextCommand ? { nextCommand: input.nextCommand } : {}),
|
|
446
610
|
};
|
|
447
611
|
}
|
|
448
612
|
|
|
613
|
+
export interface WorkOSConfigActionResult {
|
|
614
|
+
name: string;
|
|
615
|
+
command?: string[];
|
|
616
|
+
ok: boolean;
|
|
617
|
+
skipped: boolean;
|
|
618
|
+
reason?: string;
|
|
619
|
+
stdout?: string;
|
|
620
|
+
stderr?: string;
|
|
621
|
+
status?: number | null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function configActionsForSeed(seed: WorkOSSeedSummary): Array<{ name: string; command: string[]; skipReason?: string }> {
|
|
625
|
+
const actions: Array<{ name: string; command: string[]; skipReason?: string }> = [];
|
|
626
|
+
for (const uri of seed.redirectUris) {
|
|
627
|
+
actions.push({
|
|
628
|
+
name: "redirect-uri",
|
|
629
|
+
command: ["npx", "--yes", "workos@latest", "config", "redirect", "add", uri],
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
for (const origin of seed.corsOrigins) {
|
|
633
|
+
actions.push({
|
|
634
|
+
name: "cors-origin",
|
|
635
|
+
command: ["npx", "--yes", "workos@latest", "config", "cors", "add", origin],
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (seed.homepageUrl) {
|
|
639
|
+
actions.push({
|
|
640
|
+
name: "homepage-url",
|
|
641
|
+
command: ["npx", "--yes", "workos@latest", "config", "homepage-url", "set", seed.homepageUrl],
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
for (const endpoint of seed.webhookEndpoints) {
|
|
645
|
+
const events = endpoint.events.length > 0 ? endpoint.events.join(",") : "user.created,organization_membership.updated";
|
|
646
|
+
actions.push({
|
|
647
|
+
name: "webhook",
|
|
648
|
+
command: ["npx", "--yes", "workos@latest", "webhook", "create", "--url", endpoint.url, "--events", events],
|
|
649
|
+
skipReason: endpoint.url.startsWith("https://")
|
|
650
|
+
? undefined
|
|
651
|
+
: "WorkOS hosted webhook endpoints require HTTPS; use a tunnel or production URL for this endpoint.",
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return actions;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function runWorkOSConfigActions(
|
|
658
|
+
seed: WorkOSSeedSummary,
|
|
659
|
+
options: WorkOSCommandOptions,
|
|
660
|
+
dryRun: boolean,
|
|
661
|
+
): WorkOSConfigActionResult[] {
|
|
662
|
+
return configActionsForSeed(seed).map((action) => {
|
|
663
|
+
if (action.skipReason) {
|
|
664
|
+
return {
|
|
665
|
+
name: action.name,
|
|
666
|
+
command: action.command,
|
|
667
|
+
ok: true,
|
|
668
|
+
skipped: true,
|
|
669
|
+
reason: action.skipReason,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (dryRun) {
|
|
673
|
+
return {
|
|
674
|
+
name: action.name,
|
|
675
|
+
command: action.command,
|
|
676
|
+
ok: true,
|
|
677
|
+
skipped: true,
|
|
678
|
+
reason: "dry-run",
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const child = runExternalCommand(action.command, options);
|
|
682
|
+
return {
|
|
683
|
+
name: action.name,
|
|
684
|
+
command: action.command,
|
|
685
|
+
ok: child.status === 0 || isWorkOSSeedAlreadyApplied(child.stdout, child.stderr),
|
|
686
|
+
skipped: false,
|
|
687
|
+
stdout: child.stdout,
|
|
688
|
+
stderr: child.stderr,
|
|
689
|
+
status: child.status,
|
|
690
|
+
...(child.status !== 0 && isWorkOSSeedAlreadyApplied(child.stdout, child.stderr)
|
|
691
|
+
? { reason: "already-applied" }
|
|
692
|
+
: {}),
|
|
693
|
+
};
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
449
697
|
export function runWorkOSDoctorCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
450
698
|
const checks = collectWorkOSChecks(options.workspaceRoot);
|
|
451
699
|
const ok = checks.every((check) => check.ok);
|
|
@@ -553,6 +801,13 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
553
801
|
? `seed covers app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
|
|
554
802
|
: `seed missing resource type(s): ${missingSeedResources.join(", ")}`,
|
|
555
803
|
},
|
|
804
|
+
{
|
|
805
|
+
name: "seed-hosted-config",
|
|
806
|
+
ok: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl),
|
|
807
|
+
detail: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl)
|
|
808
|
+
? `seed includes hosted config: ${seed.redirectUris.length} redirect URI(s), ${seed.corsOrigins.length} CORS origin(s), homepage ${seed.homepageUrl}`
|
|
809
|
+
: "seed should include config.redirect_uris, config.cors_origins, and config.homepage_url",
|
|
810
|
+
},
|
|
556
811
|
];
|
|
557
812
|
const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
|
|
558
813
|
if (!checks.every((check) => check.ok)) {
|
|
@@ -567,6 +822,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
567
822
|
};
|
|
568
823
|
}
|
|
569
824
|
if (options.dryRun) {
|
|
825
|
+
const configActions = runWorkOSConfigActions(seed, options, true);
|
|
570
826
|
return {
|
|
571
827
|
ok: true,
|
|
572
828
|
kind: "workos-seed",
|
|
@@ -579,6 +835,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
579
835
|
expectedResourceTypes,
|
|
580
836
|
unusedSeedPermissions,
|
|
581
837
|
dryRun: true,
|
|
838
|
+
configActions,
|
|
582
839
|
nextCommand: `forge workos seed --file ${file} --json`,
|
|
583
840
|
}),
|
|
584
841
|
exitCode: 0,
|
|
@@ -594,6 +851,26 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
594
851
|
preparedSeed.file,
|
|
595
852
|
];
|
|
596
853
|
try {
|
|
854
|
+
const configActions = runWorkOSConfigActions(seed, options, false);
|
|
855
|
+
const configOk = configActions.every((action) => action.ok);
|
|
856
|
+
if (!configOk) {
|
|
857
|
+
return {
|
|
858
|
+
ok: false,
|
|
859
|
+
kind: "workos-seed",
|
|
860
|
+
checks,
|
|
861
|
+
command: configActions.find((action) => !action.ok)?.command ?? delegatedCommand,
|
|
862
|
+
applied: false,
|
|
863
|
+
data: seedData({
|
|
864
|
+
seed,
|
|
865
|
+
activePermissions,
|
|
866
|
+
expectedResourceTypes,
|
|
867
|
+
unusedSeedPermissions,
|
|
868
|
+
seedFileSanitized: preparedSeed.sanitized,
|
|
869
|
+
configActions,
|
|
870
|
+
}),
|
|
871
|
+
exitCode: 1,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
597
874
|
const child = runExternalCommand(delegatedCommand, options);
|
|
598
875
|
const seedAlreadyApplied =
|
|
599
876
|
child.status !== 0 && isWorkOSSeedAlreadyApplied(child.stdout, child.stderr);
|
|
@@ -610,6 +887,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
610
887
|
unusedSeedPermissions,
|
|
611
888
|
seedFileSanitized: preparedSeed.sanitized,
|
|
612
889
|
seedAlreadyApplied,
|
|
890
|
+
configActions,
|
|
613
891
|
}),
|
|
614
892
|
stdout: child.stdout,
|
|
615
893
|
stderr: child.stderr,
|
|
@@ -620,10 +898,79 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
620
898
|
}
|
|
621
899
|
}
|
|
622
900
|
|
|
901
|
+
export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
902
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
903
|
+
const checks = collectWorkOSChecks(options.workspaceRoot);
|
|
904
|
+
const localOk = checks.every((check) => check.ok);
|
|
905
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
906
|
+
const setupDryRun = options.dryRun || !options.real;
|
|
907
|
+
const configActions = runWorkOSConfigActions(seed, options, true);
|
|
908
|
+
const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
|
|
909
|
+
if (!localOk) {
|
|
910
|
+
return {
|
|
911
|
+
ok: false,
|
|
912
|
+
kind: "workos-setup",
|
|
913
|
+
checks,
|
|
914
|
+
command,
|
|
915
|
+
applied: false,
|
|
916
|
+
data: {
|
|
917
|
+
dryRun: setupDryRun,
|
|
918
|
+
real: options.real ?? false,
|
|
919
|
+
seed,
|
|
920
|
+
configActions,
|
|
921
|
+
nextCommand: "forge workos doctor --json",
|
|
922
|
+
},
|
|
923
|
+
exitCode: 1,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (setupDryRun) {
|
|
927
|
+
return {
|
|
928
|
+
ok: true,
|
|
929
|
+
kind: "workos-setup",
|
|
930
|
+
checks,
|
|
931
|
+
command,
|
|
932
|
+
applied: false,
|
|
933
|
+
data: {
|
|
934
|
+
dryRun: true,
|
|
935
|
+
real: false,
|
|
936
|
+
seed,
|
|
937
|
+
configActions,
|
|
938
|
+
nextCommand: `forge workos setup --real --file ${file} --json`,
|
|
939
|
+
},
|
|
940
|
+
exitCode: 0,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const seedResult = runWorkOSSeedCommand({
|
|
944
|
+
...options,
|
|
945
|
+
subcommand: "seed",
|
|
946
|
+
dryRun: false,
|
|
947
|
+
file,
|
|
948
|
+
});
|
|
949
|
+
return {
|
|
950
|
+
ok: seedResult.ok,
|
|
951
|
+
kind: "workos-setup",
|
|
952
|
+
checks: seedResult.checks,
|
|
953
|
+
command: seedResult.command,
|
|
954
|
+
applied: seedResult.ok,
|
|
955
|
+
data: {
|
|
956
|
+
real: true,
|
|
957
|
+
seed,
|
|
958
|
+
seedResult: seedResult.data,
|
|
959
|
+
nextCommand: "forge workos doctor --json",
|
|
960
|
+
},
|
|
961
|
+
stdout: seedResult.stdout,
|
|
962
|
+
stderr: seedResult.stderr,
|
|
963
|
+
exitCode: seedResult.exitCode,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
623
967
|
export function runWorkOSCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
624
968
|
if (options.subcommand === "install") {
|
|
625
969
|
return runWorkOSInstallCommand(options);
|
|
626
970
|
}
|
|
971
|
+
if (options.subcommand === "setup") {
|
|
972
|
+
return runWorkOSSetupCommand(options);
|
|
973
|
+
}
|
|
627
974
|
return options.subcommand === "doctor"
|
|
628
975
|
? runWorkOSDoctorCommand(options)
|
|
629
976
|
: runWorkOSSeedCommand(options);
|
|
@@ -659,5 +1006,15 @@ export function formatWorkOSHuman(result: WorkOSCommandResult): string {
|
|
|
659
1006
|
lines.push("seed not applied; inspect stdout/stderr from the WorkOS CLI command");
|
|
660
1007
|
}
|
|
661
1008
|
}
|
|
1009
|
+
if (result.kind === "workos-setup" && !result.applied) {
|
|
1010
|
+
const data = result.data && typeof result.data === "object"
|
|
1011
|
+
? result.data as { dryRun?: boolean; nextCommand?: string }
|
|
1012
|
+
: {};
|
|
1013
|
+
if (data.dryRun) {
|
|
1014
|
+
lines.push(`setup dry-run only; run ${data.nextCommand ?? "forge workos setup --real --file workos-seed.yml --json"} to apply hosted config`);
|
|
1015
|
+
} else {
|
|
1016
|
+
lines.push("setup not applied; inspect WorkOS checks and seed/config action output");
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
662
1019
|
return `${lines.join("\n")}\n`;
|
|
663
1020
|
}
|
|
@@ -354,8 +354,9 @@ function fileHashOrNull(path: string): string | null {
|
|
|
354
354
|
function snapshotPackageManagerFiles(
|
|
355
355
|
workspaceRoot: string,
|
|
356
356
|
pm: PackageManagerAdapter,
|
|
357
|
+
extraPaths: string[] = [],
|
|
357
358
|
): Map<string, string | null> {
|
|
358
|
-
const paths = ["package.json", pm.lockfile];
|
|
359
|
+
const paths = [...new Set(["package.json", pm.lockfile, ...extraPaths])];
|
|
359
360
|
return new Map(
|
|
360
361
|
paths.map((path) => [
|
|
361
362
|
path,
|
|
@@ -377,6 +378,70 @@ function changedPackageManagerFiles(
|
|
|
377
378
|
return changed.sort();
|
|
378
379
|
}
|
|
379
380
|
|
|
381
|
+
function workosFrontendWorkspace(workspaceRoot: string): string | undefined {
|
|
382
|
+
return findFrontendWorkspace(workspaceRoot);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string): {
|
|
386
|
+
changed: string[];
|
|
387
|
+
warnings: Diagnostic[];
|
|
388
|
+
} {
|
|
389
|
+
const mainRel = `${frontendWorkspace}/src/main.tsx`;
|
|
390
|
+
const mainPath = join(workspaceRoot, mainRel);
|
|
391
|
+
const current = nodeFileSystem.readText(mainPath);
|
|
392
|
+
if (current === null) {
|
|
393
|
+
return {
|
|
394
|
+
changed: [],
|
|
395
|
+
warnings: [
|
|
396
|
+
createDiagnostic({
|
|
397
|
+
severity: "warning",
|
|
398
|
+
code: "FORGE_WORKOS_AUTHKIT_ROOT_NOT_FOUND",
|
|
399
|
+
message: `generated WorkOS AuthKit bridge, but ${mainRel} was not found`,
|
|
400
|
+
fixHint: "Wrap the web app root with ForgeWorkOSAuthProvider from ./lib/workos-auth.",
|
|
401
|
+
suggestedCommands: ["forge workos doctor --json", "forge inspect ui --json"],
|
|
402
|
+
}),
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (current.includes("ForgeWorkOSAuthProvider")) {
|
|
407
|
+
return { changed: [], warnings: [] };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const canRewriteDefaultViteRoot =
|
|
411
|
+
current.includes('import { ForgeProvider, forgeUrl } from "./lib/forge";') &&
|
|
412
|
+
current.includes("<ForgeProvider url={forgeUrl} devAuth>") &&
|
|
413
|
+
current.includes("</ForgeProvider>");
|
|
414
|
+
|
|
415
|
+
if (!canRewriteDefaultViteRoot) {
|
|
416
|
+
return {
|
|
417
|
+
changed: [],
|
|
418
|
+
warnings: [
|
|
419
|
+
createDiagnostic({
|
|
420
|
+
severity: "warning",
|
|
421
|
+
code: "FORGE_WORKOS_AUTHKIT_ROOT_CUSTOM",
|
|
422
|
+
message: `generated WorkOS AuthKit bridge, but ${mainRel} is custom and was not rewritten automatically`,
|
|
423
|
+
fixHint: "Import ForgeWorkOSAuthProvider from ./lib/workos-auth and wrap the app root with it so AuthKit tokens are passed to ForgeProvider.",
|
|
424
|
+
suggestedCommands: ["forge workos doctor --json", "forge inspect ui --json"],
|
|
425
|
+
}),
|
|
426
|
+
],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const next = current
|
|
431
|
+
.replace(
|
|
432
|
+
'import { ForgeProvider, forgeUrl } from "./lib/forge";',
|
|
433
|
+
'import { ForgeWorkOSAuthProvider } from "./lib/workos-auth";',
|
|
434
|
+
)
|
|
435
|
+
.replace("<ForgeProvider url={forgeUrl} devAuth>", "<ForgeWorkOSAuthProvider>")
|
|
436
|
+
.replace("</ForgeProvider>", "</ForgeWorkOSAuthProvider>");
|
|
437
|
+
|
|
438
|
+
if (next !== current) {
|
|
439
|
+
nodeFileSystem.writeText(mainPath, next);
|
|
440
|
+
return { changed: [mainRel], warnings: [] };
|
|
441
|
+
}
|
|
442
|
+
return { changed: [], warnings: [] };
|
|
443
|
+
}
|
|
444
|
+
|
|
380
445
|
async function analyzeRecipePackages(
|
|
381
446
|
recipe: NonNullable<ReturnType<typeof resolveRecipe>>,
|
|
382
447
|
ctx: ReturnType<typeof discover>,
|
|
@@ -668,13 +733,24 @@ export async function forgeAdd(
|
|
|
668
733
|
const snapshot = snapshotVersionControlled(options.workspaceRoot);
|
|
669
734
|
|
|
670
735
|
try {
|
|
671
|
-
const
|
|
736
|
+
const frontendWorkspace = normalized === "workos" ? workosFrontendWorkspace(options.workspaceRoot) : undefined;
|
|
737
|
+
const packageManagerBefore = snapshotPackageManagerFiles(
|
|
738
|
+
options.workspaceRoot,
|
|
739
|
+
pm,
|
|
740
|
+
frontendWorkspace ? [packageJsonRelativeFor(frontendWorkspace)] : [],
|
|
741
|
+
);
|
|
672
742
|
for (const pkg of recipe.packages) {
|
|
673
743
|
await pm.add(pkg.packageName, {
|
|
674
744
|
cwd: options.workspaceRoot,
|
|
675
745
|
ignoreScripts: !options.allowScripts,
|
|
676
746
|
});
|
|
677
747
|
}
|
|
748
|
+
if (frontendWorkspace) {
|
|
749
|
+
await pm.add("@workos-inc/authkit-react", {
|
|
750
|
+
cwd: join(options.workspaceRoot, frontendWorkspace),
|
|
751
|
+
ignoreScripts: !options.allowScripts,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
678
754
|
|
|
679
755
|
const ctx = discover({ workspaceRoot: options.workspaceRoot });
|
|
680
756
|
const { emitPlan, warnings, errors: analyzeErrors } = await buildAddPlan(
|
|
@@ -705,7 +781,11 @@ export async function forgeAdd(
|
|
|
705
781
|
mode: "write",
|
|
706
782
|
});
|
|
707
783
|
|
|
708
|
-
const
|
|
784
|
+
const workosWeb = normalized === "workos" && frontendWorkspace
|
|
785
|
+
? connectWorkOSReactRoot(options.workspaceRoot, frontendWorkspace)
|
|
786
|
+
: { changed: [] as string[], warnings: [] as Diagnostic[] };
|
|
787
|
+
|
|
788
|
+
const warningsCombined = [...warnings, ...emitResult.warnings, ...workosWeb.warnings];
|
|
709
789
|
const errors = [...analyzeErrors, ...emitResult.errors];
|
|
710
790
|
|
|
711
791
|
if (errors.length > 0) {
|
|
@@ -772,6 +852,7 @@ export async function forgeAdd(
|
|
|
772
852
|
changed: [
|
|
773
853
|
...changedPackageManagerFiles(options.workspaceRoot, packageManagerBefore),
|
|
774
854
|
...emitResult.changed,
|
|
855
|
+
...workosWeb.changed,
|
|
775
856
|
],
|
|
776
857
|
unchanged: emitResult.unchanged,
|
|
777
858
|
warnings: warningsCombined,
|
|
@@ -97,6 +97,7 @@ function shouldEmitAdapter(
|
|
|
97
97
|
function buildGeneratedPaths(
|
|
98
98
|
recipe: IntegrationRecipe,
|
|
99
99
|
compatible: RuntimeContext[],
|
|
100
|
+
workspaceRoot: string,
|
|
100
101
|
): string[] {
|
|
101
102
|
const paths: string[] = [];
|
|
102
103
|
|
|
@@ -120,6 +121,9 @@ function buildGeneratedPaths(
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
for (const rootFile of recipe.rootFiles ?? []) {
|
|
124
|
+
if (rootFile.startsWith("web/") && !nodeFileSystem.exists(join(workspaceRoot, "web", "package.json"))) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
123
127
|
paths.push(rootFile);
|
|
124
128
|
}
|
|
125
129
|
|
|
@@ -243,6 +247,9 @@ export function buildIntegrationEmitPlan(input: IntegrationPlanInput): EmitPlan
|
|
|
243
247
|
}
|
|
244
248
|
|
|
245
249
|
for (const rootFile of recipe.rootFiles ?? []) {
|
|
250
|
+
if (rootFile.startsWith("web/") && !nodeFileSystem.exists(join(ctx.workspaceRoot, "web", "package.json"))) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
246
253
|
const content = renderRootFile(rootFile, templateCtx);
|
|
247
254
|
files.push({
|
|
248
255
|
path: rootFile,
|
|
@@ -282,7 +289,7 @@ export function buildIntegrationEmitPlan(input: IntegrationPlanInput): EmitPlan
|
|
|
282
289
|
const matrix = buildRuntimeMatrix(allClassified);
|
|
283
290
|
files.push(...buildGuardArtifactEmitFiles(matrix, appGraph.moduleGraph));
|
|
284
291
|
|
|
285
|
-
const generatedFiles = buildGeneratedPaths(recipe, compatible);
|
|
292
|
+
const generatedFiles = buildGeneratedPaths(recipe, compatible, ctx.workspaceRoot);
|
|
286
293
|
const lockEntry = buildLockEntry(recipe, classified, generatedFiles);
|
|
287
294
|
const lock = mergeLockPackages(input.existingLock, lockEntry, ctx);
|
|
288
295
|
|
|
@@ -87,6 +87,7 @@ const ROOT_FILE_RENDERERS: Record<string, Record<string, TemplateRenderer>> = {
|
|
|
87
87
|
workos: {
|
|
88
88
|
".env.example": workos.renderWorkosEnvExample,
|
|
89
89
|
"src/policies.workos.ts": workos.renderWorkosPolicies,
|
|
90
|
+
"web/src/lib/workos-auth.tsx": workos.renderWorkosReactBridge,
|
|
90
91
|
"workos-seed.yml": workos.renderWorkosSeedYaml,
|
|
91
92
|
},
|
|
92
93
|
};
|
|
@@ -186,6 +186,7 @@ export function renderWorkosAuthkit(_input: IntegrationTemplateInput): string {
|
|
|
186
186
|
' "forge workos doctor --yes --json",',
|
|
187
187
|
' "forge workos seed --file workos-seed.yml --dry-run --json",',
|
|
188
188
|
' "forge workos seed --file workos-seed.yml --json",',
|
|
189
|
+
' "forge workos setup --real --file workos-seed.yml --json",',
|
|
189
190
|
' "forge auth check --json",',
|
|
190
191
|
' "forge auth prove --json",',
|
|
191
192
|
"] as const;",
|
|
@@ -238,8 +239,9 @@ export function renderWorkosEnvExample(_input: IntegrationTemplateInput): string
|
|
|
238
239
|
"# Forge WorkOS/AuthKit production auth example.",
|
|
239
240
|
"# Secret values are intentionally blank. Keep real values in .env.local, your host secrets, or WorkOS/Vercel integration env.",
|
|
240
241
|
"FORGE_AUTH_MODE=oidc",
|
|
241
|
-
"FORGE_AUTH_ISSUER=",
|
|
242
|
+
"FORGE_AUTH_ISSUER=https://api.workos.com",
|
|
242
243
|
"FORGE_AUTH_AUDIENCE=",
|
|
244
|
+
"# Set to https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID> after filling WORKOS_CLIENT_ID.",
|
|
243
245
|
"FORGE_AUTH_JWKS_URI=",
|
|
244
246
|
"FORGE_AUTH_ALGORITHMS=RS256",
|
|
245
247
|
"",
|
|
@@ -252,12 +254,90 @@ export function renderWorkosEnvExample(_input: IntegrationTemplateInput): string
|
|
|
252
254
|
"WORKOS_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/",
|
|
253
255
|
"WORKOS_WEBHOOK_SECRET=",
|
|
254
256
|
"",
|
|
257
|
+
"# Browser AuthKit settings for Vite/React. These are public client identifiers, not secrets.",
|
|
258
|
+
"VITE_WORKOS_CLIENT_ID=",
|
|
259
|
+
"VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback",
|
|
260
|
+
"",
|
|
255
261
|
"# WorkOS token claims expected by ForgeOS after forge add auth workos:",
|
|
256
262
|
"# sub, email, organization_id, role, roles, permissions, organization_membership_id",
|
|
257
263
|
"",
|
|
258
264
|
].join("\n");
|
|
259
265
|
}
|
|
260
266
|
|
|
267
|
+
export function renderWorkosReactBridge(_input: IntegrationTemplateInput): string {
|
|
268
|
+
return [
|
|
269
|
+
"/** Forge generated WorkOS/AuthKit React bridge.",
|
|
270
|
+
" * Import ForgeWorkOSAuthProvider from web/src/main.tsx, or copy the pattern into your app shell.",
|
|
271
|
+
" */",
|
|
272
|
+
'import { AuthKitProvider, useAuth as useWorkOSAuth } from "@workos-inc/authkit-react";',
|
|
273
|
+
'import { useEffect, type ReactNode } from "react";',
|
|
274
|
+
'import { ForgeProvider, forgeUrl } from "./forge";',
|
|
275
|
+
"",
|
|
276
|
+
"const clientId = import.meta.env.VITE_WORKOS_CLIENT_ID as string | undefined;",
|
|
277
|
+
"const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI as string | undefined;",
|
|
278
|
+
"",
|
|
279
|
+
"export function hasWorkOSBrowserConfig(): boolean {",
|
|
280
|
+
" return Boolean(clientId && redirectUri);",
|
|
281
|
+
"}",
|
|
282
|
+
"",
|
|
283
|
+
"export function ForgeWorkOSAuthProvider({ children }: { children: ReactNode }) {",
|
|
284
|
+
" if (!clientId || !redirectUri) {",
|
|
285
|
+
" throw new Error(\"WorkOS AuthKit requires VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI\");",
|
|
286
|
+
" }",
|
|
287
|
+
" return (",
|
|
288
|
+
" <AuthKitProvider clientId={clientId} redirectUri={redirectUri}>",
|
|
289
|
+
" <ForgeWorkOSBearerProvider>{children}</ForgeWorkOSBearerProvider>",
|
|
290
|
+
" </AuthKitProvider>",
|
|
291
|
+
" );",
|
|
292
|
+
"}",
|
|
293
|
+
"",
|
|
294
|
+
"function ForgeWorkOSBearerProvider({ children }: { children: ReactNode }) {",
|
|
295
|
+
" const auth = useWorkOSAuth();",
|
|
296
|
+
" return (",
|
|
297
|
+
" <ForgeProvider",
|
|
298
|
+
" url={forgeUrl}",
|
|
299
|
+
" auth={{",
|
|
300
|
+
" getAccessToken: async () => {",
|
|
301
|
+
" const token = await auth.getAccessToken();",
|
|
302
|
+
" return token ?? null;",
|
|
303
|
+
" },",
|
|
304
|
+
" }}",
|
|
305
|
+
" >",
|
|
306
|
+
" {children}",
|
|
307
|
+
" </ForgeProvider>",
|
|
308
|
+
" );",
|
|
309
|
+
"}",
|
|
310
|
+
"",
|
|
311
|
+
"export function WorkOSLoginPanel() {",
|
|
312
|
+
" const { isLoading, user, signIn, signUp, signOut } = useWorkOSAuth();",
|
|
313
|
+
" useEffect(() => {",
|
|
314
|
+
" if (!isLoading && !user && window.location.pathname === \"/login\") {",
|
|
315
|
+
" void signIn();",
|
|
316
|
+
" }",
|
|
317
|
+
" }, [isLoading, signIn, user]);",
|
|
318
|
+
" if (isLoading) return <p>Loading authentication...</p>;",
|
|
319
|
+
" if (!user && window.location.pathname === \"/login\") return <p>Redirecting...</p>;",
|
|
320
|
+
" if (!user) {",
|
|
321
|
+
" return (",
|
|
322
|
+
" <div>",
|
|
323
|
+
" <button type=\"button\" onClick={() => void signIn()}>Sign in</button>",
|
|
324
|
+
" <button type=\"button\" onClick={() => void signUp()}>Sign up</button>",
|
|
325
|
+
" </div>",
|
|
326
|
+
" );",
|
|
327
|
+
" }",
|
|
328
|
+
" return (",
|
|
329
|
+
" <div>",
|
|
330
|
+
" <span>{user.email}</span>",
|
|
331
|
+
" <button type=\"button\" onClick={() => signOut({ returnTo: window.location.origin })}>Sign out</button>",
|
|
332
|
+
" </div>",
|
|
333
|
+
" );",
|
|
334
|
+
"}",
|
|
335
|
+
"",
|
|
336
|
+
"export { useWorkOSAuth };",
|
|
337
|
+
"",
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
261
341
|
export function renderWorkosFga(input: IntegrationTemplateInput): string {
|
|
262
342
|
const permissions = permissionsFromApp(input);
|
|
263
343
|
const roles = rolePermissions(permissions);
|
|
@@ -941,8 +1021,14 @@ export function renderWorkosSeedYaml(input: IntegrationTemplateInput): string {
|
|
|
941
1021
|
" domains: ['globex.test']",
|
|
942
1022
|
"",
|
|
943
1023
|
"config:",
|
|
944
|
-
" redirect_uris:
|
|
945
|
-
"
|
|
1024
|
+
" redirect_uris:",
|
|
1025
|
+
" - 'http://localhost:5173'",
|
|
1026
|
+
" - 'http://localhost:5173/callback'",
|
|
1027
|
+
" - 'http://127.0.0.1:5173'",
|
|
1028
|
+
" - 'http://127.0.0.1:5173/callback'",
|
|
1029
|
+
" cors_origins:",
|
|
1030
|
+
" - 'http://localhost:5173'",
|
|
1031
|
+
" - 'http://127.0.0.1:5173'",
|
|
946
1032
|
" homepage_url: 'http://localhost:5173'",
|
|
947
1033
|
" webhook_endpoints:",
|
|
948
1034
|
" - url: 'http://localhost:3765/webhooks/workos'",
|
|
@@ -1064,6 +1150,7 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
|
1064
1150
|
"## What this recipe does",
|
|
1065
1151
|
"",
|
|
1066
1152
|
"- Installs `@workos-inc/node` for server/action/workflow/endpoint code.",
|
|
1153
|
+
"- Installs `@workos-inc/authkit-react` and generates a Vite React bridge when a `web/package.json` workspace exists.",
|
|
1067
1154
|
"- Registers WorkOS secret names without storing values.",
|
|
1068
1155
|
"- Generates an AuthKit/FGA checklist and a `workos-seed.yml` for roles, permissions, organizations, redirect URIs, and CORS origins.",
|
|
1069
1156
|
"- Generates framework-agnostic AuthKit route/session helpers for `/login`, `/callback`, `/logout`, and `/session`.",
|
|
@@ -1079,6 +1166,7 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
|
1079
1166
|
"forge workos doctor --yes --json",
|
|
1080
1167
|
"forge workos seed --file workos-seed.yml --dry-run --json",
|
|
1081
1168
|
"forge workos seed --file workos-seed.yml --json",
|
|
1169
|
+
"forge workos setup --real --file workos-seed.yml --json",
|
|
1082
1170
|
"forge auth check --json",
|
|
1083
1171
|
"forge auth prove --json",
|
|
1084
1172
|
"forge check --json",
|
|
@@ -1099,11 +1187,13 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
|
|
|
1099
1187
|
"## Production auth mapping",
|
|
1100
1188
|
"",
|
|
1101
1189
|
"- `FORGE_AUTH_MODE=oidc` or `jwt`",
|
|
1102
|
-
"- `FORGE_AUTH_ISSUER`
|
|
1190
|
+
"- `FORGE_AUTH_ISSUER=https://api.workos.com` for hosted WorkOS AuthKit.",
|
|
1191
|
+
"- `FORGE_AUTH_JWKS_URI=https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>` for hosted WorkOS AuthKit.",
|
|
1103
1192
|
"- `FORGE_AUTH_AUDIENCE` matches the API audience configured for the app.",
|
|
1104
1193
|
"- Tenant claim should map to WorkOS `org_id` or a normalized `tenant_id` claim.",
|
|
1105
1194
|
"- Role and permissions should come from WorkOS organization roles/permissions, not from dev headers.",
|
|
1106
1195
|
"- Use generated `src/policies.workos.ts` as a permission-first policy template; it uses `canPermission(...)` rather than role-only checks.",
|
|
1196
|
+
"- `dev-headers` and frontend `devAuth` are local-only simulation paths. Real WorkOS production-like tests should use AuthKit and Bearer tokens.",
|
|
1107
1197
|
"",
|
|
1108
1198
|
"## AuthKit routes",
|
|
1109
1199
|
"",
|
|
@@ -246,7 +246,7 @@ export const WORKOS_RECIPE: IntegrationRecipe = {
|
|
|
246
246
|
"workos/webhook.ts",
|
|
247
247
|
"workos/workos-seed.yml",
|
|
248
248
|
],
|
|
249
|
-
rootFiles: [".env.example", "src/policies.workos.ts", "workos-seed.yml"],
|
|
249
|
+
rootFiles: [".env.example", "src/policies.workos.ts", "web/src/lib/workos-auth.tsx", "workos-seed.yml"],
|
|
250
250
|
testkits: ["workos.mock.ts"],
|
|
251
251
|
docs: ["workos.md"],
|
|
252
252
|
};
|
package/src/forge/dev/server.ts
CHANGED
|
@@ -1318,6 +1318,16 @@ export async function startDevServer(
|
|
|
1318
1318
|
const aiRegistry = loadAiRegistry(workspaceRoot);
|
|
1319
1319
|
const aiCheck = checkAiProviders(envStore, aiRegistry, secretRegistry);
|
|
1320
1320
|
const mockAi = isMockAiEnabled({ mockAi: options.mockAi });
|
|
1321
|
+
const productionAuthMode = authConfig.mode === "oidc" || authConfig.mode === "jwt";
|
|
1322
|
+
const missingAuthConfig = [
|
|
1323
|
+
...(productionAuthMode && !authConfig.issuer ? ["FORGE_AUTH_ISSUER"] : []),
|
|
1324
|
+
...(productionAuthMode && !authConfig.jwksUri ? ["FORGE_AUTH_JWKS_URI"] : []),
|
|
1325
|
+
];
|
|
1326
|
+
const workosClientId = envStore.resolve("WORKOS_CLIENT_ID") || envStore.resolve("VITE_WORKOS_CLIENT_ID");
|
|
1327
|
+
const suggestedWorkOSJwksUri = workosClientId
|
|
1328
|
+
? `https://api.workos.com/sso/jwks/${workosClientId}`
|
|
1329
|
+
: "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
|
|
1330
|
+
const authProductionReady = !productionAuthMode || missingAuthConfig.length === 0;
|
|
1321
1331
|
|
|
1322
1332
|
return jsonResponse({
|
|
1323
1333
|
ok: true,
|
|
@@ -1346,6 +1356,12 @@ export async function startDevServer(
|
|
|
1346
1356
|
audienceConfigured: Boolean(authConfig.audience),
|
|
1347
1357
|
jwksConfigured: Boolean(authConfig.jwksUri),
|
|
1348
1358
|
requiresTenant: authConfig.requiresTenant,
|
|
1359
|
+
ready: authProductionReady,
|
|
1360
|
+
productionReady: authProductionReady,
|
|
1361
|
+
missing: missingAuthConfig,
|
|
1362
|
+
...(missingAuthConfig.includes("FORGE_AUTH_JWKS_URI")
|
|
1363
|
+
? { suggestedWorkOSJwksUri }
|
|
1364
|
+
: {}),
|
|
1349
1365
|
},
|
|
1350
1366
|
env: {
|
|
1351
1367
|
loadedFiles: envStore.loadedFiles,
|
package/src/forge/ui/index.ts
CHANGED
|
@@ -117,6 +117,25 @@ function looksLikeAuthFlow(text: string): boolean {
|
|
|
117
117
|
return /sign\s*in|signin|login|logout|authkit|organization|tenant|session|workos|clerk|auth0/i.test(text);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
function hasWorkOSIntegration(workspaceRoot: string): boolean {
|
|
121
|
+
return nodeFileSystem.exists(join(workspaceRoot, `${GENERATED}/integrations/workos/authkit.ts`)) ||
|
|
122
|
+
nodeFileSystem.exists(join(workspaceRoot, `${GENERATED}/integrations/workos/auth-routes.ts`));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function webPackageHasAuthKit(workspaceRoot: string): boolean {
|
|
126
|
+
const path = join(workspaceRoot, "web/package.json");
|
|
127
|
+
if (!nodeFileSystem.exists(path)) return false;
|
|
128
|
+
try {
|
|
129
|
+
const packageJson = JSON.parse(nodeFileSystem.readText(path) ?? "{}") as {
|
|
130
|
+
dependencies?: Record<string, string>;
|
|
131
|
+
devDependencies?: Record<string, string>;
|
|
132
|
+
};
|
|
133
|
+
return Boolean(packageJson.dependencies?.["@workos-inc/authkit-react"] ?? packageJson.devDependencies?.["@workos-inc/authkit-react"]);
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
function hasMainLandmark(text: string): boolean {
|
|
121
140
|
return /<main[\s>]|role=["']main["']|<header[\s>]|<nav[\s>]/i.test(text);
|
|
122
141
|
}
|
|
@@ -775,6 +794,10 @@ function runUiAudit(options: UiCommandOptions): UiCommandResult {
|
|
|
775
794
|
const diagnostics: Diagnostic[] = [];
|
|
776
795
|
const webSources = listWebSourceFiles(options.workspaceRoot, manifest.webRoot);
|
|
777
796
|
const webText = webSources.map((source) => source.text).join("\n");
|
|
797
|
+
const appShellText = webSources
|
|
798
|
+
.filter((source) => !source.path.endsWith("/lib/workos-auth.tsx"))
|
|
799
|
+
.map((source) => source.text)
|
|
800
|
+
.join("\n");
|
|
778
801
|
const scenarioRoutes = new Set(scenarios.map((scenario) => scenario.route));
|
|
779
802
|
const scenarioNames = scenarios.map((scenario) => scenario.name);
|
|
780
803
|
const selectorSet = new Set(manifest.selectors);
|
|
@@ -835,6 +858,18 @@ function runUiAudit(options: UiCommandOptions): UiCommandResult {
|
|
|
835
858
|
"Tenant-scoped or production-auth app has no obvious sign-in/session/organization UI; local devAuth is not a production auth flow.",
|
|
836
859
|
));
|
|
837
860
|
}
|
|
861
|
+
if (
|
|
862
|
+
hasWorkOSIntegration(options.workspaceRoot) &&
|
|
863
|
+
manifest.webRoot &&
|
|
864
|
+
webSources.length > 0 &&
|
|
865
|
+
(!webPackageHasAuthKit(options.workspaceRoot) || !/AuthKitProvider/.test(appShellText) || !/getAccessToken/.test(appShellText))
|
|
866
|
+
) {
|
|
867
|
+
diagnostics.push(diagnostic(
|
|
868
|
+
"warning",
|
|
869
|
+
"FORGE_UI_WORKOS_AUTHKIT_MISSING",
|
|
870
|
+
"WorkOS integration is present, but the web app does not appear to mount AuthKitProvider and pass getAccessToken into ForgeProvider.",
|
|
871
|
+
));
|
|
872
|
+
}
|
|
838
873
|
|
|
839
874
|
return {
|
|
840
875
|
ok: diagnostics.every((item) => item.severity !== "error"),
|
package/src/forge/version.ts
CHANGED