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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +12 -0
- package/docs/changelog.md +20 -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/auth.ts +56 -1
- package/src/forge/cli/authmd.ts +356 -0
- package/src/forge/cli/commands.ts +28 -0
- package/src/forge/cli/main.ts +6 -0
- package/src/forge/cli/output.ts +12 -0
- package/src/forge/cli/parse.ts +67 -1
- package/src/forge/cli/workos.ts +340 -0
- package/src/forge/compiler/agent-contract/build.ts +97 -3
- package/src/forge/compiler/agent-contract/types.ts +2 -1
- package/src/forge/compiler/emitter/render.ts +4 -0
- package/src/forge/compiler/integration/add.ts +1 -1
- package/src/forge/compiler/integration/plan.ts +15 -0
- package/src/forge/compiler/integration/render.ts +20 -0
- package/src/forge/compiler/integration/templates/index.ts +1 -0
- package/src/forge/compiler/integration/templates/render.ts +31 -0
- package/src/forge/compiler/integration/templates/workos.ts +1046 -0
- package/src/forge/compiler/orchestrator/plan.ts +10 -2
- package/src/forge/compiler/policy-registry/build.ts +3 -1
- package/src/forge/compiler/policy-registry/parse.ts +32 -2
- package/src/forge/compiler/recipes/definitions.ts +38 -0
- package/src/forge/compiler/recipes/index.ts +1 -0
- package/src/forge/compiler/recipes/registry.ts +3 -0
- package/src/forge/compiler/types/dev-manifest.ts +4 -0
- package/src/forge/compiler/types/emit.ts +2 -0
- package/src/forge/compiler/types/integration.ts +1 -0
- package/src/forge/compiler/types/policy-registry.ts +3 -1
- package/src/forge/dev/server.ts +519 -5
- package/src/forge/policy.ts +1 -1
- package/src/forge/runtime/auth/config.ts +17 -0
- package/src/forge/runtime/auth/evaluate.ts +15 -2
- package/src/forge/runtime/auth/resolve.ts +29 -4
- package/src/forge/runtime/webhooks/security.ts +12 -7
- package/src/forge/server.ts +5 -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.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 +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.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.
|
|
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.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
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;
|
package/src/forge/cli/auth.ts
CHANGED
|
@@ -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) {
|
package/src/forge/cli/main.ts
CHANGED
|
@@ -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",
|
package/src/forge/cli/output.ts
CHANGED
|
@@ -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
|
]
|