Skip to content

Commit 1003125

Browse files
os-zhuangclaude
andauthored
feat(client): close the approvals (6) + record-shares (3) REST gaps (#3587 batch 3/5) (#3629)
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). Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ecda20c commit 1003125

6 files changed

Lines changed: 209 additions & 24 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
feat(client): close the approvals (6) + record-shares (3) REST gaps (#3587 batch 3/5)
7+
8+
`client.approvals` gains the full request lifecycle beyond approve/reject:
9+
`recall` (submitter withdraw), `revise` / `resubmit` (ADR-0044 send-back
10+
round-trip), and the thread interactions `remind` / `requestInfo` / `comment`.
11+
New `client.shares` namespace for per-record sharing grants: `list` / `grant` /
12+
`revoke` (204-safe) under `/data/:object/:id/shares`. REST route-ledger
13+
ratchet: 26 → 17.

content/docs/api/client-sdk.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
116116
| **analytics** || 3 | Analytics queries |
117117
| **automation** || 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
118118
| **actions** || 2 | Server-registered action handlers (`engine.registerAction`) |
119+
| **approvals** || 12 | Approval requests (ADR-0019): inbox, decisions, recall, revise/resubmit (ADR-0044), thread interactions, audit trail |
119120
| **reports** || 8 | Saved reports: definitions, execution, recurring email schedules (501 without `@objectstack/plugin-reports`) |
121+
| **shares** || 3 | Per-record sharing grants: list/grant/revoke on `/data/:object/:id/shares` |
120122
| **keys** || 1 | API key minting (one-time secret) |
121123
| **shareLinks** || 3 | Record share-link management |
122124
| **security** || 3 | Suggested audience bindings (admin) |

packages/client/src/client.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,62 @@ describe('Reports namespace (#3587 gap closure)', () => {
262262
});
263263
});
264264

