Skip to content

Commit b7550d6

Browse files
os-zhuangclaude
andauthored
feat(client): keys, shareLinks, and security surfaces (#3563 PR-3) (#3575)
Three more domains the route audit found with zero SDK expression, each implemented against the dispatcher's actual contract (domains/keys.ts, domains/share-links.ts, domains/security.ts): - client.keys.create({ name?, expiresAt? }) — POST /api/v1/keys. The raw secret is returned exactly once (only its hash is stored); user_id is pinned server-side and never sent. expiresAt forwards as expires_at (ISO or epoch — the server normalizes and rejects past dates). There was previously NO SDK path to create an API key. - client.shareLinks.create(object, recordId, opts?) / list(opts?) / revoke(idOrToken) — authenticated share-link management. Listing is server-constrained to the caller's own links. The public token routes (:token/resolve, :token/messages) stay browser-only by design — their ledger rows remain `public`. - client.security.suggestedBindings.list / confirm / dismiss — the ADR-0090 admin surface. Anonymous is denied unconditionally server-side. All three use fixed /api/v1 paths (none are in ApiRoutesSchema — the actions/projects precedent, noted at each definition). Ledger: seven rows flip to sdk; the gap ratchet drops 24 → 17. Docs-site SDK page gains the three namespaces + usage snippets. Verified: client 129 passed (7 new), ledger guard both halves green, eslint clean. Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3f2ff6 commit b7550d6

6 files changed

Lines changed: 305 additions & 11 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(client): `keys`, `shareLinks`, and `security` surfaces (#3563 PR-3)
7+
8+
Three more domains the route audit found with zero SDK expression:
9+
10+
- `client.keys.create({ name?, expiresAt? })` — mints a `sys_api_key`
11+
(`POST /api/v1/keys`). The raw secret comes back exactly once; `user_id`
12+
is pinned server-side. There was previously no SDK path to create an API
13+
key at all.
14+
- `client.shareLinks.create / list / revoke` — authenticated management of
15+
record share links. Listing is server-constrained to the caller's own
16+
links; the public token-consumption routes stay browser-only by design.
17+
- `client.security.suggestedBindings.list / confirm / dismiss` — the
18+
ADR-0090 admin surface for package audience-binding suggestions.
19+
20+
The route ledger flips all seven rows to `sdk` and the gap ratchet drops
21+
24 → 17.

content/docs/api/client-sdk.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
119119
| **analytics** || 3 | Analytics queries |
120120
| **automation** || 15 | Flow CRUD, trigger/execute, runs, screen-flow resume |
121121
| **actions** || 2 | Server-registered action handlers (`engine.registerAction`) |
122+
| **keys** || 1 | API key minting (one-time secret) |
123+
| **shareLinks** || 3 | Record share-link management |
124+
| **security** || 3 | Suggested audience bindings (admin) |
122125
| **storage** || 2 | File upload & download |
123126
| **i18n** || 3 | Internationalization |
124127
| **notifications** | 🟡 | 7 | Legacy notification helpers; receipt/inbox cut-over pending |
@@ -352,6 +355,22 @@ if (!res.success) console.warn(res.error);
352355
// Global (object-less) actions dispatch to the wildcard handler:
353356
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
354357

358+
// API Keys — the raw secret is returned exactly ONCE; store it immediately.
359+
const apiKey = await client.keys.create({ name: 'CI key', expiresAt: '2027-01-01' });
360+
console.log(apiKey.key); // never re-displayable
361+
362+
// Share Links — manage record share links (public token URLs stay browser-only)
363+
const link = await client.shareLinks.create('crm_account', recordId, {
364+
permission: 'view',
365+
expiresAt: '2026-12-31',
366+
});
367+
await client.shareLinks.list({ object: 'crm_account' });
368+
await client.shareLinks.revoke(link.token);
369+
370+
// Security (admin) — resolve package audience-binding suggestions (ADR-0090)
371+
const { suggestions } = await client.security.suggestedBindings.list({ status: 'pending' });
372+
await client.security.suggestedBindings.confirm(suggestions[0].id);
373+
355374
// Storage — File upload and management
356375
await client.storage.upload(fileData, 'user');
357376
await client.storage.getDownloadUrl('file-123');
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `client.keys` / `client.shareLinks` / `client.security` — #3563 PR-3 gap
5+
* closures. Before these surfaces, the SDK had no way to mint an API key,
6+
* manage share links, or resolve suggested audience bindings; all three
7+
* domains were `gap` in the route ledger.
8+
*/
9+
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { ObjectStackClient } from './index';
12+
13+
function createMockClient(body: any, status = 200) {
14+
const fetchMock = vi.fn().mockResolvedValue({
15+
ok: status >= 200 && status < 300,
16+
status,
17+
statusText: status === 200 ? 'OK' : 'Error',
18+
json: async () => body,
19+
headers: new Headers()
20+
});
21+
const client = new ObjectStackClient({
22+
baseUrl: 'http://localhost:3000',
23+
fetch: fetchMock
24+
});
25+
return { client, fetchMock };
26+
}
27+
28+
describe('client.keys', () => {
29+
it('create POSTs name + expires_at to /api/v1/keys and returns the one-time secret', async () => {
30+
const { client, fetchMock } = createMockClient({
31+
success: true,
32+
data: { id: 'k1', name: 'CI key', prefix: 'osk_abc', key: 'osk_abc.RAW', expires_at: '2027-01-01T00:00:00.000Z' },
33+
});
34+
35+
const key = await client.keys.create({ name: 'CI key', expiresAt: '2027-01-01T00:00:00.000Z' });
36+
37+
const [url, init] = fetchMock.mock.calls[0];
38+
expect(String(url)).toBe('http://localhost:3000/api/v1/keys');
39+
expect(init.method).toBe('POST');
40+
expect(JSON.parse(init.body)).toEqual({ name: 'CI key', expires_at: '2027-01-01T00:00:00.000Z' });
41+
expect(key.key).toBe('osk_abc.RAW');
42+
});
43+
44+
it('create sends an empty body object when no options are given', async () => {
45+
const { client, fetchMock } = createMockClient({ success: true, data: { id: 'k1', key: 'raw' } });
46+
await client.keys.create();
47+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
48+
});
49+
});
50+
51+
describe('client.shareLinks', () => {
52+
it('create POSTs object + recordId + options to /api/v1/share-links', async () => {
53+
const { client, fetchMock } = createMockClient({ success: true, data: { id: 'sl1', token: 'tok_1' } });
54+
55+
const link = await client.shareLinks.create('crm_account', 'acc_1', {
56+
permission: 'view',
57+
password: 'hunter2',
58+
label: 'For the auditor',
59+
});
60+
61+
const [url, init] = fetchMock.mock.calls[0];
62+
expect(String(url)).toBe('http://localhost:3000/api/v1/share-links');
63+
expect(JSON.parse(init.body)).toEqual({
64+
object: 'crm_account',
65+
recordId: 'acc_1',
66+
permission: 'view',
67+
password: 'hunter2',
68+
label: 'For the auditor',
69+
});
70+
expect(link.token).toBe('tok_1');
71+
});
72+
73+
it('list builds the query string and omits empty filters', async () => {
74+
const { client, fetchMock } = createMockClient({ success: true, data: [] });
75+
await client.shareLinks.list({ object: 'crm_account', includeRevoked: true });
76+
expect(String(fetchMock.mock.calls[0][0])).toBe(
77+
'http://localhost:3000/api/v1/share-links?object=crm_account&includeRevoked=true',
78+
);
79+
80+
await client.shareLinks.list();
81+
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/share-links');
82+
});
83+
84+
it('revoke DELETEs by id or token, URL-encoded', async () => {
85+
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
86+
await client.shareLinks.revoke('tok/with slash');
87+
const [url, init] = fetchMock.mock.calls[0];
88+
expect(String(url)).toBe('http://localhost:3000/api/v1/share-links/tok%2Fwith%20slash');
89+
expect(init.method).toBe('DELETE');
90+
});
91+
});
92+
93+
describe('client.security.suggestedBindings', () => {
94+
it('list GETs /api/v1/security/suggested-bindings with optional filters', async () => {
95+
const { client, fetchMock } = createMockClient({ success: true, data: { suggestions: [] } });
96+
await client.security.suggestedBindings.list({ status: 'pending', packageId: 'com.acme.crm' });
97+
expect(String(fetchMock.mock.calls[0][0])).toBe(
98+
'http://localhost:3000/api/v1/security/suggested-bindings?status=pending&packageId=com.acme.crm',
99+
);
100+
101+
await client.security.suggestedBindings.list();
102+
expect(String(fetchMock.mock.calls[1][0])).toBe(
103+
'http://localhost:3000/api/v1/security/suggested-bindings',
104+
);
105+
});
106+
107+
it('confirm and dismiss POST to the id-scoped subroutes', async () => {
108+
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
109+
await client.security.suggestedBindings.confirm('sug_1');
110+
await client.security.suggestedBindings.dismiss('sug_2');
111+
expect(String(fetchMock.mock.calls[0][0])).toBe(
112+
'http://localhost:3000/api/v1/security/suggested-bindings/sug_1/confirm',
113+
);
114+
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
115+
expect(String(fetchMock.mock.calls[1][0])).toBe(
116+
'http://localhost:3000/api/v1/security/suggested-bindings/sug_2/dismiss',
117+
);
118+
});
119+
});

packages/client/src/index.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2410,6 +2410,140 @@ export class ObjectStackClient {
24102410
},
24112411
};
24122412

2413+
/**
2414+
* API Keys (#3563 gap closure)
2415+
*
2416+
* `POST /api/v1/keys` mints a `sys_api_key` for the CALLER — `user_id` is
2417+
* pinned server-side and never read from the body, and the raw secret is
2418+
* returned exactly once (only its hash is stored; it is never
2419+
* re-displayable). Until this surface existed the SDK had no way to create
2420+
* an API key at all. Fixed path — `keys` is not in `ApiRoutesSchema`
2421+
* (same precedent as `actions` / `projects`).
2422+
*/
2423+
keys = {
2424+
/**
2425+
* Mint an API key. Returns `{ id, name, prefix, key, expires_at? }` —
2426+
* `key` is the raw secret, shown ONCE; store it immediately.
2427+
* `expiresAt` accepts an ISO string or epoch (ms or s); the server
2428+
* rejects past dates.
2429+
*/
2430+
create: async (opts?: {
2431+
name?: string;
2432+
expiresAt?: string | number;
2433+
}): Promise<{ id: string; name: string; prefix: string; key: string; expires_at?: string }> => {
2434+
const res = await this.fetch(`${this.baseUrl}/api/v1/keys`, {
2435+
method: 'POST',
2436+
body: JSON.stringify({
2437+
...(opts?.name != null ? { name: opts.name } : {}),
2438+
...(opts?.expiresAt != null ? { expires_at: opts.expiresAt } : {}),
2439+
}),
2440+
});
2441+
return this.unwrapResponse(res);
2442+
},
2443+
};
2444+
2445+
/**
2446+
* Share Links (#3563 gap closure)
2447+
*
2448+
* Authenticated management of record share links (`sys_share_link`).
2449+
* The public consumption routes (`GET /share-links/:token/resolve`,
2450+
* `GET /share-links/:token/messages`) are browser-facing token URLs and
2451+
* deliberately stay out of the SDK. Listing is server-constrained to links
2452+
* the CALLER created — a guessed recordId can never enumerate another
2453+
* user's tokens. Fixed path — `share-links` is not in `ApiRoutesSchema`.
2454+
*/
2455+
shareLinks = {
2456+
/** Create a share link for a record. Returns the link row (incl. `token`). */
2457+
create: async (
2458+
object: string,
2459+
recordId: string,
2460+
opts?: {
2461+
permission?: string;
2462+
audience?: string;
2463+
expiresAt?: string | null;
2464+
emailAllowlist?: string[];
2465+
password?: string;
2466+
redactFields?: string[];
2467+
label?: string;
2468+
},
2469+
): Promise<any> => {
2470+
const res = await this.fetch(`${this.baseUrl}/api/v1/share-links`, {
2471+
method: 'POST',
2472+
body: JSON.stringify({ object, recordId, ...(opts ?? {}) }),
2473+
});
2474+
return this.unwrapResponse(res);
2475+
},
2476+
2477+
/** List the caller's own share links, optionally filtered. */
2478+
list: async (opts?: {
2479+
object?: string;
2480+
recordId?: string;
2481+
includeRevoked?: boolean;
2482+
}): Promise<any[]> => {
2483+
const params = new URLSearchParams();
2484+
if (opts?.object) params.set('object', opts.object);
2485+
if (opts?.recordId) params.set('recordId', opts.recordId);
2486+
if (opts?.includeRevoked) params.set('includeRevoked', 'true');
2487+
const qs = params.toString();
2488+
const res = await this.fetch(`${this.baseUrl}/api/v1/share-links${qs ? `?${qs}` : ''}`);
2489+
return this.unwrapResponse(res);
2490+
},
2491+
2492+
/** Revoke a share link by id or token. */
2493+
revoke: async (idOrToken: string): Promise<{ ok: boolean }> => {
2494+
const res = await this.fetch(
2495+
`${this.baseUrl}/api/v1/share-links/${encodeURIComponent(idOrToken)}`,
2496+
{ method: 'DELETE' },
2497+
);
2498+
return this.unwrapResponse(res);
2499+
},
2500+
};
2501+
2502+
/**
2503+
* Security Admin (#3563 gap closure)
2504+
*
2505+
* ADR-0090 D5/D9 suggested audience bindings: a package's
2506+
* `isDefault: true` permission set is an install-time SUGGESTION to bind
2507+
* it to the `everyone` position; these calls let an admin see and resolve
2508+
* those suggestions. Anonymous callers are denied unconditionally
2509+
* server-side, and confirm/dismiss run under the audience-anchor +
2510+
* delegated-admin gates with the caller's own context. Fixed path —
2511+
* `security` is not in `ApiRoutesSchema`.
2512+
*/
2513+
security = {
2514+
suggestedBindings: {
2515+
/** List suggestions, optionally by `status` / `packageId` (reconciles first). */
2516+
list: async (opts?: { status?: string; packageId?: string }): Promise<any> => {
2517+
const params = new URLSearchParams();
2518+
if (opts?.status) params.set('status', opts.status);
2519+
if (opts?.packageId) params.set('packageId', opts.packageId);
2520+
const qs = params.toString();
2521+
const res = await this.fetch(
2522+
`${this.baseUrl}/api/v1/security/suggested-bindings${qs ? `?${qs}` : ''}`,
2523+
);
2524+
return this.unwrapResponse(res);
2525+
},
2526+
2527+
/** Confirm a suggestion — creates the anchor binding. */
2528+
confirm: async (id: string): Promise<any> => {
2529+
const res = await this.fetch(
2530+
`${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/confirm`,
2531+
{ method: 'POST' },
2532+
);
2533+
return this.unwrapResponse(res);
2534+
},
2535+
2536+
/** Dismiss (decline) a suggestion. */
2537+
dismiss: async (id: string): Promise<any> => {
2538+
const res = await this.fetch(
2539+
`${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/dismiss`,
2540+
{ method: 'POST' },
2541+
);
2542+
return this.unwrapResponse(res);
2543+
},
2544+
},
2545+
};
2546+
24132547
/**
24142548
* Event Subscription API
24152549
* Provides real-time event subscriptions for metadata and data changes

packages/runtime/src/route-ledger.conformance.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ describe('route ledger hygiene', () => {
8282

8383
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
8484
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
85-
// (actions surface). Closing a gap = reclassify to `sdk` AND lower this
86-
// bound. Raising it demands an explicit, reviewed decision.
85+
// (actions surface); 17 after PR-3 (keys / share-links / security).
86+
// Closing a gap = reclassify to `sdk` AND lower this bound. Raising it
87+
// demands an explicit, reviewed decision.
8788
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
88-
expect(gaps).toBeLessThanOrEqual(24);
89+
expect(gaps).toBeLessThanOrEqual(17);
8990
});
9091
});

packages/runtime/src/route-ledger.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,13 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
9696
{ route: 'POST /notifications/read/all', domain: '/notifications', disposition: 'sdk', client: 'notifications.markAllRead' },
9797

9898
// ── security (ADR-0090 suggested audience bindings) ───────────────────────
99-
{ route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'gap', note: 'no client expression for the whole domain' },
100-
{ route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'gap', note: 'no client expression' },
101-
{ route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'gap', note: 'no client expression' },
99+
{ route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.list' },
100+
{ route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.confirm' },
101+
{ route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.dismiss' },
102102

103103
// ── keys ──────────────────────────────────────────────────────────────────
104-
{ route: 'POST /keys', domain: '/keys', disposition: 'gap',
105-
note: 'mints a sys_api_key (secret returned once); there is NO SDK path to create an API key' },
104+
{ route: 'POST /keys', domain: '/keys', disposition: 'sdk', client: 'keys.create',
105+
note: 'raw secret returned once; user_id pinned server-side' },
106106

107107
// ── storage ───────────────────────────────────────────────────────────────
108108
{ route: 'POST /storage/upload', domain: '/storage', disposition: 'mismatch', client: 'storage.upload',
@@ -115,9 +115,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
115115
note: 'the one ui-route call in the SDK is filed under client.meta, not a ui surface' },
116116

117117
// ── share-links ───────────────────────────────────────────────────────────
118-
{ route: 'POST /share-links', domain: '/share-links', disposition: 'gap', note: 'create a link — no client expression for the whole domain' },
119-
{ route: 'GET /share-links', domain: '/share-links', disposition: 'gap', note: 'list own links — no client expression' },
120-
{ route: 'DELETE /share-links/:idOrToken', domain: '/share-links', disposition: 'gap', note: 'revoke — no client expression' },
118+
{ route: 'POST /share-links', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.create' },
119+
{ route: 'GET /share-links', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.list' },
120+
{ route: 'DELETE /share-links/:idOrToken', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.revoke' },
121121
{ route: 'GET /share-links/:token/resolve', domain: '/share-links', disposition: 'public', note: 'unauthenticated token resolution for shared-record pages' },
122122
{ route: 'GET /share-links/:token/messages', domain: '/share-links', disposition: 'public', note: 'unauthenticated shared-conversation messages' },
123123

0 commit comments

Comments
 (0)