Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/client-keys-sharelinks-security.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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');
Expand Down
119 changes: 119 additions & 0 deletions packages/client/src/admin-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
134 changes: 134 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> => {
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<any[]> => {
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<any> => {
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<any> => {
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<any> => {
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
Expand Down
7 changes: 4 additions & 3 deletions packages/runtime/src/route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
16 changes: 8 additions & 8 deletions packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' },

Expand Down