diff --git a/.changeset/client-keys-sharelinks-security.md b/.changeset/client-keys-sharelinks-security.md new file mode 100644 index 0000000000..c13065043b --- /dev/null +++ b/.changeset/client-keys-sharelinks-security.md @@ -0,0 +1,21 @@ +--- +"@objectstack/client": minor +"@objectstack/runtime": patch +--- + +feat(client): `keys`, `shareLinks`, and `security` surfaces (#3563 PR-3) + +Three more domains the route audit found with zero SDK expression: + +- `client.keys.create({ name?, expiresAt? })` — mints a `sys_api_key` + (`POST /api/v1/keys`). The raw secret comes back exactly once; `user_id` + is pinned server-side. There was previously no SDK path to create an API + key at all. +- `client.shareLinks.create / list / revoke` — authenticated management of + record share links. Listing is server-constrained to the caller's own + links; the public token-consumption routes stay browser-only by design. +- `client.security.suggestedBindings.list / confirm / dismiss` — the + ADR-0090 admin surface for package audience-binding suggestions. + +The route ledger flips all seven rows to `sdk` and the gap ratchet drops +24 → 17. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 596342ab7a..7676d6a0de 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -119,6 +119,9 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe | **analytics** | ✅ | 3 | Analytics queries | | **automation** | ✅ | 15 | Flow CRUD, trigger/execute, runs, screen-flow resume | | **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) | +| **keys** | ✅ | 1 | API key minting (one-time secret) | +| **shareLinks** | ✅ | 3 | Record share-link management | +| **security** | ✅ | 3 | Suggested audience bindings (admin) | | **storage** | ✅ | 2 | File upload & download | | **i18n** | ✅ | 3 | Internationalization | | **notifications** | 🟡 | 7 | Legacy notification helpers; receipt/inbox cut-over pending | @@ -352,6 +355,22 @@ if (!res.success) console.warn(res.error); // Global (object-less) actions dispatch to the wildcard handler: await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } }); +// API Keys — the raw secret is returned exactly ONCE; store it immediately. +const apiKey = await client.keys.create({ name: 'CI key', expiresAt: '2027-01-01' }); +console.log(apiKey.key); // never re-displayable + +// Share Links — manage record share links (public token URLs stay browser-only) +const link = await client.shareLinks.create('crm_account', recordId, { + permission: 'view', + expiresAt: '2026-12-31', +}); +await client.shareLinks.list({ object: 'crm_account' }); +await client.shareLinks.revoke(link.token); + +// Security (admin) — resolve package audience-binding suggestions (ADR-0090) +const { suggestions } = await client.security.suggestedBindings.list({ status: 'pending' }); +await client.security.suggestedBindings.confirm(suggestions[0].id); + // Storage — File upload and management await client.storage.upload(fileData, 'user'); await client.storage.getDownloadUrl('file-123'); diff --git a/packages/client/src/admin-surfaces.test.ts b/packages/client/src/admin-surfaces.test.ts new file mode 100644 index 0000000000..ef6cb8b0c1 --- /dev/null +++ b/packages/client/src/admin-surfaces.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `client.keys` / `client.shareLinks` / `client.security` — #3563 PR-3 gap + * closures. Before these surfaces, the SDK had no way to mint an API key, + * manage share links, or resolve suggested audience bindings; all three + * domains were `gap` in the route ledger. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackClient } from './index'; + +function createMockClient(body: any, status = 200) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: async () => body, + headers: new Headers() + }); + const client = new ObjectStackClient({ + baseUrl: 'http://localhost:3000', + fetch: fetchMock + }); + return { client, fetchMock }; +} + +describe('client.keys', () => { + it('create POSTs name + expires_at to /api/v1/keys and returns the one-time secret', async () => { + const { client, fetchMock } = createMockClient({ + success: true, + data: { id: 'k1', name: 'CI key', prefix: 'osk_abc', key: 'osk_abc.RAW', expires_at: '2027-01-01T00:00:00.000Z' }, + }); + + const key = await client.keys.create({ name: 'CI key', expiresAt: '2027-01-01T00:00:00.000Z' }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/keys'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ name: 'CI key', expires_at: '2027-01-01T00:00:00.000Z' }); + expect(key.key).toBe('osk_abc.RAW'); + }); + + it('create sends an empty body object when no options are given', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { id: 'k1', key: 'raw' } }); + await client.keys.create(); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({}); + }); +}); + +describe('client.shareLinks', () => { + it('create POSTs object + recordId + options to /api/v1/share-links', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { id: 'sl1', token: 'tok_1' } }); + + const link = await client.shareLinks.create('crm_account', 'acc_1', { + permission: 'view', + password: 'hunter2', + label: 'For the auditor', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/share-links'); + expect(JSON.parse(init.body)).toEqual({ + object: 'crm_account', + recordId: 'acc_1', + permission: 'view', + password: 'hunter2', + label: 'For the auditor', + }); + expect(link.token).toBe('tok_1'); + }); + + it('list builds the query string and omits empty filters', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: [] }); + await client.shareLinks.list({ object: 'crm_account', includeRevoked: true }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/share-links?object=crm_account&includeRevoked=true', + ); + + await client.shareLinks.list(); + expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/share-links'); + }); + + it('revoke DELETEs by id or token, URL-encoded', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } }); + await client.shareLinks.revoke('tok/with slash'); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/share-links/tok%2Fwith%20slash'); + expect(init.method).toBe('DELETE'); + }); +}); + +describe('client.security.suggestedBindings', () => { + it('list GETs /api/v1/security/suggested-bindings with optional filters', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { suggestions: [] } }); + await client.security.suggestedBindings.list({ status: 'pending', packageId: 'com.acme.crm' }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/security/suggested-bindings?status=pending&packageId=com.acme.crm', + ); + + await client.security.suggestedBindings.list(); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/security/suggested-bindings', + ); + }); + + it('confirm and dismiss POST to the id-scoped subroutes', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } }); + await client.security.suggestedBindings.confirm('sug_1'); + await client.security.suggestedBindings.dismiss('sug_2'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/security/suggested-bindings/sug_1/confirm', + ); + expect(fetchMock.mock.calls[0][1].method).toBe('POST'); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/security/suggested-bindings/sug_2/dismiss', + ); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cac6a9c35e..45fa65f0c5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2410,6 +2410,140 @@ export class ObjectStackClient { }, }; + /** + * API Keys (#3563 gap closure) + * + * `POST /api/v1/keys` mints a `sys_api_key` for the CALLER — `user_id` is + * pinned server-side and never read from the body, and the raw secret is + * returned exactly once (only its hash is stored; it is never + * re-displayable). Until this surface existed the SDK had no way to create + * an API key at all. Fixed path — `keys` is not in `ApiRoutesSchema` + * (same precedent as `actions` / `projects`). + */ + keys = { + /** + * Mint an API key. Returns `{ id, name, prefix, key, expires_at? }` — + * `key` is the raw secret, shown ONCE; store it immediately. + * `expiresAt` accepts an ISO string or epoch (ms or s); the server + * rejects past dates. + */ + create: async (opts?: { + name?: string; + expiresAt?: string | number; + }): Promise<{ id: string; name: string; prefix: string; key: string; expires_at?: string }> => { + const res = await this.fetch(`${this.baseUrl}/api/v1/keys`, { + method: 'POST', + body: JSON.stringify({ + ...(opts?.name != null ? { name: opts.name } : {}), + ...(opts?.expiresAt != null ? { expires_at: opts.expiresAt } : {}), + }), + }); + return this.unwrapResponse(res); + }, + }; + + /** + * Share Links (#3563 gap closure) + * + * Authenticated management of record share links (`sys_share_link`). + * The public consumption routes (`GET /share-links/:token/resolve`, + * `GET /share-links/:token/messages`) are browser-facing token URLs and + * deliberately stay out of the SDK. Listing is server-constrained to links + * the CALLER created — a guessed recordId can never enumerate another + * user's tokens. Fixed path — `share-links` is not in `ApiRoutesSchema`. + */ + shareLinks = { + /** Create a share link for a record. Returns the link row (incl. `token`). */ + create: async ( + object: string, + recordId: string, + opts?: { + permission?: string; + audience?: string; + expiresAt?: string | null; + emailAllowlist?: string[]; + password?: string; + redactFields?: string[]; + label?: string; + }, + ): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/share-links`, { + method: 'POST', + body: JSON.stringify({ object, recordId, ...(opts ?? {}) }), + }); + return this.unwrapResponse(res); + }, + + /** List the caller's own share links, optionally filtered. */ + list: async (opts?: { + object?: string; + recordId?: string; + includeRevoked?: boolean; + }): Promise => { + const params = new URLSearchParams(); + if (opts?.object) params.set('object', opts.object); + if (opts?.recordId) params.set('recordId', opts.recordId); + if (opts?.includeRevoked) params.set('includeRevoked', 'true'); + const qs = params.toString(); + const res = await this.fetch(`${this.baseUrl}/api/v1/share-links${qs ? `?${qs}` : ''}`); + return this.unwrapResponse(res); + }, + + /** Revoke a share link by id or token. */ + revoke: async (idOrToken: string): Promise<{ ok: boolean }> => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/share-links/${encodeURIComponent(idOrToken)}`, + { method: 'DELETE' }, + ); + return this.unwrapResponse(res); + }, + }; + + /** + * Security Admin (#3563 gap closure) + * + * ADR-0090 D5/D9 suggested audience bindings: a package's + * `isDefault: true` permission set is an install-time SUGGESTION to bind + * it to the `everyone` position; these calls let an admin see and resolve + * those suggestions. Anonymous callers are denied unconditionally + * server-side, and confirm/dismiss run under the audience-anchor + + * delegated-admin gates with the caller's own context. Fixed path — + * `security` is not in `ApiRoutesSchema`. + */ + security = { + suggestedBindings: { + /** List suggestions, optionally by `status` / `packageId` (reconciles first). */ + list: async (opts?: { status?: string; packageId?: string }): Promise => { + const params = new URLSearchParams(); + if (opts?.status) params.set('status', opts.status); + if (opts?.packageId) params.set('packageId', opts.packageId); + const qs = params.toString(); + const res = await this.fetch( + `${this.baseUrl}/api/v1/security/suggested-bindings${qs ? `?${qs}` : ''}`, + ); + return this.unwrapResponse(res); + }, + + /** Confirm a suggestion — creates the anchor binding. */ + confirm: async (id: string): Promise => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/confirm`, + { method: 'POST' }, + ); + return this.unwrapResponse(res); + }, + + /** Dismiss (decline) a suggestion. */ + dismiss: async (id: string): Promise => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/dismiss`, + { method: 'POST' }, + ); + return this.unwrapResponse(res); + }, + }, + }; + /** * Event Subscription API * Provides real-time event subscriptions for metadata and data changes diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts index bfdfc4de63..4bf67c5a47 100644 --- a/packages/runtime/src/route-ledger.conformance.test.ts +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -82,9 +82,10 @@ describe('route ledger hygiene', () => { it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => { // Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2 - // (actions surface). Closing a gap = reclassify to `sdk` AND lower this - // bound. Raising it demands an explicit, reviewed decision. + // (actions surface); 17 after PR-3 (keys / share-links / security). + // Closing a gap = reclassify to `sdk` AND lower this bound. Raising it + // demands an explicit, reviewed decision. const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; - expect(gaps).toBeLessThanOrEqual(24); + expect(gaps).toBeLessThanOrEqual(17); }); }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index f5ae2337b4..04adad1d92 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -96,13 +96,13 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /notifications/read/all', domain: '/notifications', disposition: 'sdk', client: 'notifications.markAllRead' }, // ── security (ADR-0090 suggested audience bindings) ─────────────────────── - { route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'gap', note: 'no client expression for the whole domain' }, - { route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'gap', note: 'no client expression' }, - { route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'gap', note: 'no client expression' }, + { route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.list' }, + { route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.confirm' }, + { route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.dismiss' }, // ── keys ────────────────────────────────────────────────────────────────── - { route: 'POST /keys', domain: '/keys', disposition: 'gap', - note: 'mints a sys_api_key (secret returned once); there is NO SDK path to create an API key' }, + { route: 'POST /keys', domain: '/keys', disposition: 'sdk', client: 'keys.create', + note: 'raw secret returned once; user_id pinned server-side' }, // ── storage ─────────────────────────────────────────────────────────────── { route: 'POST /storage/upload', domain: '/storage', disposition: 'mismatch', client: 'storage.upload', @@ -115,9 +115,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: 'the one ui-route call in the SDK is filed under client.meta, not a ui surface' }, // ── share-links ─────────────────────────────────────────────────────────── - { route: 'POST /share-links', domain: '/share-links', disposition: 'gap', note: 'create a link — no client expression for the whole domain' }, - { route: 'GET /share-links', domain: '/share-links', disposition: 'gap', note: 'list own links — no client expression' }, - { route: 'DELETE /share-links/:idOrToken', domain: '/share-links', disposition: 'gap', note: 'revoke — no client expression' }, + { route: 'POST /share-links', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.create' }, + { route: 'GET /share-links', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.list' }, + { route: 'DELETE /share-links/:idOrToken', domain: '/share-links', disposition: 'sdk', client: 'shareLinks.revoke' }, { route: 'GET /share-links/:token/resolve', domain: '/share-links', disposition: 'public', note: 'unauthenticated token resolution for shared-record pages' }, { route: 'GET /share-links/:token/messages', domain: '/share-links', disposition: 'public', note: 'unauthenticated shared-conversation messages' },