Skip to content

Commit dbb1cc7

Browse files
author
The No Hands Company
committed
feat: token scopes UI, scope tests, fh create completion, OpenAPI 1.0.0
Token scopes UI (Tokens.tsx) - Scope selector in create-token dialog: read / write / deploy / admin - Toggle buttons with primary highlight for selected scopes - Scope description legend below the selector - Defaults to [read, write, deploy] (backwards-compatible) - Scopes sent in createMutation body, reset on dialog close - Token list shows scope string when non-default - Token interface now includes scopes field Shell completion (completion.ts) - fh create added to bash/zsh/fish command lists - fh create --template flag with completions: html vite astro nextjs svelte - fh create --no-install flag documented - env command added to bash/fish command lists Unit tests — tokenScopes.test.ts (21 assertions) - Scope parsing: comma-separated, single, all, whitespace, empty string - Backwards-compat default (read,write,deploy) - Enforcement: read-only blocked from write, deploy-only blocked from write, write blocked from deploy, admin passes all, undefined scopes = session auth - Validation: unknown scopes filtered, valid accepted, empty stays empty - Serialisation round-trip: array → comma string → Set OpenAPI 1.0.0 - Version bumped from 0.9.0 to 1.0.0 - New paths: webhooks CRUD, webhook deliveries, site clone, deployment diff, token list+create with scopes
1 parent de60790 commit dbb1cc7

File tree

4 files changed

+305
-4
lines changed

4 files changed

+305
-4
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
// Mirror the scope logic from tokenAuth.ts
4+
type TokenScope = "read" | "write" | "deploy" | "admin";
5+
6+
function parseScopes(raw: string): Set<TokenScope> {
7+
return new Set<TokenScope>(
8+
raw.split(",").map(s => s.trim()).filter(Boolean) as TokenScope[]
9+
);
10+
}
11+
12+
function hasScope(scopeSet: Set<TokenScope>, required: TokenScope): boolean {
13+
return scopeSet.has(required);
14+
}
15+
16+
// Mirror key validation from tokens.ts
17+
const VALID_SCOPES = ["read", "write", "deploy", "admin"] as const;
18+
function validateScopes(scopes: string[]): string[] {
19+
return scopes.filter(s => (VALID_SCOPES as readonly string[]).includes(s));
20+
}
21+
22+
describe("Token scope parsing", () => {
23+
it("parses comma-separated scopes", () => {
24+
const s = parseScopes("read,write,deploy");
25+
expect(s.has("read")).toBe(true);
26+
expect(s.has("write")).toBe(true);
27+
expect(s.has("deploy")).toBe(true);
28+
expect(s.has("admin")).toBe(false);
29+
});
30+
31+
it("handles single scope", () => {
32+
const s = parseScopes("read");
33+
expect(s.has("read")).toBe(true);
34+
expect(s.has("write")).toBe(false);
35+
});
36+
37+
it("handles all scopes", () => {
38+
const s = parseScopes("read,write,deploy,admin");
39+
expect(s.size).toBe(4);
40+
for (const scope of VALID_SCOPES) {
41+
expect(s.has(scope)).toBe(true);
42+
}
43+
});
44+
45+
it("trims whitespace around scope names", () => {
46+
const s = parseScopes("read, write , deploy");
47+
expect(s.has("read")).toBe(true);
48+
expect(s.has("write")).toBe(true);
49+
});
50+
51+
it("empty string produces empty set", () => {
52+
expect(parseScopes("").size).toBe(0);
53+
});
54+
55+
it("default scope string matches backwards-compat default", () => {
56+
const s = parseScopes("read,write,deploy");
57+
// New tokens default to read+write+deploy
58+
// Existing tokens that predate the scopes column also get this default
59+
expect(s.has("read")).toBe(true);
60+
expect(s.has("write")).toBe(true);
61+
expect(s.has("deploy")).toBe(true);
62+
expect(s.has("admin")).toBe(false);
63+
});
64+
});
65+
66+
describe("Scope enforcement (requireScope)", () => {
67+
it("read-only token blocked from write route", () => {
68+
const scopes = parseScopes("read");
69+
expect(hasScope(scopes, "write")).toBe(false);
70+
});
71+
72+
it("read-only token allowed on read route", () => {
73+
const scopes = parseScopes("read");
74+
expect(hasScope(scopes, "read")).toBe(true);
75+
});
76+
77+
it("deploy-only token cannot write settings", () => {
78+
const scopes = parseScopes("deploy");
79+
expect(hasScope(scopes, "write")).toBe(false);
80+
expect(hasScope(scopes, "deploy")).toBe(true);
81+
});
82+
83+
it("write token cannot deploy", () => {
84+
const scopes = parseScopes("read,write");
85+
expect(hasScope(scopes, "deploy")).toBe(false);
86+
});
87+
88+
it("admin token has all capabilities", () => {
89+
const scopes = parseScopes("read,write,deploy,admin");
90+
for (const scope of VALID_SCOPES) {
91+
expect(hasScope(scopes, scope)).toBe(true);
92+
}
93+
});
94+
95+
it("session auth (no tokenScopes) always passes — no restriction", () => {
96+
// When tokenScopes is undefined, requireScope passes through
97+
const tokenScopes = undefined;
98+
// Simulate the middleware: if (!req.tokenScopes) → next()
99+
expect(tokenScopes).toBeUndefined();
100+
});
101+
});
102+
103+
describe("Scope validation on creation", () => {
104+
it("filters out unknown scopes", () => {
105+
const result = validateScopes(["read", "write", "superuser", "god"]);
106+
expect(result).toEqual(["read", "write"]);
107+
});
108+
109+
it("accepts all valid scopes", () => {
110+
const result = validateScopes([...VALID_SCOPES]);
111+
expect(result).toHaveLength(4);
112+
});
113+
114+
it("empty array stays empty", () => {
115+
expect(validateScopes([])).toHaveLength(0);
116+
});
117+
118+
it("duplicates not deduplicated at validation layer (DB handles it)", () => {
119+
const result = validateScopes(["read", "read", "write"]);
120+
expect(result).toEqual(["read", "read", "write"]);
121+
});
122+
});
123+
124+
describe("Scope string serialisation (for DB storage)", () => {
125+
it("array serialises to comma-separated string", () => {
126+
const scopes = ["read", "write", "deploy"];
127+
expect(scopes.join(",")).toBe("read,write,deploy");
128+
});
129+
130+
it("round-trips: array → string → set", () => {
131+
const original = ["read", "deploy"];
132+
const stored = original.join(",");
133+
const parsed = parseScopes(stored);
134+
expect(parsed.has("read")).toBe(true);
135+
expect(parsed.has("deploy")).toBe(true);
136+
expect(parsed.has("write")).toBe(false);
137+
});
138+
});

