forgeos 0.1.0-alpha.28 → 0.1.0-alpha.29

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.
Files changed (40) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/docs/changelog.md +20 -0
  4. package/package.json +1 -1
  5. package/src/forge/_generated/releaseManifest.json +1 -1
  6. package/src/forge/_generated/releaseManifest.ts +3 -3
  7. package/src/forge/cli/auth.ts +56 -1
  8. package/src/forge/cli/authmd.ts +356 -0
  9. package/src/forge/cli/commands.ts +28 -0
  10. package/src/forge/cli/main.ts +6 -0
  11. package/src/forge/cli/output.ts +12 -0
  12. package/src/forge/cli/parse.ts +67 -1
  13. package/src/forge/cli/workos.ts +340 -0
  14. package/src/forge/compiler/agent-contract/build.ts +97 -3
  15. package/src/forge/compiler/agent-contract/types.ts +2 -1
  16. package/src/forge/compiler/emitter/render.ts +4 -0
  17. package/src/forge/compiler/integration/add.ts +1 -1
  18. package/src/forge/compiler/integration/plan.ts +15 -0
  19. package/src/forge/compiler/integration/render.ts +20 -0
  20. package/src/forge/compiler/integration/templates/index.ts +1 -0
  21. package/src/forge/compiler/integration/templates/render.ts +31 -0
  22. package/src/forge/compiler/integration/templates/workos.ts +1046 -0
  23. package/src/forge/compiler/orchestrator/plan.ts +10 -2
  24. package/src/forge/compiler/policy-registry/build.ts +3 -1
  25. package/src/forge/compiler/policy-registry/parse.ts +32 -2
  26. package/src/forge/compiler/recipes/definitions.ts +38 -0
  27. package/src/forge/compiler/recipes/index.ts +1 -0
  28. package/src/forge/compiler/recipes/registry.ts +3 -0
  29. package/src/forge/compiler/types/dev-manifest.ts +4 -0
  30. package/src/forge/compiler/types/emit.ts +2 -0
  31. package/src/forge/compiler/types/integration.ts +1 -0
  32. package/src/forge/compiler/types/policy-registry.ts +3 -1
  33. package/src/forge/dev/server.ts +519 -5
  34. package/src/forge/policy.ts +1 -1
  35. package/src/forge/runtime/auth/config.ts +17 -0
  36. package/src/forge/runtime/auth/evaluate.ts +15 -2
  37. package/src/forge/runtime/auth/resolve.ts +29 -4
  38. package/src/forge/runtime/webhooks/security.ts +12 -7
  39. package/src/forge/server.ts +5 -0
  40. package/src/forge/version.ts +1 -1
package/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.28 input=17144e8bdfd3e1024bc4ca6ec68a23a3b7c2fba75d3033853d2486fbc51db17f content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
1
+ // @forge-generated generator=0.1.0-alpha.29 input=25d84ce77a02f5c4ce49fa082fa968608c9859594d5876886344276ca0c27523 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # forgeos
2
2
 
3
+ ## 0.1.0-alpha.29
4
+
5
+ ### Patch Changes
6
+
7
+ - Add the first WorkOS/AuthKit adapter and local auth metadata tooling.
8
+
9
+ - Add `forge add auth workos`, generated WorkOS seed/config/docs, AuthKit routes, webhook handling, JWT/OIDC claim mapping, and permission-derived Forge policies.
10
+ - Add `forge authmd generate` and `forge authmd check`, including `/auth.md` and OAuth protected-resource metadata served by `forge dev`.
11
+ - Add a local WorkOS/FGA testkit, resource-graph helpers, cross-tenant guards, FGA cache/fallback telemetry, and mock multi-tenant regression coverage.
12
+ - Teach Forge auth and policies to understand permission claims alongside roles.
13
+ - Add `forge version --json` as a command alias and capture local helper table reads in the generated agent contract/capability map.
14
+
3
15
  ## 0.1.0-alpha.28
4
16
 
5
17
  ### Patch Changes
