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
13 changes: 13 additions & 0 deletions .changeset/close-approvals-and-record-shares-gaps.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
56 changes: 56 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
123 changes: 123 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> => {
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<any>(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<any> => {
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<any>(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<any> => {
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<any>(res);
},

/** Nudge the pending approver(s); a thread interaction — the flow does not move. */
remind: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
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<any>(res);
},

/** Ask the submitter for more information (thread interaction). */
requestInfo: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<any> => {
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<any>(res);
},

/** Append a comment (optionally with attachments) to the request thread. */
comment: async (requestId: string, opts: { comment: string; actorId?: string; attachments?: string[] }): Promise<any> => {
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<any>(res);
},

/**
* Audit trail (the immutable action log) for an approval request.
*/
Expand All @@ -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<any[]> => {
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<any> => {
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<any>(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)
*
Expand Down
12 changes: 6 additions & 6 deletions packages/rest/src/rest-route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
27 changes: 9 additions & 18 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────────
Expand Down
Loading