265+
describe('Approvals lifecycle & thread routes (#3587 gap closure)', () => {
266+
it.each([
267+
['recall', 'recall'],
268+
['revise', 'revise'],
269+
['resubmit', 'resubmit'],
270+
['remind', 'remind'],
271+
['requestInfo', 'request-info'],
272+
] as const)('approvals.%s pins POST /approvals/requests/:id/%s', async (method, segment) => {
273+
const { client, fetchMock } = createMockClient({ success: true, data: { status: 'ok' } });
274+
await (client.approvals as any)[method]('req-1', { comment: 'hi' });
275+
const [url, init] = fetchMock.mock.calls[0];
276+
expect(String(url)).toBe(`http://localhost:3000/api/v1/approvals/requests/req-1/${segment}`);
277+
expect(init.method).toBe('POST');
278+
expect(JSON.parse(init.body).comment).toBe('hi');
279+
});
280+
281+
it('approvals.comment pins the comment route and carries attachments', async () => {
282+
const { client, fetchMock } = createMockClient({ success: true, data: { status: 'ok' } });
283+
await client.approvals.comment('req-1', { comment: 'note', attachments: ['f1'] });
284+
const [url, init] = fetchMock.mock.calls[0];
285+
expect(String(url)).toBe('http://localhost:3000/api/v1/approvals/requests/req-1/comment');
286+
expect(JSON.parse(init.body)).toEqual({ comment: 'note', attachments: ['f1'] });
287+
});
288+
});
289+
290+
describe('Record shares namespace (#3587 gap closure)', () => {
291+
it('shares.list pins GET /data/:object/:id/shares and unwraps {data}', async () => {
292+
const { client, fetchMock } = createMockClient({ data: [{ id: 'sh1' }] });
293+
const rows = await client.shares.list('lead', 'rec1');
294+
expect(String(fetchMock.mock.calls[0][0])).toBe(
295+
'http://localhost:3000/api/v1/data/lead/rec1/shares',
296+
);
297+
expect(rows).toEqual([{ id: 'sh1' }]);
298+
});
299+
300+
it('shares.grant pins POST /data/:object/:id/shares with the grant body', async () => {
301+
const { client, fetchMock } = createMockClient({ id: 'sh1' });
302+
await client.shares.grant('lead', 'rec1', { recipientType: 'user', recipientId: 'u1', accessLevel: 'read' });
303+
const [url, init] = fetchMock.mock.calls[0];
304+
expect(String(url)).toBe('http://localhost:3000/api/v1/data/lead/rec1/shares');
305+
expect(init.method).toBe('POST');
306+
expect(JSON.parse(init.body)).toEqual({ recipientType: 'user', recipientId: 'u1', accessLevel: 'read' });
307+
});
308+
309+
it('shares.revoke pins DELETE /data/:object/:id/shares/:shareId and tolerates 204', async () => {
310+
const del = createMockClient(undefined, 204);
311+
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
312+
const out = await del.client.shares.revoke('lead', 'rec1', 'sh1');
313+
expect(String(del.fetchMock.mock.calls[0][0])).toBe(
314+
'http://localhost:3000/api/v1/data/lead/rec1/shares/sh1',
315+
);
316+
expect(del.fetchMock.mock.calls[0][1].method).toBe('DELETE');
317+
expect(out).toEqual({ deleted: true });
318+
});
319+
});
320+
265321
describe('Approvals namespace (ADR-0019)', () => {
266322
it('should list approval requests with filters', async () => {
267323
const { client, fetchMock } = createMockClient({

packages/client/src/index.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2942,6 +2942,75 @@ export class ObjectStackClient {
29422942
return this.unwrapResponse<{ request: ApprovalRequestRow }>(res);
29432943
},
29442944

2945+
/**
2946+
* Recall (withdraw) a pending request. Submitter-only — the service
2947+
* enforces access. (#3587 gap closure)
2948+
*/
2949+
recall: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
2950+
const route = this.getRoute('approvals');
2951+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/recall`, {
2952+
method: 'POST',
2953+
body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }),
2954+
});
2955+
return this.unwrapResponse<any>(res);
2956+
},
2957+
2958+
/**
2959+
* ADR-0044 send-back-for-revision: the request finalizes `returned` and
2960+
* the flow run parks at a wait point. Pending-approver-only.
2961+
*/
2962+
revise: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
2963+
const route = this.getRoute('approvals');
2964+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/revise`, {
2965+
method: 'POST',
2966+
body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }),
2967+
});
2968+
return this.unwrapResponse<any>(res);
2969+
},
2970+
2971+
/**
2972+
* ADR-0044 resubmit-after-revision: re-enters the approval node.
2973+
* Submitter-only. Returns the flow outcome (`resumed` / `autoRejected`).
2974+
*/
2975+
resubmit: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
2976+
const route = this.getRoute('approvals');
2977+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/resubmit`, {
2978+
method: 'POST',
2979+
body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }),
2980+
});
2981+
return this.unwrapResponse<any>(res);
2982+
},
2983+
2984+
/** Nudge the pending approver(s); a thread interaction — the flow does not move. */
2985+
remind: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
2986+
const route = this.getRoute('approvals');
2987+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/remind`, {
2988+
method: 'POST',
2989+
body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }),
2990+
});
2991+
return this.unwrapResponse<any>(res);
2992+
},
2993+
2994+
/** Ask the submitter for more information (thread interaction). */
2995+
requestInfo: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
2996+
const route = this.getRoute('approvals');
2997+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/request-info`, {
2998+
method: 'POST',
2999+
body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }),
3000+
});
3001+
return this.unwrapResponse<any>(res);
3002+
},
3003+
3004+
/** Append a comment (optionally with attachments) to the request thread. */
3005+
comment: async (requestId: string, opts: { comment: string; actorId?: string; attachments?: string[] }): Promise<any> => {
3006+
const route = this.getRoute('approvals');
3007+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/comment`, {
3008+
method: 'POST',
3009+
body: JSON.stringify({ actorId: opts.actorId, comment: opts.comment, attachments: opts.attachments }),
3010+
});
3011+
return this.unwrapResponse<any>(res);
3012+
},
3013+
29453014
/**
29463015
* Audit trail (the immutable action log) for an approval request.
29473016
*/
@@ -2953,6 +3022,60 @@ export class ObjectStackClient {
29533022
}
29543023
};
29553024