package/docs/changelog.md CHANGED
@@ -6,6 +6,26 @@ The canonical source file in the repository is `CHANGELOG.md`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.0-alpha.29
10
+
11
+ - Added the first WorkOS/AuthKit adapter surface: `forge add auth workos`
12
+ generates local AuthKit wiring, `.env.example`, `workos-seed.yml`, demo
13
+ organizations, roles, permissions, redirect/CORS/webhook hints, JWT/OIDC
14
+ claim mapping, and permission-derived Forge policies.
15
+ - Added `forge authmd generate` and `forge authmd check`, including
16
+ `public/auth.md`, OAuth protected-resource metadata, command/policy/tenant
17
+ requirements, approval metadata, and `forge dev` serving for `/auth.md` and
18
+ `/.well-known/oauth-protected-resource`.
19
+ - Added local WorkOS/FGA scaffolding without requiring real WorkOS credentials:
20
+ resource graph helpers, cross-tenant guards, FGA check cache/fallback
21
+ telemetry, a mock WorkOS testkit, and Acme/Globex multi-tenant regression
22
+ coverage.
23
+ - Taught Forge auth and policies to evaluate permission claims alongside
24
+ roles, including dev-header permission simulation.
25
+ - Added `forge version --json` as a command alias and improved the generated
26
+ agent contract/capability map so table reads performed by imported local
27
+ helpers are captured.
28
+
9
29
  ## 0.1.0-alpha.28
10
30
 
11
31
  - Accepted visible Codex hook canaries as sufficient for local editing while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.28",
3
+ "version": "0.1.0-alpha.29",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.28","releaseId":"forgeos@0.1.0-alpha.28+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.29","releaseId":"forgeos@0.1.0-alpha.29+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.28 input=17144e8bdfd3e1024bc4ca6ec68a23a3b7c2fba75d3033853d2486fbc51db17f content=232067bb5b77f562ad4aa2ddf44f9e3a796ef42205185a28d16ec1ff86cc40b0
1
+ // @forge-generated generator=0.1.0-alpha.29 input=25d84ce77a02f5c4ce49fa082fa968608c9859594d5876886344276ca0c27523 content=ace6a64b0efd317e17fff6fb883bd20137bde066cbee7c2a304be18b90a20fd0
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.28",
23
- "releaseId": "forgeos@0.1.0-alpha.28+unknown",
22
+ "packageVersion": "0.1.0-alpha.29",
23
+ "releaseId": "forgeos@0.1.0-alpha.29+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -4,10 +4,11 @@ import {
4
4
  FORGE_AUTH_INVALID_AUDIENCE,
5
5
  FORGE_AUTH_JWKS_FAILED,
6
6
  } from "../compiler/diagnostics/codes.ts";
7
- import { loadAuthConfigFromEnv } from "../runtime/auth/config.ts";
7
+ import { loadAuthConfigFromEnv, type AuthClaimsMapping } from "../runtime/auth/config.ts";
8
8
  import { mapClaimsToAuthContext } from "../runtime/auth/claims.ts";
9
9
  import { ForgeAuthError } from "../runtime/auth/errors.ts";
10
10
  import { verifyJwtToken } from "../runtime/auth/verifier.ts";
11
+ import { loadSecretRegistry } from "../runtime/secrets/check.ts";
11
12
 
12
13
  export type AuthSubcommand = "check" | "config" | "decode" | "test-token" | "jwks" | "prove";
13
14
 
@@ -26,9 +27,42 @@ export interface AuthCommandResult {
26
27
  exitCode: 0 | 1;
27
28
  }
28
29
 
