Skip to content

Commit 6e59f3f

Browse files
MajorTalclaude
andauthored
feat(cli,mcp): run402 auth scaffold-roles — role-table + requireRole gate generator (#422)
* feat(cli,mcp): run402 auth scaffold-roles — role-table + requireRole gate generator Offline, deterministic generator (CLI subcommand + scaffold_roles MCP tool) that emits the conventional app_roles(user_id uuid, role text) migration, a matching requireRole deploy-spec gate snippet, and a first-operator service-role bootstrap INSERT. No project/network. Docs (cli/llms-cli.txt, SKILL.md) document auth.role() (@run402/functions 3.4.0) and the requireRole(x in allowed) vs auth.role() (branch-friendly read) distinction, plus the tenant-user-id (not wallet) keying. Implements the public-repo follow-ups for the improve-role-gate-ergonomics change. 339 CLI tests pass; new files typecheck clean (pre-existing unrelated tsc error in src/tools/jobs.ts re: Jobs.downloadArtifact SDK lag). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: register scaffold_roles + auth:scaffold-roles in the sync surface map Adds the auth_scaffold_roles capability to SURFACE (mcp/cli/openclaw) and SDK_BY_CAPABILITY (null — offline generator, no SDK method) so the MCP/CLI/OpenClaw inventory guards pass. The remaining sync.test.ts failure (SDK mapping → real method) is the pre-existing jobs.ts downloadArtifact SDK-lag, unrelated to this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f8a9b46 commit 6e59f3f

7 files changed

Lines changed: 220 additions & 0 deletions

File tree

SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,8 @@ Rules and footnotes:
411411
- **Deploy-time validation.** Missing table or column at activation fails with `DEPLOY_INVALID_ROLE_GATE` (422) *before* flipping the live release.
412412
- **Cache TTL.** Default 60s, max 600s. A demoted user keeps the cached role until expiry — for instant revocation, set `cacheTtl: 0` (fresh lookup per request).
413413
- **Gate applies to both** routed (`/your/route`) and direct (`POST /functions/v1/:name` with API key) invocation. Direct invocation still requires the API key at the edge; the gate runs after API-key auth, against the user JWT.
414+
- **Reading the role (`@run402/functions` 3.4.0+).** `await auth.requireRole("operator")` returns `{ user, role }`; it throws a distinct `RoleGateNotConfiguredError` (500) when no `requireRole` gate is declared (vs `InsufficientRoleError` 403 for a real mismatch). For multi-role gates, `await auth.role()` returns the resolved role (or `null`, never throws) so you branch instead of re-asserting.
415+
- **Scaffold + first-operator bootstrap.** `run402 auth scaffold-roles --roles operator` emits the `app_roles` migration, the `requireRole` snippet, and a service-role `INSERT` for the FIRST operator — the table starts empty, so the first grant bypasses RLS with the service key. The gate keys on the tenant user id (JWT `sub`), not a wallet.
414416

415417
### Astro SSR runtime + ISR cache (v1.52+)
416418

cli-help.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const MATRIX = {
101101
"magic-link", "verify", "create-user", "invite-user", "set-password", "settings",
102102
"passkey-register-options", "passkey-register-verify", "passkey-login-options",
103103
"passkey-login-verify", "passkeys", "delete-passkey", "providers",
104+
"scaffold-roles",
104105
],
105106
},
106107
"sender-domain": {

cli/lib/auth.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,17 @@ Subcommands:
4848
providers [--project <id>]
4949
List available auth providers for the project.
5050
51+
scaffold-roles [--table <name>] [--user-col <col>] [--role-col <col>] [--roles <csv>] [--cache-ttl <secs>]
52+
Generate a role-table migration + requireRole gate snippet (offline; no project or network).
53+
5154
Examples:
5255
run402 auth magic-link --email user@example.com --redirect https://myapp.run402.com/cb
5356
run402 auth verify --token abc123def456
5457
run402 auth invite-user --email admin@example.com --redirect https://myapp.run402.com/cb --admin true
5558
run402 auth set-password --token eyJ... --new "new-pass" --current "old-pass"
5659
run402 auth settings --preferred passkey --require-admin-passkey true
5760
run402 auth providers
61+
run402 auth scaffold-roles --roles operator,editor | jq -r .migration
5862
`;
5963

6064
const SUB_HELP = {
@@ -233,6 +237,31 @@ Options:
233237
Examples:
234238
run402 auth providers
235239
run402 auth providers --project prj_abc123
240+
`,
241+
"scaffold-roles": `run402 auth scaffold-roles — Generate a role-table migration + requireRole gate snippet
242+
243+
Usage:
244+
run402 auth scaffold-roles [options]
245+
246+
Generates (offline — no project or network):
247+
- migration: idempotent CREATE TABLE for the conventional role table
248+
- gate: a requireRole deploy-spec snippet pointing at it
249+
- bootstrap: a service-role INSERT to grant the first role
250+
251+
Options:
252+
--table <name> Role table name (default: app_roles)
253+
--user-col <col> User-id column (default: user_id) — matches the tenant user id (JWT 'sub')
254+
--role-col <col> Role column (default: role)
255+
--roles <csv> Allowed roles, comma-separated (default: operator)
256+
--cache-ttl <secs> Role-lookup cache seconds, 0-600 (default: 60; 0 = instant revocation)
257+
258+
Output is a single JSON object; pipe through jq to extract a part:
259+
run402 auth scaffold-roles --roles operator | jq -r .migration
260+
run402 auth scaffold-roles --roles operator,editor | jq .gate
261+
262+
Notes:
263+
requireRole(x) requires x in 'allowed'; for multi-role gates read auth.role() and branch.
264+
The gate accepts any table/columns — this is just the blessed default.
236265
`,
237266
};
238267

