Skill Tracer
Skills › gstack › ios-qa › allowlist.ts

allowlist.ts

114 lines · ios-qa/daemon/src/allowlist.ts at 3f79d81

1// Allowlist file at ~/.gstack/ios-qa-allowlist.json. The single source of
2// truth for who can call what at which capability tier.
3//
4// Self-service mint over tailnet ONLY succeeds for identities present in the
5// allowlist. Owner-granted mint (CLI on the Mac) is what writes new entries
6// to the allowlist. Self-service mint NEVER auto-allowlists.
7
8import { readFile, writeFile, mkdir } from 'fs/promises';
9import { join, dirname } from 'path';
10import type { Allowlist, AllowlistEntry, Capability } from './types';
11import { capabilityCovers } from './types';
12import { resolveStateRoot } from '../../../lib/state-root';
14export function defaultAllowlistPath(): string {
15 return process.env.GSTACK_IOS_ALLOWLIST_PATH
16 ?? join(resolveStateRoot(), 'ios-qa-allowlist.json');
19export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise<Allowlist> {
20 let raw: string;
21 try {
22 raw = await readFile(path, 'utf-8');
23 } catch (err: unknown) {
24 const e = err as { code?: string };
25 if (e.code === 'ENOENT') {
26 return { version: 1, entries: [] };
27 }
28 throw err;
29 }
30 // Empty-file path (mktemp default, partial write, manual `: > file`): treat
31 // as "no entries yet" rather than a parse error. The first grant will fill
32 // it in atomically via saveAllowlist.
33 if (raw.trim() === '') {
34 return { version: 1, entries: [] };
35 }
36 const parsed = JSON.parse(raw) as Allowlist;
37 if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
38 throw new Error('invalid_allowlist');
39 }
40 return parsed;
43export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise<void> {
44 await mkdir(dirname(path), { recursive: true, mode: 0o700 });
45 await writeFile(path, JSON.stringify(allowlist, null, 2) + '\n', { mode: 0o600 });
48/**
49 * Look up an identity in the allowlist. Returns the entry if present AND
50 * not expired. Lookup is exact-match on canonicalized identity.
51 */
52export function findEntry(allowlist: Allowlist, identity: string): AllowlistEntry | null {
53 const now = Date.now();
54 for (const entry of allowlist.entries) {
55 if (entry.identity !== identity) continue;
56 if (entry.expires_at) {
57 const exp = Date.parse(entry.expires_at);
58 if (Number.isFinite(exp) && exp < now) continue;
59 }
60 return entry;
61 }
62 return null;
65/**
66 * Check whether an identity has at least the requested capability tier.
67 * Returns false on missing/expired entries OR insufficient tier.
68 */
69export function hasCapability(allowlist: Allowlist, identity: string, need: Capability): boolean {
70 const entry = findEntry(allowlist, identity);
71 if (!entry) return false;
72 return entry.capabilities.some(c => capabilityCovers(c, need));
75/**
76 * Owner-granted mint path. Adds (or upgrades) an allowlist entry.
77 */
78export async function grantIdentity(opts: {
79 identity: string;
80 capability: Capability;
81 ttlSeconds?: number | null; // null/undefined = no expiry
82 note?: string;
83 path?: string;
84}): Promise<Allowlist> {
85 const path = opts.path ?? defaultAllowlistPath();
86 const allowlist = await loadAllowlist(path);
87 const existingIdx = allowlist.entries.findIndex(e => e.identity === opts.identity);
88 const expiresAt = opts.ttlSeconds && opts.ttlSeconds > 0
89 ? new Date(Date.now() + opts.ttlSeconds * 1000).toISOString()
90 : null;
91 const newEntry: AllowlistEntry = {
92 identity: opts.identity,
93 capabilities: [opts.capability],
94 expires_at: expiresAt,
95 note: opts.note,
96 };
97 if (existingIdx >= 0) {
98 allowlist.entries[existingIdx] = newEntry;
99 } else {
100 allowlist.entries.push(newEntry);
101 }
102 await saveAllowlist(allowlist, path);
103 return allowlist;
106/**
107 * Revoke an identity from the allowlist.
108 */
109export async function revokeIdentity(identity: string, path: string = defaultAllowlistPath()): Promise<Allowlist> {
110 const allowlist = await loadAllowlist(path);
111 allowlist.entries = allowlist.entries.filter(e => e.identity !== identity);
112 await saveAllowlist(allowlist, path);
113 return allowlist;