30
+ function detectWorkOS(workspaceRoot: string, claims: AuthClaimsMapping) {
31
+ const secretRegistry = loadSecretRegistry(workspaceRoot);
32
+ const secretNames = new Set((secretRegistry?.secrets ?? []).map((secret) => secret.name));
33
+ const detected =
34
+ secretNames.has("WORKOS_API_KEY") ||
35
+ secretNames.has("WORKOS_CLIENT_ID") ||
36
+ claims.tenantId === "organization_id";
37
+ const expectedClaims = {
38
+ userId: "sub",
39
+ email: "email",
40
+ tenantId: "organization_id",
41
+ role: "role",
42
+ roles: "roles",
43
+ permissions: "permissions",
44
+ };
45
+ const claimStatus = Object.entries(expectedClaims).map(([name, expected]) => ({
46
+ name,
47
+ expected,
48
+ actual: claims[name as keyof AuthClaimsMapping],
49
+ ok: claims[name as keyof AuthClaimsMapping] === expected,
50
+ }));
51
+ return {
52
+ detected,
53
+ requiredSecretsRegistered: ["WORKOS_API_KEY", "WORKOS_CLIENT_ID", "WORKOS_COOKIE_PASSWORD"].every((name) =>
54
+ secretNames.has(name)
55
+ ),
56
+ webhookSecretRegistered: secretNames.has("WORKOS_WEBHOOK_SECRET"),
57
+ expectedClaims,
58
+ claimStatus,
59
+ };
60
+ }
61
+
29
62
  function validateConfig(workspaceRoot: string): AuthCommandResult {
30
63
  const config = loadAuthConfigFromEnv(workspaceRoot);
31
64
  const errors: { code: string; message: string }[] = [];
65
+ const workos = detectWorkOS(workspaceRoot, config.claims);
32
66
 
33
67
  if ((config.mode === "jwt" || config.mode === "oidc") && !config.issuer) {
34
68
  errors.push({
@@ -60,6 +94,7 @@ function validateConfig(workspaceRoot: string): AuthCommandResult {
60
94
  algorithms: config.algorithms,
61
95
  claims: config.claims,
62
96
  requiresTenant: config.requiresTenant,
97
+ workos,
63
98
  errors,
64
99
  },
65
100
  error: errors[0],
@@ -69,6 +104,7 @@ function validateConfig(workspaceRoot: string): AuthCommandResult {
69
104
 
70
105
  function publicConfig(workspaceRoot: string): AuthCommandResult {
71
106
  const config = loadAuthConfigFromEnv(workspaceRoot);
107
+ const workos = detectWorkOS(workspaceRoot, config.claims);
72
108
  return {
73
109
  ok: true,
74
110
  mode: config.mode,
@@ -80,6 +116,7 @@ function publicConfig(workspaceRoot: string): AuthCommandResult {
80
116
  algorithms: config.algorithms,
81
117
  claims: config.claims,
82
118
  requiresTenant: config.requiresTenant,
119
+ workos,
83
120
  },
84
121
  exitCode: 0,
85
122
  };
@@ -166,7 +203,9 @@ export async function runAuthCommand(
166
203
  if (options.subcommand === "prove") {
167
204
  const checked = validateConfig(options.workspaceRoot);
168
205
  const config = loadAuthConfigFromEnv(options.workspaceRoot);
206
+ const workos = detectWorkOS(options.workspaceRoot, config.claims);
169
207
  const productionMode = config.mode === "jwt" || config.mode === "oidc";
208
+ const workosClaimsOk = workos.claimStatus.every((claim) => claim.ok);
170
209
  return {
171
210
  ok: checked.ok,
172
211
  mode: config.mode,
@@ -191,7 +230,23 @@ export async function runAuthCommand(
191
230
  status: checked.ok ? "passed" : "failed",
192
231
  evidence: checked.data,
193
232
  },
233
+ {
234
+ id: "INV-WORKOS-001",
235
+ name: "WorkOS adapter claim mapping is explicit when WorkOS is present",
236
+ status: !workos.detected ? "not-applicable" : workosClaimsOk ? "passed" : "failed",
237
+ evidence: workos.claimStatus,
238
+ },
239
+ {
240
+ id: "INV-WORKOS-002",
241
+ name: "WorkOS required secret names are registered without values",
242
+ status: !workos.detected ? "not-applicable" : workos.requiredSecretsRegistered ? "passed" : "failed",
243
+ evidence: {
244
+ required: ["WORKOS_API_KEY", "WORKOS_CLIENT_ID", "WORKOS_COOKIE_PASSWORD"],
245
+ webhookSecretRegistered: workos.webhookSecretRegistered,
246
+ },
247
+ },
194
248
  ],
249
+ workos,
195
250
  checkedAt: "deterministic",
196
251
  },
197
252
  error: checked.error,
@@ -0,0 +1,356 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
4
+ import { nodeFileSystem } from "../compiler/fs/index.ts";
5
+ import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
6
+
7
+ export type AuthMdSubcommand = "generate" | "check";
8
+
9
+ export interface AuthMdCommandOptions {
10
+ subcommand: AuthMdSubcommand;
11
+ workspaceRoot: string;
12
+ json: boolean;
13
+ output?: string;
14
+ }
15
+
16
+ export interface AuthMdCommandResult {
17
+ ok: boolean;
18
+ path: string;
19
+ metadataPath: string;
20
+ changed: boolean;
21
+ diagnostics: Array<{ code: string; message: string }>;
22
+ data: {
23
+ commands: number;
24
+ queries: number;
25
+ liveQueries: number;
26
+ actions: number;
27
+ policies: number;
28
+ aiTools: number;
29
+ requiresTenant: boolean;
30
+ };
31
+ exitCode: 0 | 1;
32
+ }
33
+
34
+ interface RuntimeEntry {
35
+ name: string;
36
+ policy?: string;
37
+ requiresAuth?: boolean;
38
+ risk?: string;
39
+ needsApproval?: boolean;
40
+ }
41
+
42
+ interface AgentContractLike {
43
+ project?: { name?: string };
44
+ commands?: RuntimeEntry[];
45
+ queries?: RuntimeEntry[];
46
+ liveQueries?: RuntimeEntry[];
47
+ actions?: RuntimeEntry[];
48
+ policies?: Array<{ name: string; kind?: string; roles?: string[]; permissions?: string[] }>;
49
+ auth?: {
50
+ defaultMode?: string;
51
+ bearerTokenHeader?: string;
52
+ env?: Record<string, string>;
53
+ claims?: Record<string, string | undefined>;
54
+ requiresTenant?: boolean;
55
+ };
56
+ }
57
+
58
+ interface AgentToolsLike {
59
+ tools?: Array<{
60
+ name: string;
61
+ description?: string;
62
+ risk?: string;
63
+ needsApproval?: boolean | "dynamic";
64
+ }>;
65
+ }
66
+
67
+ function readGeneratedJson<T>(workspaceRoot: string, relativePath: string): T | null {
68
+ const absolute = join(workspaceRoot, relativePath);
69
+ if (!nodeFileSystem.exists(absolute)) {
70
+ return null;
71
+ }
72
+ return JSON.parse(stripDeterministicHeader(readFileSync(absolute, "utf8"))) as T;
73
+ }
74
+
75
+ function outputPath(options: AuthMdCommandOptions): string {
76
+ return options.output?.trim() || "public/auth.md";
77
+ }
78
+
79
+ function listRuntimeEntries(title: string, entries: RuntimeEntry[] | undefined): string[] {
80
+ const rows = (entries ?? []).map((entry) =>
81
+ `| \`${entry.name}\` | ${entry.policy ? `\`${entry.policy}\`` : "none"} | ${
82
+ entry.requiresAuth === false ? "no" : "yes"
83
+ } | ${entry.risk ?? "read"} | ${String(entry.needsApproval ?? false)} |`
84
+ );
85
+ return [
86
+ `## ${title}`,
87
+ "",
88
+ "| Name | Policy | Requires auth | Risk | Needs approval |",
89
+ "|------|--------|---------------|------|----------------|",
90
+ ...(rows.length > 0 ? rows : ["| none | none | no | none | false |"]),
91
+ "",
92
+ ];
93
+ }
94
+
95
+ function existingDocLinks(workspaceRoot: string): string[] {
96
+ const candidates = [
97
+ "README.md",
98
+ "docs/index.md",
99
+ "docs/security-and-data.md",
100
+ "docs/forge-add.md",
101
+ `${GENERATED_DIR}/capabilityMap.md`,
102
+ `${GENERATED_DIR}/operationPlaybooks.md`,
103
+ `${GENERATED_DIR}/docs/workos.md`,
104
+ ];
105
+ const docsDir = join(workspaceRoot, "docs");
106
+ if (existsSync(docsDir)) {
107
+ for (const entry of readdirSync(docsDir).slice(0, 20)) {
108
+ if (entry.endsWith(".md")) {
109
+ candidates.push(`docs/${entry}`);
110
+ }
111
+ }
112
+ }
113
+ return [...new Set(candidates)].filter((path) => existsSync(join(workspaceRoot, path)));
114
+ }
115
+
116
+ function riskMetadata(contract: AgentContractLike | null, tools: AgentToolsLike | null) {
117
+ const runtime = [
118
+ ...(contract?.commands ?? []).map((entry) => ({ kind: "command", ...entry, risk: entry.risk ?? "write" })),
119
+ ...(contract?.queries ?? []).map((entry) => ({ kind: "query", ...entry, risk: entry.risk ?? "read" })),
120
+ ...(contract?.liveQueries ?? []).map((entry) => ({ kind: "liveQuery", ...entry, risk: entry.risk ?? "read" })),
121
+ ...(contract?.actions ?? []).map((entry) => ({ kind: "action", ...entry, risk: entry.risk ?? "external" })),
122
+ ].map((entry) => ({
123
+ kind: entry.kind,
124
+ name: entry.name,
125
+ policy: entry.policy ?? null,
126
+ risk: entry.risk ?? "read",
127
+ needs_approval: entry.needsApproval ?? (entry.kind === "action"),
128
+ requires_auth: entry.requiresAuth !== false,
129
+ }));
130
+ const aiTools = (tools?.tools ?? []).map((tool) => ({
131
+ kind: "aiTool",
132
+ name: tool.name,
133
+ risk: tool.risk ?? "read",
134
+ needs_approval: tool.needsApproval ?? false,
135
+ policy: null,
136
+ }));
137
+ return [...runtime, ...aiTools];
138
+ }
139
+
140
+ function metadataPathFor(markdownPath: string): string {
141
+ const publicPrefix = "public/";
142
+ return markdownPath.startsWith(publicPrefix)
143
+ ? "public/.well-known/oauth-protected-resource"
144
+ : `${dirname(markdownPath)}/.well-known/oauth-protected-resource`;
145
+ }
146
+
147
+ function renderAuthMd(workspaceRoot: string): AuthMdCommandResult & { content: string; metadataContent: string } {
148
+ const contract = readGeneratedJson<AgentContractLike>(
149
+ workspaceRoot,
150
+ `${GENERATED_DIR}/agentContract.json`,
151
+ );
152
+ const tools = readGeneratedJson<AgentToolsLike>(
153
+ workspaceRoot,
154
+ `${GENERATED_DIR}/agentTools.json`,
155
+ );
156
+ const diagnostics: AuthMdCommandResult["diagnostics"] = [];
157
+ if (!contract) {
158
+ diagnostics.push({
159
+ code: "FORGE_AUTHMD_MISSING_AGENT_CONTRACT",
160
+ message: "missing src/forge/_generated/agentContract.json; run forge generate first",
161
+ });
162
+ }
163
+
164
+ const auth = contract?.auth;
165
+ const docLinks = existingDocLinks(workspaceRoot);
166
+ const riskRows = riskMetadata(contract, tools);
167
+ const toolRows = (tools?.tools ?? []).map((tool) =>
168
+ `| \`${tool.name}\` | ${tool.description ?? ""} | ${tool.risk ?? "read"} | ${
169
+ String(tool.needsApproval ?? false)
170
+ } |`
171
+ );
172
+ const policyRows = (contract?.policies ?? []).map((policy) =>
173
+ `| \`${policy.name}\` | ${policy.kind ?? "roles"} | ${
174
+ (policy.roles ?? []).map((role) => `\`${role}\``).join(", ") || "none"
175
+ } | ${(policy.permissions ?? []).map((permission) => `\`${permission}\``).join(", ") || "none"} |`
176
+ );
177
+ const scopes = (contract?.policies ?? []).map((policy) => policy.name).sort();
178
+ const actionNames = (contract?.actions ?? []).map((entry) => entry.name).sort();
179
+ const protectedResourceMetadata = {
180
+ resource: "/",
181
+ authorization_servers: ["configured via FORGE_AUTH_ISSUER"],
182
+ bearer_methods_supported: ["header"],
183
+ resource_documentation: "/auth.md",
184
+ scopes_supported: scopes,
185
+ resource_signing_alg_values_supported: auth?.env?.algorithms ? ["RS256"] : undefined,
186
+ forge: {
187
+ app: contract?.project?.name ?? "unknown",
188
+ tenant_required: auth?.requiresTenant ?? false,
189
+ commands: (contract?.commands ?? []).map((entry) => entry.name).sort(),
190
+ queries: (contract?.queries ?? []).map((entry) => entry.name).sort(),
191
+ live_queries: (contract?.liveQueries ?? []).map((entry) => entry.name).sort(),
192
+ actions: actionNames,
193
+ policies: scopes,
194
+ risks: riskRows,
195
+ docs: docLinks.map((path) => `/${path}`),
196
+ ai_tools: (tools?.tools ?? []).map((tool) => ({
197
+ name: tool.name,
198
+ risk: tool.risk ?? "read",
199
+ needs_approval: tool.needsApproval ?? false,
200
+ })),
201
+ },
202
+ };
203
+
204
+ const content = [
205
+ "# auth.md",
206
+ "",
207
+ `App: ${contract?.project?.name ?? "unknown"}`,
208
+ "",
209
+ "This file is generated by ForgeOS for agents and authorization-aware clients. It describes protected resource metadata, runtime capabilities, policy names, tenant requirements, and approval expectations without exposing secrets.",
210
+ "",
211
+ "## Protected Resource Metadata",
212
+ "",
213
+ `- Auth mode: \`${auth?.defaultMode ?? "dev-headers"}\` locally; production should use \`jwt\` or \`oidc\`.`,
214
+ `- Bearer header: \`${auth?.bearerTokenHeader ?? "Authorization"}\`.`,
215
+ `- Issuer env: \`${auth?.env?.issuer ?? "FORGE_AUTH_ISSUER"}\`.`,
216
+ `- Audience env: \`${auth?.env?.audience ?? "FORGE_AUTH_AUDIENCE"}\`.`,
217
+ `- JWKS env: \`${auth?.env?.jwksUri ?? "FORGE_AUTH_JWKS_URI"}\`.`,
218
+ `- Tenant required: \`${String(auth?.requiresTenant ?? false)}\`.`,
219
+ "",
220
+ "## OAuth 2.0 Protected Resource Metadata",
221
+ "",
222
+ "```json",
223
+ JSON.stringify(protectedResourceMetadata, null, 2),
224
+ "```",
225
+ "",
226
+ "## Claim Mapping",
227
+ "",
228
+ `- User: \`${auth?.claims?.userId ?? "sub"}\``,
229
+ `- Email: \`${auth?.claims?.email ?? "email"}\``,
230
+ `- Tenant/organization: \`${auth?.claims?.tenantId ?? "tenant_id"}\``,
231
+ `- Role: \`${auth?.claims?.role ?? "role"}\``,
232
+ `- Roles: \`${auth?.claims?.roles ?? "roles"}\``,
233
+ `- Permissions: \`${auth?.claims?.permissions ?? "permissions"}\``,
234
+ "",
235
+ ...listRuntimeEntries("Commands", contract?.commands),
236
+ ...listRuntimeEntries("Queries", contract?.queries),
237
+ ...listRuntimeEntries("Live Queries", contract?.liveQueries),
238
+ ...listRuntimeEntries("Actions", contract?.actions),
239
+ "## Policies",
240
+ "",
241
+ "| Policy | Kind | Roles | Permissions |",
242
+ "|--------|------|-------|-------------|",
243
+ ...(policyRows.length > 0 ? policyRows : ["| none | none | none | none |"]),
244
+ "",
245
+ "## Risk And Approval Metadata",
246
+ "",
247
+ "| Kind | Name | Risk | Needs approval | Policy |",
248
+ "|------|------|------|----------------|--------|",
249
+ ...(riskRows.length > 0
250
+ ? riskRows.map((entry) => `| ${entry.kind} | \`${entry.name}\` | ${entry.risk} | ${String(entry.needs_approval)} | ${entry.policy ? `\`${entry.policy}\`` : "none"} |`)
251
+ : ["| none | none | read | false | none |"]),
252
+ "",
253
+ "## Agent Tools",
254
+ "",
255
+ "| Tool | Description | Risk | Needs approval |",
256
+ "|------|-------------|------|----------------|",
257
+ ...(toolRows.length > 0 ? toolRows : ["| none | none | read | false |"]),
258
+ "",
259
+ "## Verification",
260
+ "",
261
+ "- Run `forge auth check --json` before production.",
262
+ "- Run `forge auth prove --json` before exposing tenant-scoped data.",
263
+ "- Run `forge authmd check --json` before publishing this file.",
264
+ "",
265
+ "## App Docs",
266
+ "",
267
+ "- `/auth.md` should be served as this public authorization summary when the web app has a public directory.",
268
+ "- `src/forge/_generated/agentContract.json` is the private source contract.",
269
+ "- `src/forge/_generated/capabilityMap.json` maps frontend/backend capabilities.",
270
+ "- `src/forge/_generated/policyRegistry.json` is the policy source of truth.",
271
+ ...docLinks.map((path) => `- \`${path}\``),
272
+ "",
273
+ ].join("\n");
274
+
275
+ return {
276
+ ok: diagnostics.length === 0,
277
+ path: "public/auth.md",
278
+ metadataPath: "public/.well-known/oauth-protected-resource",
279
+ changed: false,
280
+ diagnostics,
281
+ data: {
282
+ commands: contract?.commands?.length ?? 0,
283
+ queries: contract?.queries?.length ?? 0,
284
+ liveQueries: contract?.liveQueries?.length ?? 0,
285
+ actions: contract?.actions?.length ?? 0,
286
+ policies: contract?.policies?.length ?? 0,
287
+ aiTools: tools?.tools?.length ?? 0,
288
+ requiresTenant: auth?.requiresTenant ?? false,
289
+ },
290
+ content,
291
+ metadataContent: `${JSON.stringify(protectedResourceMetadata, null, 2)}\n`,
292
+ exitCode: diagnostics.length === 0 ? 0 : 1,
293
+ };
294
+ }
295
+
296
+ export function runAuthMdCommand(options: AuthMdCommandOptions): AuthMdCommandResult {
297
+ const rendered = renderAuthMd(options.workspaceRoot);
298
+ const path = outputPath(options);
299
+ const metadataPath = metadataPathFor(path);
300
+ const absolute = join(options.workspaceRoot, path);
301
+ const metadataAbsolute = join(options.workspaceRoot, metadataPath);
302
+ const current = nodeFileSystem.exists(absolute) ? readFileSync(absolute, "utf8") : null;
303
+ const currentMetadata = nodeFileSystem.exists(metadataAbsolute) ? readFileSync(metadataAbsolute, "utf8") : null;
304
+ const changed = current !== rendered.content || currentMetadata !== rendered.metadataContent;
305
+ const result = {
306
+ ...rendered,
307
+ path,
308
+ metadataPath,
309
+ changed,
310
+ exitCode: rendered.exitCode,
311
+ };
312
+ delete (result as { content?: string }).content;
313
+ delete (result as { metadataContent?: string }).metadataContent;
314
+
315
+ if (rendered.exitCode !== 0) {
316
+ return result;
317
+ }
318
+
319
+ if (options.subcommand === "check") {
320
+ return {
321
+ ...result,
322
+ ok: !changed,
323
+ diagnostics: changed
324
+ ? [
325
+ {
326
+ code: "FORGE_AUTHMD_DRIFT",
327
+ message: `${path} is missing or stale; run forge authmd generate`,
328
+ },
329
+ ]
330
+ : [],
331
+ exitCode: changed ? 1 : 0,
332
+ };
333
+ }
334
+
335
+ mkdirSync(dirname(absolute), { recursive: true });
336
+ mkdirSync(dirname(metadataAbsolute), { recursive: true });
337
+ writeFileSync(absolute, rendered.content, "utf8");
338
+ writeFileSync(metadataAbsolute, rendered.metadataContent, "utf8");
339
+ return {
340
+ ...result,
341
+ ok: true,
342
+ changed,
343
+ exitCode: 0,
344
+ };
345
+ }
346
+
347
+ export function formatAuthMdJson(result: AuthMdCommandResult): string {
348
+ return `${JSON.stringify(result, null, 2)}\n`;
349
+ }
350
+
351
+ export function formatAuthMdHuman(result: AuthMdCommandResult): string {
352
+ const status = result.ok ? "ok" : "failed";
353
+ const drift = result.changed ? "changed" : "unchanged";
354
+ const diagnostics = result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n");
355
+ return `auth.md ${status}: ${result.path} (${drift})\n${diagnostics ? `${diagnostics}\n` : ""}`;
356
+ }
@@ -192,6 +192,16 @@ import {
192
192
  runWindowsSetupCommand,
193
193
  } from "./windows.ts";
194
194
  import { formatAuthHuman, formatAuthJson, runAuthCommand } from "./auth.ts";
195
+ import {
196
+ formatAuthMdHuman,
197
+ formatAuthMdJson,
198
+ runAuthMdCommand,
199
+ } from "./authmd.ts";
200
+ import {
201
+ formatWorkOSHuman,
202
+ formatWorkOSJson,
203
+ runWorkOSCommand,
204
+ } from "./workos.ts";
195
205
  import { formatRlsHuman, formatRlsJson, runRlsCommand } from "./rls.ts";
196
206
  import {
197
207
  formatSecurityHuman,
@@ -1696,6 +1706,24 @@ export async function executeCommand(command: ForgeCommand): Promise<number> {
1696
1706
  }
1697
1707
  return result.exitCode;
1698
1708
  }
1709
+ case "authmd": {
1710
+ const result = runAuthMdCommand(command);
1711
+ if (command.json) {
1712
+ process.stdout.write(formatAuthMdJson(result));
1713
+ } else {
1714
+ process.stdout.write(formatAuthMdHuman(result));
1715
+ }
1716
+ return result.exitCode;
1717
+ }
1718
+ case "workos": {
1719
+ const result = runWorkOSCommand(command);
1720
+ if (command.json) {
1721
+ process.stdout.write(formatWorkOSJson(result));
1722
+ } else {
1723
+ process.stdout.write(formatWorkOSHuman(result));
1724
+ }
1725
+ return result.exitCode;
1726
+ }
1699
1727
  case "rls": {
1700
1728
  const result = await runRlsCommand(command);
1701
1729
  if (command.json) {
@@ -23,6 +23,12 @@ function formatHelp(): string {
23
23
  " forge docs check --json Check public docs, ReadTheDocs config, links, and local MkDocs tooling",
24
24
  " forge docs check --build --install-venv --json Build docs strictly in a local RTD-style venv",
25
25
  " forge release doctor --json Aggregate release, sourcemaps, self-host, and docs readiness",
26
+ " forge authmd generate Write public/auth.md from the generated agent/auth contract",
27
+ " forge authmd check --json Check public/auth.md drift for CI and agent-ready apps",
28
+ " forge workos install --yes --json Delegate AuthKit setup to npx --yes workos@latest install",
29
+ " forge workos doctor --json Check WorkOS AuthKit/FGA files, claims, seed, webhook, and tenant guards",
30
+ " forge workos doctor --yes --json Run local checks, then delegate to npx --yes workos@latest doctor",
31
+ " forge workos seed --file src/forge/_generated/integrations/workos/workos-seed.yml --json Plan WorkOS CLI seed",
26
32
  " forge release check --allow-missing-local-release --json Gate release readiness without failing on unprepared local artifacts",
27
33
  " forge self-host check --prepared-only --json Report compose readiness without creating deploy files",
28
34
  " forge delta status --verbose --json Include Delta schema, lock, and aggregate count details",
@@ -162,6 +162,18 @@ export function buildAddJson(result: ForgeAddResult): Record<string, unknown> {
162
162
  ...((result.requiredSecrets?.length ?? 0) > 0 || (result.optionalSecrets?.length ?? 0) > 0
163
163
  ? ["forge secrets check --json", "forge inspect secrets --json"]
164
164
  : []),
165
+ ...(result.alias === "workos"
166
+ ? [
167
+ "forge workos install --json",
168
+ "forge workos install --yes --json",
169
+ "forge workos doctor --json",
170
+ "forge workos doctor --yes --json",
171
+ "forge workos seed --file src/forge/_generated/integrations/workos/workos-seed.yml --json",
172
+ "forge workos seed --file src/forge/_generated/integrations/workos/workos-seed.yml --yes --json",
173
+ "forge auth check --json",
174
+ "forge auth prove --json",
175
+ ]
176
+ : []),
165
177
  "forge check --json",
166
178
  "forge verify --smoke",
167
179
  ]