From 0cd7e37ba9a26c69800cbe67a2736ca50dfcdc62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:11:28 +0000 Subject: [PATCH] feat(client): close the approvals (6) + record-shares (3) REST gaps (#3587 batch 3/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.approvals gains the full request lifecycle beyond approve/reject: recall (submitter withdraw), revise / resubmit (the ADR-0044 send-back round-trip, both flow-moving), and the thread interactions remind / requestInfo / comment (comment carries attachments). Bodies follow the decision-route convention ({ actorId?, comment? }; the server defaults actor to the authenticated user). New client.shares namespace for per-record sharing grants under the data surface: list / grant / revoke on /data/:object/:id/shares (revoke is 204-safe). 501s cleanly without the sharing service. Each method URL-pinned in client.test.ts. Ledger: 9 rows gap→sdk; ratchet 26→17. client-sdk.mdx: shares row added and the previously missing approvals row documented (12 methods). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 --- .../close-approvals-and-record-shares-gaps.md | 13 ++ content/docs/api/client-sdk.mdx | 2 + packages/client/src/client.test.ts | 56 ++++++++ packages/client/src/index.ts | 123 ++++++++++++++++++ .../src/rest-route-ledger.conformance.test.ts | 12 +- packages/rest/src/rest-route-ledger.ts | 27 ++-- 6 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 .changeset/close-approvals-and-record-shares-gaps.md diff --git a/.changeset/close-approvals-and-record-shares-gaps.md b/.changeset/close-approvals-and-record-shares-gaps.md new file mode 100644 index 000000000..d29f75817 --- /dev/null +++ b/.changeset/close-approvals-and-record-shares-gaps.md @@ -0,0 +1,13 @@ +--- +"@objectstack/client": minor +"@objectstack/rest": patch +--- + +feat(client): close the approvals (6) + record-shares (3) REST gaps (#3587 batch 3/5) + +`client.approvals` gains the full request lifecycle beyond approve/reject: +`recall` (submitter withdraw), `revise` / `resubmit` (ADR-0044 send-back +round-trip), and the thread interactions `remind` / `requestInfo` / `comment`. +New `client.shares` namespace for per-record sharing grants: `list` / `grant` / +`revoke` (204-safe) under `/data/:object/:id/shares`. REST route-ledger +ratchet: 26 → 17. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 8fa4f327f..4fbfd2e7f 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -116,7 +116,9 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe | **analytics** | ✅ | 3 | Analytics queries | | **automation** | ✅ | 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries | | **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) | +| **approvals** | ✅ | 12 | Approval requests (ADR-0019): inbox, decisions, recall, revise/resubmit (ADR-0044), thread interactions, audit trail | | **reports** | ✅ | 8 | Saved reports: definitions, execution, recurring email schedules (501 without `@objectstack/plugin-reports`) | +| **shares** | ✅ | 3 | Per-record sharing grants: list/grant/revoke on `/data/:object/:id/shares` | | **keys** | ✅ | 1 | API key minting (one-time secret) | | **shareLinks** | ✅ | 3 | Record share-link management | | **security** | ✅ | 3 | Suggested audience bindings (admin) | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index b43ceb663..3478dac89 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -262,6 +262,62 @@ describe('Reports namespace (#3587 gap closure)', () => { }); }); +describe('Approvals lifecycle & thread routes (#3587 gap closure)', () => { + it.each([ + ['recall', 'recall'], + ['revise', 'revise'], + ['resubmit', 'resubmit'], + ['remind', 'remind'], + ['requestInfo', 'request-info'], + ] as const)('approvals.%s pins POST /approvals/requests/:id/%s', async (method, segment) => { + const { client, fetchMock } = createMockClient({ success: true, data: { status: 'ok' } }); + await (client.approvals as any)[method]('req-1', { comment: 'hi' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe(`http://localhost:3000/api/v1/approvals/requests/req-1/${segment}`); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body).comment).toBe('hi'); + }); + + it('approvals.comment pins the comment route and carries attachments', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { status: 'ok' } }); + await client.approvals.comment('req-1', { comment: 'note', attachments: ['f1'] }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/approvals/requests/req-1/comment'); + expect(JSON.parse(init.body)).toEqual({ comment: 'note', attachments: ['f1'] }); + }); +}); + +describe('Record shares namespace (#3587 gap closure)', () => { + it('shares.list pins GET /data/:object/:id/shares and unwraps {data}', async () => { + const { client, fetchMock } = createMockClient({ data: [{ id: 'sh1' }] }); + const rows = await client.shares.list('lead', 'rec1'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/data/lead/rec1/shares', + ); + expect(rows).toEqual([{ id: 'sh1' }]); + }); + + it('shares.grant pins POST /data/:object/:id/shares with the grant body', async () => { + const { client, fetchMock } = createMockClient({ id: 'sh1' }); + await client.shares.grant('lead', 'rec1', { recipientType: 'user', recipientId: 'u1', accessLevel: 'read' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/data/lead/rec1/shares'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ recipientType: 'user', recipientId: 'u1', accessLevel: 'read' }); + }); + + it('shares.revoke pins DELETE /data/:object/:id/shares/:shareId and tolerates 204', async () => { + const del = createMockClient(undefined, 204); + del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() }); + const out = await del.client.shares.revoke('lead', 'rec1', 'sh1'); + expect(String(del.fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/data/lead/rec1/shares/sh1', + ); + expect(del.fetchMock.mock.calls[0][1].method).toBe('DELETE'); + expect(out).toEqual({ deleted: true }); + }); +}); + describe('Approvals namespace (ADR-0019)', () => { it('should list approval requests with filters', async () => { const { client, fetchMock } = createMockClient({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d34818fe2..f9f6be1b2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2942,6 +2942,75 @@ export class ObjectStackClient { return this.unwrapResponse<{ request: ApprovalRequestRow }>(res); }, + /** + * Recall (withdraw) a pending request. Submitter-only — the service + * enforces access. (#3587 gap closure) + */ + recall: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + const route = this.getRoute('approvals'); + const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/recall`, { + method: 'POST', + body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), + }); + return this.unwrapResponse(res); + }, + + /** + * ADR-0044 send-back-for-revision: the request finalizes `returned` and + * the flow run parks at a wait point. Pending-approver-only. + */ + revise: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + const route = this.getRoute('approvals'); + const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/revise`, { + method: 'POST', + body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), + }); + return this.unwrapResponse(res); + }, + + /** + * ADR-0044 resubmit-after-revision: re-enters the approval node. + * Submitter-only. Returns the flow outcome (`resumed` / `autoRejected`). + */ + resubmit: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + const route = this.getRoute('approvals'); + const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/resubmit`, { + method: 'POST', + body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), + }); + return this.unwrapResponse(res); + }, + + /** Nudge the pending approver(s); a thread interaction — the flow does not move. */ + remind: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + const route = this.getRoute('approvals'); + const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/remind`, { + method: 'POST', + body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), + }); + return this.unwrapResponse(res); + }, + + /** Ask the submitter for more information (thread interaction). */ + requestInfo: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + const route = this.getRoute('approvals'); + const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/request-info`, { + method: 'POST', + body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), + }); + return this.unwrapResponse(res); + }, + + /** Append a comment (optionally with attachments) to the request thread. */ + comment: async (requestId: string, opts: { comment: string; actorId?: string; attachments?: string[] }): Promise => { + const route = this.getRoute('approvals'); + const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/comment`, { + method: 'POST', + body: JSON.stringify({ actorId: opts.actorId, comment: opts.comment, attachments: opts.attachments }), + }); + return this.unwrapResponse(res); + }, + /** * Audit trail (the immutable action log) for an approval request. */ @@ -2953,6 +3022,60 @@ export class ObjectStackClient { } }; + /** + * Per-record sharing grants (#3587 gap closure) + * + * Manual (and rule-materialised) row-level access grants on a specific + * record, served under the data surface. Every route 501s + * [NOT_IMPLEMENTED] on deployments without the sharing service. + */ + shares = { + /** List the sharing grants on a record. */ + list: async (object: string, recordId: string): Promise => { + const route = this.getRoute('data'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares`, + ); + const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res); + return Array.isArray(body) ? body : (body?.data ?? []); + }, + + /** + * Grant a principal access to a record. 400 [VALIDATION_FAILED] on a + * bad recipient/level combination. + */ + grant: async ( + object: string, + recordId: string, + opts: { + recipientType: string; + recipientId: string; + accessLevel: string; + source?: string; + sourceId?: string; + reason?: string; + }, + ): Promise => { + const route = this.getRoute('data'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares`, + { method: 'POST', body: JSON.stringify(opts) }, + ); + return this.unwrapResponse(res); + }, + + /** Revoke a share by its id. */ + revoke: async (object: string, recordId: string, shareId: string): Promise<{ deleted: boolean }> => { + const route = this.getRoute('data'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares/${encodeURIComponent(shareId)}`, + { method: 'DELETE' }, + ); + if (res.status === 204) return { deleted: true }; + return this.unwrapResponse<{ deleted: boolean }>(res); + }, + }; + /** * Saved reports (#3587 gap closure) * diff --git a/packages/rest/src/rest-route-ledger.conformance.test.ts b/packages/rest/src/rest-route-ledger.conformance.test.ts index 2037f8745..bfdb04a5d 100644 --- a/packages/rest/src/rest-route-ledger.conformance.test.ts +++ b/packages/rest/src/rest-route-ledger.conformance.test.ts @@ -144,13 +144,13 @@ describe('REST route ledger hygiene', () => { }); it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => { - // Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 26 after the - // metadata batch closed its 9 and the reports batch its 8 (data-actions 2, - // search 1, email 1, analytics 1, security-explain 2, record-shares 3, - // sharing-rules 5, approvals 6, external-datasource 5 remain). Closing a - // gap = reclassify to `sdk` AND lower this bound. Raising it demands an + // Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 17 after the + // metadata (9), reports (8), and approvals+record-shares (9) batches + // (data-actions 2, search 1, email 1, analytics 1, security-explain 2, + // sharing-rules 5, external-datasource 5 remain). Closing a gap = + // reclassify to `sdk` AND lower this bound. Raising it demands an // explicit, reviewed decision. const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; - expect(gaps).toBeLessThanOrEqual(26); + expect(gaps).toBeLessThanOrEqual(17); }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index cf4be5fc4..3668ba041 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -153,12 +153,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'authorization explain (body form) with no SDK expression' }, // ── per-record shares ───────────────────────────────────────────────────── - { route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'gap', - note: 'per-record sharing grants listing with no SDK expression' }, - { route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'gap', - note: 'grant a per-record share with no SDK expression' }, - { route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'gap', - note: 'revoke a per-record share with no SDK expression' }, + { route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.list' }, + { route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.grant' }, + { route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.revoke' }, // ── sharing rules ───────────────────────────────────────────────────────── { route: 'GET /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', @@ -195,19 +192,13 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/approvals/requests/:id', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.getRequest' }, { route: 'POST /api/v1/approvals/requests/:id/approve', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.approve' }, { route: 'POST /api/v1/approvals/requests/:id/reject', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.reject' }, - { route: 'POST /api/v1/approvals/requests/:id/recall', family: 'approvals', source: 'route-manager', disposition: 'gap', - note: 'recall/withdraw with no SDK expression' }, - { route: 'POST /api/v1/approvals/requests/:id/revise', family: 'approvals', source: 'route-manager', disposition: 'gap', - note: 'send-back-for-revision (ADR-0044) with no SDK expression' }, - { route: 'POST /api/v1/approvals/requests/:id/resubmit', family: 'approvals', source: 'route-manager', disposition: 'gap', - note: 'resubmit-after-revision with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/recall', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.recall' }, + { route: 'POST /api/v1/approvals/requests/:id/revise', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.revise' }, + { route: 'POST /api/v1/approvals/requests/:id/resubmit', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.resubmit' }, { route: 'POST /api/v1/approvals/requests/:id/reassign', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.reassign' }, - { route: 'POST /api/v1/approvals/requests/:id/remind', family: 'approvals', source: 'route-manager', disposition: 'gap', - note: 'thread reminder with no SDK expression' }, - { route: 'POST /api/v1/approvals/requests/:id/request-info', family: 'approvals', source: 'route-manager', disposition: 'gap', - note: 'thread request-info with no SDK expression' }, - { route: 'POST /api/v1/approvals/requests/:id/comment', family: 'approvals', source: 'route-manager', disposition: 'gap', - note: 'thread comment with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/remind', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.remind' }, + { route: 'POST /api/v1/approvals/requests/:id/request-info', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.requestInfo' }, + { route: 'POST /api/v1/approvals/requests/:id/comment', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.comment' }, { route: 'GET /api/v1/approvals/requests/:id/actions', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.listActions' }, // ── batch ─────────────────────────────────────────────────────────────────