@@ -289,6 +318,10 @@ const AUTH_FLAGS = {
289318
known: ["--project", "--help", "-h"],
290319
values: ["--project"],
291320
},
321+
"scaffold-roles": {
322+
known: ["--table", "--user-col", "--role-col", "--roles", "--cache-ttl", "--help", "-h"],
323+
values: ["--table", "--user-col", "--role-col", "--roles", "--cache-ttl"],
324+
},
292325
};
293326

294327
function parseFlag(args, flag) {
@@ -629,6 +662,74 @@ async function providers(args) {
629662
}
630663
}
631664

665+
const ROLE_SCAFFOLD_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
666+
667+
function scaffoldIdent(args, flag, def) {
668+
const value = parseFlag(args, flag) || def;
669+
if (!ROLE_SCAFFOLD_IDENT.test(value)) {
670+
fail({
671+
code: "BAD_FLAG",
672+
message: `${flag} must be an unquoted SQL identifier matching ${ROLE_SCAFFOLD_IDENT.source}`,
673+
});
674+
}
675+
return value;
676+
}
677+
678+
// Pure, offline generator — no SDK / network / project. Emits the conventional
679+
// role-table migration, the matching requireRole gate snippet, and a
680+
// first-operator bootstrap. (Keep in sync with the MCP `scaffold_roles` tool in
681+
// src/tools/scaffold-roles.ts — same artifacts, different presentation.)
682+
function scaffoldRoles(args) {
683+
const table = scaffoldIdent(args, "--table", "app_roles");
684+
const userCol = scaffoldIdent(args, "--user-col", "user_id");
685+
const roleCol = scaffoldIdent(args, "--role-col", "role");
686+
const allowed = (parseFlag(args, "--roles") || "operator")
687+
.split(",")
688+
.map((r) => r.trim())
689+
.filter(Boolean);
690+
if (allowed.length === 0) {
691+
fail({ code: "BAD_FLAG", message: "--roles must list at least one role (comma-separated)" });
692+
}
693+
let cacheTtl = 60;
694+
const ttlRaw = parseFlag(args, "--cache-ttl");
695+
if (ttlRaw !== null) {
696+
const n = Number(ttlRaw);
697+
if (!Number.isInteger(n) || n < 0 || n > 600) {
698+
fail({ code: "BAD_FLAG", message: "--cache-ttl must be an integer in [0, 600]" });
699+
}
700+
cacheTtl = n;
701+
}
702+
const firstRole = allowed[0];
703+
const migration = `-- Conventional Run402 role table: single role per user, keyed on the tenant user id.
704+
CREATE TABLE IF NOT EXISTS ${table} (
705+
${userCol} uuid NOT NULL,
706+
${roleCol} text NOT NULL,
707+
PRIMARY KEY (${userCol})
708+
);`;
709+
const gate = { table, idColumn: userCol, roleColumn: roleCol, allowed, cacheTtl };
710+
const bootstrap = `-- First-operator bootstrap: run ONCE with the SERVICE key (bypasses RLS).
711+
-- Replace <FIRST_OPERATOR_USER_ID> with the tenant user id (internal.users.id /
712+
-- the JWT 'sub') of the first '${firstRole}' — NOT a wallet address.
713+
INSERT INTO ${table} (${userCol}, ${roleCol})
714+
VALUES ('<FIRST_OPERATOR_USER_ID>', '${firstRole}')
715+
ON CONFLICT (${userCol}) DO NOTHING;`;
716+
console.log(
717+
JSON.stringify({
718+
table,
719+
migration,
720+
gate,
721+
bootstrap,
722+
notes: [
723+
`Put 'gate' on the function's requireRole deploy-spec field, then call auth.requireRole('${firstRole}') in the function — or auth.role() to branch when 'allowed' has multiple roles.`,
724+
"requireRole(x) requires x in 'allowed'; for multi-role gates read auth.role() and branch instead of re-asserting.",
725+
"cacheTtl is the role-lookup cache in seconds; set it to 0 for instant revocation (fresh DB read per request).",
726+
"The gate keys on the tenant USER id (internal.users.id / JWT 'sub'), NOT a wallet address.",
727+
`The gate accepts any table/columns — '${table}'(${userCol},${roleCol}) is the blessed default; point requireRole at your own table if you already have one.`,
728+
],
729+
}),
730+
);
731+
}
732+
632733
export async function run(sub, args) {
633734
if (!sub || sub === "--help" || sub === "-h") { console.log(HELP); process.exit(0); }
634735
args = normalizeArgv(args);
@@ -654,6 +755,7 @@ export async function run(sub, args) {
654755
case "passkeys": await passkeys(args); break;
655756
case "delete-passkey": await deletePasskey(args); break;
656757
case "providers": await providers(args); break;
758+
case "scaffold-roles": scaffoldRoles(args); break;
657759
default:
658760
console.error(`Unknown subcommand: ${sub}\n`);
659761
console.log(HELP);

cli/llms-cli.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,10 @@ Validation rules:
639639
- Empty `allowed`. Rejected with `INVALID_SPEC` — set the role values explicitly.
640640
- Deploy-time validation. Missing table or column at activation fails with `DEPLOY_INVALID_ROLE_GATE` (HTTP 422) *before* flipping the live release. `run402 deploy apply` surfaces the structured envelope on stderr.
641641

642+
Reading the role in-function (`@run402/functions` 3.4.0+). `await auth.requireRole("operator")` returns `{ user, role }` or throws — and throws a *distinct* `RoleGateNotConfiguredError` (server-class 500) when the function declares no `requireRole` gate, vs `InsufficientRoleError` (403) for a real role mismatch. When a gate allows more than one role, read `await auth.role()` (returns the resolved role or `null`, never throws) and branch instead of re-asserting — `requireRole(x)` requires `x` to be one of `allowed`.
643+
644+
Scaffolding + first-operator bootstrap. `run402 auth scaffold-roles --roles operator` emits the conventional `app_roles(user_id uuid, role text)` migration, the matching `requireRole` snippet, and a service-role `INSERT` to grant the FIRST role — the table starts empty, so the first grant must bypass RLS with the service key. The gate keys on the tenant user id (`internal.users.id` / JWT `sub`), NOT a wallet. The conventional table is a default — `requireRole` accepts any `(table, idColumn, roleColumn)`.
645+
642646
The gate applies to both routed (`/your/route`) and direct (`POST /functions/v1/:name` with API key plus user JWT) invocation. Direct invocation still requires the API key at the edge; the gate runs after API-key auth, against the user JWT.
643647

644648
Binary files (images, fonts, PDFs): Set `"encoding": "base64"` and provide base64-encoded data. MIME types are auto-detected from the file extension (`.png` → `image/png`, `.woff2` → `font/woff2`, etc.). Text files use `"encoding": "utf-8"` (the default — can be omitted).
@@ -1301,6 +1305,7 @@ Manage project user authentication: magic links, trusted invites, passwords, pas
13011305
- `run402 auth passkeys --token <bearer> [--project <id>]` — list authenticated user's passkeys
13021306
- `run402 auth delete-passkey --token <bearer> --id <passkey_id> [--project <id>]` — delete one passkey
13031307
- `run402 auth providers [--project <id>]` — list available auth providers
1308+
- `run402 auth scaffold-roles [--table <name>] [--user-col <col>] [--role-col <col>] [--roles <csv>] [--cache-ttl <secs>]` — offline generator: emits a role-table migration + `requireRole` gate snippet + first-operator bootstrap (JSON out; no project/network). Pipe through `jq` (e.g. `| jq -r .migration`).
13041309

13051310
Magic link flow: request → user clicks email link → frontend extracts token → verify → authenticated. Token expires in 15 minutes, single-use. Rate limited: 5 per email/hour, plus per-project limits by tier.
13061311

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ import { requestMagicLinkSchema, handleRequestMagicLink } from "./tools/request-
160160
import { verifyMagicLinkSchema, handleVerifyMagicLink } from "./tools/verify-magic-link.js";
161161
import { setUserPasswordSchema, handleSetUserPassword } from "./tools/set-user-password.js";
162162
import { authSettingsSchema, handleAuthSettings } from "./tools/auth-settings.js";
163+
import { scaffoldRolesSchema, handleScaffoldRoles } from "./tools/scaffold-roles.js";
163164
import {
164165
createAuthUserSchema,
165166
handleCreateAuthUser,
@@ -1146,6 +1147,13 @@ server.tool(
11461147
async (args) => handleAuthSettings(args),
11471148
);
11481149

1150+
server.tool(
1151+
"scaffold_roles",
1152+
"Generate a role-table migration + requireRole gate snippet + first-operator bootstrap for Run402 function role gates. Offline and deterministic (no project or network). Inputs: table, user_col, role_col, roles[], cache_ttl.",
1153+
scaffoldRolesSchema,
1154+
async (args) => handleScaffoldRoles(args),
1155+
);
1156+
11491157
server.tool(
11501158
"passkey_register_options",
11511159
"Create WebAuthn passkey registration options for the authenticated user.",

src/tools/scaffold-roles.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { z } from "zod";
2+
3+
// Pure, offline generator — no SDK / network / project. Emits the conventional
4+
// role-table migration, the matching requireRole gate snippet, and a
5+
// first-operator bootstrap. (Keep in sync with the CLI `auth scaffold-roles`
6+
// command in cli/lib/auth.mjs — same artifacts, different presentation.)
7+
8+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
9+
10+
export const scaffoldRolesSchema = {
11+
table: z
12+
.string()
13+
.regex(IDENT)
14+
.optional()
15+
.describe("Role table name (unquoted SQL identifier). Default: app_roles."),
16+
user_col: z
17+
.string()
18+
.regex(IDENT)
19+
.optional()
20+
.describe("User-id column — matches the tenant user id (internal.users.id / JWT 'sub'). Default: user_id."),
21+
role_col: z
22+
.string()
23+
.regex(IDENT)
24+
.optional()
25+
.describe("Role column. Default: role."),
26+
roles: z
27+
.array(z.string().min(1))
28+
.optional()
29+
.describe('Allowed roles. Default: ["operator"].'),
30+
cache_ttl: z
31+
.number()
32+
.int()
33+
.min(0)
34+
.max(600)
35+
.optional()
36+
.describe("Role-lookup cache seconds (0-600). Default: 60. 0 = instant revocation (fresh DB read per request)."),
37+
};
38+
39+
export function handleScaffoldRoles(args: {
40+
table?: string;
41+
user_col?: string;
42+
role_col?: string;
43+
roles?: string[];
44+
cache_ttl?: number;
45+
}): { content: Array<{ type: "text"; text: string }>; isError?: boolean } {
46+
const table = args.table ?? "app_roles";
47+
const userCol = args.user_col ?? "user_id";
48+
const roleCol = args.role_col ?? "role";
49+
const allowed = (args.roles && args.roles.length > 0 ? args.roles : ["operator"])
50+
.map((r) => r.trim())
51+
.filter(Boolean);
52+
if (allowed.length === 0) {
53+
return { isError: true, content: [{ type: "text", text: "roles must contain at least one non-empty role" }] };
54+
}
55+
const cacheTtl = args.cache_ttl ?? 60;
56+
const firstRole = allowed[0];
57+
58+
const migration = `-- Conventional Run402 role table: single role per user, keyed on the tenant user id.
59+
CREATE TABLE IF NOT EXISTS ${table} (
60+
${userCol} uuid NOT NULL,
61+
${roleCol} text NOT NULL,
62+
PRIMARY KEY (${userCol})
63+
);`;
64+
const gate = { table, idColumn: userCol, roleColumn: roleCol, allowed, cacheTtl };
65+
const bootstrap = `-- First-operator bootstrap: run ONCE with the SERVICE key (bypasses RLS).
66+
-- Replace <FIRST_OPERATOR_USER_ID> with the tenant user id (internal.users.id /
67+
-- the JWT 'sub') of the first '${firstRole}' — NOT a wallet address.
68+
INSERT INTO ${table} (${userCol}, ${roleCol})
69+
VALUES ('<FIRST_OPERATOR_USER_ID>', '${firstRole}')
70+
ON CONFLICT (${userCol}) DO NOTHING;`;
71+
72+
const text = [
73+
"## Role-gate scaffold",
74+
"",
75+
"**1. Migration** (apply once, or via your deploy spec's database migrations):",
76+
"```sql",
77+
migration,
78+
"```",
79+
"",
80+
"**2. `requireRole` gate** — put this on the function in your deploy spec (`spec.functions[].requireRole`):",
81+
"```json",
82+
JSON.stringify(gate, null, 2),
83+
"```",
84+
"",
85+
`**3. In the function:** \`await auth.requireRole(${JSON.stringify(firstRole)})\` — or \`await auth.role()\` to branch when \`allowed\` has multiple roles.`,
86+
"",
87+
"**4. First-operator bootstrap** (the table starts empty — grant the first role once with the service key):",
88+
"```sql",
89+
bootstrap,
90+
"```",
91+
"",
92+
"Notes:",
93+
"- `requireRole(x)` requires `x` to be in `allowed`; for multi-role gates read `auth.role()` and branch instead of re-asserting.",
94+
"- `cacheTtl` is the role-lookup cache in seconds; set it to `0` for instant revocation (fresh DB read per request).",
95+
"- The gate keys on the tenant **user id** (`internal.users.id` / JWT `sub`), NOT a wallet address.",
96+
`- The gate accepts any table/columns — \`${table}(${userCol}, ${roleCol})\` is the blessed default; point \`requireRole\` at your own table if you already have one.`,
97+
].join("\n");
98+
99+
return { content: [{ type: "text", text }] };
100+
}

sync.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ const SURFACE: Capability[] = [
400400
{ id: "list_passkeys", endpoint: "GET /auth/v1/passkeys", mcp: "list_passkeys", cli: "auth:passkeys", openclaw: "auth:passkeys" },
401401
{ id: "delete_passkey", endpoint: "DELETE /auth/v1/passkeys/:id", mcp: "delete_passkey", cli: "auth:delete-passkey", openclaw: "auth:delete-passkey" },
402402
{ id: "auth_providers", endpoint: "GET /auth/v1/providers", mcp: null, cli: "auth:providers", openclaw: "auth:providers" },
403+
{ id: "auth_scaffold_roles", endpoint: "(local)", mcp: "scaffold_roles", cli: "auth:scaffold-roles", openclaw: "auth:scaffold-roles" },
403404

404405
// ── Custom sender domains ─────────────────────────────────────────────
405406
{ id: "register_sender_domain", endpoint: "POST /email/v1/domains", mcp: "register_sender_domain", cli: "sender-domain:register", openclaw: "sender-domain:register" },
@@ -659,6 +660,7 @@ const SDK_BY_CAPABILITY: Record<string, string | null> = {
659660
list_passkeys: "auth.listPasskeys",
660661
delete_passkey: "auth.deletePasskey",
661662
auth_providers: "auth.providers",
663+
auth_scaffold_roles: null, // offline CLI/MCP generator — no SDK method
662664

663665
// Sender domains
664666
register_sender_domain: "senderDomain.register",

0 commit comments

Comments
 (0)