artifacts/cli/src/commands/completion.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ _fh_completion() {
2828
-*)
2929
case "$prev" in
3030
deploy) COMPREPLY=( $(compgen -W "--node --token" -- "$cur") ) ;;
31+
create) COMPREPLY=( $(compgen -W "--template --no-install" -- "$cur") ) ;;
3132
build) COMPREPLY=( $(compgen -W "--git-url --branch --command --output --env --install --staging --wait" -- "$cur") ) ;;
3233
logs) COMPREPLY=( $(compgen -W "--build --follow --limit" -- "$cur") ) ;;
3334
forms) COMPREPLY=( $(compgen -W "--form --limit --export --json --unread" -- "$cur") ) ;;
@@ -57,6 +58,7 @@ _fh() {
5758
case $state in
5859
command)
5960
local commands=(
61+
'create:Scaffold a new project from a template'
6062
'init:Initialise a new site in the current directory'
6163
'login:Authenticate with a FedHost node'
6264
'logout:Remove stored credentials'
@@ -115,6 +117,7 @@ const FISH_COMPLETION = `
115117
116118
set -l commands init login logout whoami status deploy build logs sites forms tokens rollback analytics completion
117119
120+
complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a create -d "Scaffold new project from template"
118121
complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a init -d "Initialise a new site"
119122
complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a login -d "Authenticate with a node"
120123
complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a logout -d "Remove stored credentials"
@@ -129,6 +132,10 @@ complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a tokens -d
129132
complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a rollback -d "Rollback deployment"
130133
complete -c fh -f -n "not __fish_seen_subcommand_from $commands" -a analytics -d "View analytics"
131134
135+
# create flags
136+
complete -c fh -n "__fish_seen_subcommand_from create" -l template -a "html vite astro nextjs svelte" -d "Project template"
137+
complete -c fh -n "__fish_seen_subcommand_from create" -l no-install -d "Skip npm install"
138+
132139
# build flags
133140
complete -c fh -n "__fish_seen_subcommand_from build" -l git-url -d "Git repository URL"
134141
complete -c fh -n "__fish_seen_subcommand_from build" -l branch -d "Branch to build"

artifacts/federated-hosting/src/pages/Tokens.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ApiToken {
2222
tokenPrefix: string;
2323
lastUsedAt: string | null;
2424
expiresAt: string | null;
25+
scopes: string;
2526
createdAt: string;
2627
}
2728

@@ -67,8 +68,9 @@ export default function TokensPage() {
6768
const qc = useQueryClient();
6869

6970
const [createOpen, setCreateOpen] = useState(false);
70-
const [newName, setNewName] = useState("");
71-
const [newExpiry, setNewExpiry] = useState("");
71+
const [newName, setNewName] = useState("");
72+
const [newExpiry, setNewExpiry] = useState("");
73+
const [newScopes, setNewScopes] = useState<string[]>(["read", "write", "deploy"]);
7274
const [createdToken, setCreatedToken] = useState<CreatedToken | null>(null);
7375

7476
const { data: tokens = [], isLoading } = useQuery<ApiToken[]>({
@@ -78,7 +80,7 @@ export default function TokensPage() {
7880
});
7981

8082
const createMutation = useMutation({
81-
mutationFn: (body: { name: string; expiresInDays?: number }) =>
83+
mutationFn: (body: { name: string; expiresInDays?: number; scopes?: string[] }) =>
8284
apiFetch<CreatedToken>("/tokens", { method: "POST", body: JSON.stringify(body) }),
8385
onSuccess: (data) => {
8486
qc.invalidateQueries({ queryKey: ["tokens"] });
@@ -117,6 +119,7 @@ export default function TokensPage() {
117119
if (!newName.trim()) return;
118120
createMutation.mutate({
119121
name: newName.trim(),
122+
scopes: newScopes,
120123
...(newExpiry ? { expiresInDays: parseInt(newExpiry, 10) } : {}),
121124
});
122125
}
@@ -126,6 +129,7 @@ export default function TokensPage() {
126129
setCreatedToken(null);
127130
setNewName("");
128131
setNewExpiry("");
132+
setNewScopes(["read", "write", "deploy"]);
129133
}
130134

131135
return (
@@ -188,6 +192,29 @@ export default function TokensPage() {
188192
</div>
189193
<div>
190194
<label className="text-sm text-muted-foreground mb-1.5 block">
195+
<div className="space-y-2">
196+
<label className="text-sm font-medium text-white">Permissions</label>
197+
<div className="flex flex-wrap gap-2">
198+
{(["read", "write", "deploy", "admin"] as const).map(scope => (
199+
<button key={scope} type="button"
200+
onClick={() => setNewScopes(s => s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope])}
201+
className={`px-3 py-1.5 rounded-lg text-xs border transition-all ${
202+
newScopes.includes(scope)
203+
? "bg-primary/10 border-primary/30 text-primary"
204+
: "border-white/10 text-muted-foreground hover:border-white/20"
205+
}`}>
206+
{scope}
207+
</button>
208+
))}
209+
</div>
210+
<p className="text-xs text-muted-foreground">
211+
<span className="text-white">read</span> — view sites/analytics·
212+
<span className="text-white"> write</span> — edit settings·
213+
<span className="text-white"> deploy</span> — push files·
214+
<span className="text-white"> admin</span> — node admin
215+
</p>
216+
</div>
217+
191218
Expires in (days) <span className="text-muted-foreground/60">— leave blank for no expiry</span>
192219
</label>
193220
<input
@@ -274,6 +301,9 @@ export default function TokensPage() {
274301
{token.lastUsedAt && (
275302
<> · Last used {formatDistanceToNow(new Date(token.lastUsedAt), { addSuffix: true })}</>
276303
)}
304+
{token.scopes && token.scopes !== "read,write,deploy" && (
305+
<span className="ml-1 text-xs text-primary/70">[{token.scopes}]</span>
306+
)}
277307
{token.expiresAt && (
278308
<> · Expires {formatDistanceToNow(new Date(token.expiresAt), { addSuffix: true })}</>
279309
)}

lib/api-spec/openapi.yaml

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
openapi: 3.1.0
22
info:
33
title: Federated Hosting API
4-
version: 0.9.0
4+
version: 1.0.0
55
description: |
66
Production-grade federated static site hosting network.
77
Every node exposes this API. Authentication uses session cookies (browser)
@@ -2705,3 +2705,129 @@ components:
27052705
up: {type: integer}
27062706
degraded: {type: integer}
27072707
down: {type: integer}
2708+
2709+
/sites/{id}/webhooks:
2710+
get:
2711+
summary: List webhooks for a site
2712+
tags: [Webhooks]
2713+
security: [{sessionCookie: []}, {bearerToken: []}]
2714+
parameters: [{name: id, in: path, required: true, schema: {type: integer}}]
2715+
responses:
2716+
'200': {description: Webhook list (secrets masked)}
2717+
post:
2718+
summary: Create a webhook
2719+
tags: [Webhooks]
2720+
security: [{sessionCookie: []}, {bearerToken: []}]
2721+
parameters: [{name: id, in: path, required: true, schema: {type: integer}}]
2722+
requestBody:
2723+
required: true
2724+
content:
2725+
application/json:
2726+
schema:
2727+
type: object
2728+
required: [url]
2729+
properties:
2730+
url: {type: string, format: uri}
2731+
secret: {type: string}
2732+
events: {oneOf: [{type: string, enum: ['*']}, {type: array, items: {type: string}}]}
2733+
enabled: {type: boolean, default: true}
2734+
responses:
2735+
'201': {description: Webhook created}
2736+
2737+
/sites/{id}/webhooks/{hookId}:
2738+
patch:
2739+
summary: Update a webhook
2740+
tags: [Webhooks]
2741+
security: [{sessionCookie: []}, {bearerToken: []}]
2742+
parameters:
2743+
- {name: id, in: path, required: true, schema: {type: integer}}
2744+
- {name: hookId, in: path, required: true, schema: {type: integer}}
2745+
responses:
2746+
'200': {description: Updated webhook}
2747+
delete:
2748+
summary: Delete a webhook
2749+
tags: [Webhooks]
2750+
security: [{sessionCookie: []}, {bearerToken: []}]
2751+
parameters:
2752+
- {name: id, in: path, required: true, schema: {type: integer}}
2753+
- {name: hookId, in: path, required: true, schema: {type: integer}}
2754+
responses:
2755+
'204': {description: Deleted}
2756+
2757+
/sites/{id}/webhooks/{hookId}/deliveries:
2758+
get:
2759+
summary: List recent webhook delivery attempts
2760+
tags: [Webhooks]
2761+
security: [{sessionCookie: []}, {bearerToken: []}]
2762+
parameters:
2763+
- {name: id, in: path, required: true, schema: {type: integer}}
2764+
- {name: hookId, in: path, required: true, schema: {type: integer}}
2765+
responses:
2766+
'200': {description: Last 50 deliveries with status, timing, retry count}
2767+
2768+
/sites/{id}/clone:
2769+
post:
2770+
summary: Clone a site — copy all files to a new domain
2771+
tags: [Sites]
2772+
security: [{sessionCookie: []}, {bearerToken: []}]
2773+
parameters: [{name: id, in: path, required: true, schema: {type: integer}}]
2774+
requestBody:
2775+
required: true
2776+
content:
2777+
application/json:
2778+
schema:
2779+
type: object
2780+
required: [name, domain]
2781+
properties:
2782+
name: {type: string}
2783+
domain: {type: string}
2784+
responses:
2785+
'201': {description: Cloned site, no storage duplication (objectPaths reused)}
2786+
2787+
/sites/{id}/deployments/{depId}/diff:
2788+
get:
2789+
summary: File-level diff between two deployments
2790+
tags: [Sites]
2791+
security: [{sessionCookie: []}, {bearerToken: []}]
2792+
parameters:
2793+
- {name: id, in: path, required: true, schema: {type: integer}}
2794+
- {name: depId, in: path, required: true, schema: {type: integer}}
2795+
- {name: base, in: query, required: false, schema: {type: integer}, description: "Base deployment ID; defaults to previous version"}
2796+
responses:
2797+
'200':
2798+
description: Diff summary with added/changed/removed/unchanged file lists
2799+
content:
2800+
application/json:
2801+
schema:
2802+
type: object
2803+
properties:
2804+
summary: {type: object, properties: {added: {type: integer}, changed: {type: integer}, removed: {type: integer}, unchanged: {type: integer}, netSizeBytes: {type: integer}}}
2805+
added: {type: array}
2806+
changed: {type: array}
2807+
removed: {type: array}
2808+
2809+
/tokens:
2810+
get:
2811+
summary: List API tokens for the current user
2812+
tags: [Auth]
2813+
security: [{sessionCookie: []}]
2814+
responses:
2815+
'200': {description: Token list (hashes never returned, prefix shown)}
2816+
post:
2817+
summary: Create an API token
2818+
tags: [Auth]
2819+
security: [{sessionCookie: []}]
2820+
requestBody:
2821+
required: true
2822+
content:
2823+
application/json:
2824+
schema:
2825+
type: object
2826+
required: [name]
2827+
properties:
2828+
name: {type: string}
2829+
scopes: {type: array, items: {type: string, enum: [read, write, deploy, admin]}, default: [read, write, deploy]}
2830+
expiresInDays:{type: integer}
2831+
responses:
2832+
'201':
2833+
description: Token created — plaintext in `token` field, shown once only

0 commit comments

Comments
 (0)