3025+
/**
3026+
* Per-record sharing grants (#3587 gap closure)
3027+
*
3028+
* Manual (and rule-materialised) row-level access grants on a specific
3029+
* record, served under the data surface. Every route 501s
3030+
* [NOT_IMPLEMENTED] on deployments without the sharing service.
3031+
*/
3032+
shares = {
3033+
/** List the sharing grants on a record. */
3034+
list: async (object: string, recordId: string): Promise<any[]> => {
3035+
const route = this.getRoute('data');
3036+
const res = await this.fetch(
3037+
`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares`,
3038+
);
3039+
const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res);
3040+
return Array.isArray(body) ? body : (body?.data ?? []);
3041+
},
3042+
3043+
/**
3044+
* Grant a principal access to a record. 400 [VALIDATION_FAILED] on a
3045+
* bad recipient/level combination.
3046+
*/
3047+
grant: async (
3048+
object: string,
3049+
recordId: string,
3050+
opts: {
3051+
recipientType: string;
3052+
recipientId: string;
3053+
accessLevel: string;
3054+
source?: string;
3055+
sourceId?: string;
3056+
reason?: string;
3057+
},
3058+
): Promise<any> => {
3059+
const route = this.getRoute('data');
3060+
const res = await this.fetch(
3061+
`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares`,
3062+
{ method: 'POST', body: JSON.stringify(opts) },
3063+
);
3064+
return this.unwrapResponse<any>(res);
3065+
},
3066+
3067+
/** Revoke a share by its id. */
3068+
revoke: async (object: string, recordId: string, shareId: string): Promise<{ deleted: boolean }> => {
3069+
const route = this.getRoute('data');
3070+
const res = await this.fetch(
3071+
`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares/${encodeURIComponent(shareId)}`,
3072+
{ method: 'DELETE' },
3073+
);
3074+
if (res.status === 204) return { deleted: true };
3075+
return this.unwrapResponse<{ deleted: boolean }>(res);
3076+
},
3077+
};
3078+
29563079
/**
29573080
* Saved reports (#3587 gap closure)
29583081
*

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,13 @@ describe('REST route ledger hygiene', () => {
144144
});
145145

146146
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
147-
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 26 after the
148-
// metadata batch closed its 9 and the reports batch its 8 (data-actions 2,
149-
// search 1, email 1, analytics 1, security-explain 2, record-shares 3,
150-
// sharing-rules 5, approvals 6, external-datasource 5 remain). Closing a
151-
// gap = reclassify to `sdk` AND lower this bound. Raising it demands an
147+
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 17 after the
148+
// metadata (9), reports (8), and approvals+record-shares (9) batches
149+
// (data-actions 2, search 1, email 1, analytics 1, security-explain 2,
150+
// sharing-rules 5, external-datasource 5 remain). Closing a gap =
151+
// reclassify to `sdk` AND lower this bound. Raising it demands an
152152
// explicit, reviewed decision.
153153
const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
154-
expect(gaps).toBeLessThanOrEqual(26);
154+
expect(gaps).toBeLessThanOrEqual(17);
155155
});
156156
});

packages/rest/src/rest-route-ledger.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
153153
note: 'authorization explain (body form) with no SDK expression' },
154154

155155
// ── per-record shares ─────────────────────────────────────────────────────
156-
{ route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'gap',
157-
note: 'per-record sharing grants listing with no SDK expression' },
158-
{ route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'gap',
159-
note: 'grant a per-record share with no SDK expression' },
160-
{ route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'gap',
161-
note: 'revoke a per-record share with no SDK expression' },
156+
{ route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.list' },
157+
{ route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.grant' },
158+
{ route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.revoke' },
162159

163160
// ── sharing rules ─────────────────────────────────────────────────────────
164161
{ 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[] = [
195192
{ route: 'GET /api/v1/approvals/requests/:id', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.getRequest' },
196193
{ route: 'POST /api/v1/approvals/requests/:id/approve', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.approve' },
197194
{ route: 'POST /api/v1/approvals/requests/:id/reject', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.reject' },
198-
{ route: 'POST /api/v1/approvals/requests/:id/recall', family: 'approvals', source: 'route-manager', disposition: 'gap',
199-
note: 'recall/withdraw with no SDK expression' },
200-
{ route: 'POST /api/v1/approvals/requests/:id/revise', family: 'approvals', source: 'route-manager', disposition: 'gap',
201-
note: 'send-back-for-revision (ADR-0044) with no SDK expression' },
202-
{ route: 'POST /api/v1/approvals/requests/:id/resubmit', family: 'approvals', source: 'route-manager', disposition: 'gap',
203-
note: 'resubmit-after-revision with no SDK expression' },
195+
{ route: 'POST /api/v1/approvals/requests/:id/recall', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.recall' },
196+
{ route: 'POST /api/v1/approvals/requests/:id/revise', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.revise' },
197+
{ route: 'POST /api/v1/approvals/requests/:id/resubmit', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.resubmit' },
204198
{ route: 'POST /api/v1/approvals/requests/:id/reassign', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.reassign' },
205-
{ route: 'POST /api/v1/approvals/requests/:id/remind', family: 'approvals', source: 'route-manager', disposition: 'gap',
206-
note: 'thread reminder with no SDK expression' },
207-
{ route: 'POST /api/v1/approvals/requests/:id/request-info', family: 'approvals', source: 'route-manager', disposition: 'gap',
208-
note: 'thread request-info with no SDK expression' },
209-
{ route: 'POST /api/v1/approvals/requests/:id/comment', family: 'approvals', source: 'route-manager', disposition: 'gap',
210-
note: 'thread comment with no SDK expression' },
199+
{ route: 'POST /api/v1/approvals/requests/:id/remind', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.remind' },
200+
{ route: 'POST /api/v1/approvals/requests/:id/request-info', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.requestInfo' },
201+
{ route: 'POST /api/v1/approvals/requests/:id/comment', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.comment' },
211202
{ route: 'GET /api/v1/approvals/requests/:id/actions', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.listActions' },
212203

213204
// ── batch ─────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)