@colixsystems/datastore-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,9 @@ See the design reference: [`docs/architecture/widget-marketplace.md`](../../docs
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.3.0` — pre-publish. Not yet published to npm.
9
+ `v0.4.0` — pre-publish. Not yet published to npm.
10
+
11
+ > **0.4.0 (additive):** added the `records(t).permissions(recordId)` namespace — `list()`, `grant()`, `update(permissionId, patch)`, `revoke(permissionId)` — covering REQ-ACL-06 row-level grants. The client presents the camelCase shape (`userId`, `canRead`, …) and translates to the snake_case wire contract in both directions.
10
12
 
11
13
  > **0.3.0 (breaking):** the client now matches the AppStudio backend's real REST contract. `records().list()` takes `{ limit, offset, q, filterMode, filter }` (the old `cursor` / JSON `filter` and `sort` are gone) and returns the `{ data, meta }` envelope. `records().aggregate({ groupBy, sumField, filter })` is a `GET` returning `[{ group, count, sum }]`. `users.me()` resolves the current principal via `/auth/app/me`. Filter values are `op:value` expressions.
12
14
 
@@ -57,6 +59,14 @@ const byStatus = await client
57
59
 
58
60
  const me = await client.users.me(); // GET /auth/app/me
59
61
  const myGroups = await client.groups.listMine(); // { data, meta }
62
+
63
+ // Row-level permissions (REQ-ACL-06): a grant to a user OR group with
64
+ // `canRead` is what membership means. Provide exactly one of userId/groupId.
65
+ const perms = client.records("channels").permissions(channelId);
66
+ const { data: members } = await perms.list();
67
+ const grant = await perms.grant({ userId: "user_123", canRead: true });
68
+ await perms.update(grant.id, { canWrite: true });
69
+ await perms.revoke(grant.id);
60
70
  ```
61
71
 
62
72
  ## Surface
@@ -71,6 +81,10 @@ const myGroups = await client.groups.listMine(); // { data, meta }
71
81
  | `records(t).update(id, values)` | `PATCH /tables/{t}/records/{id}` | `Record` |
72
82
  | `records(t).delete(id)` | `DELETE /tables/{t}/records/{id}` | `void` |
73
83
  | `records(t).aggregate(spec)` | `GET /tables/{t}/records/aggregate` | `AggregateResult` |
84
+ | `records(t).permissions(r).list()` | `GET /tables/{t}/records/{r}/permissions` | `Page<RecordPermission>` |
85
+ | `records(t).permissions(r).grant(grant)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
86
+ | `records(t).permissions(r).update(pid, patch)` | `PUT /tables/{t}/records/{r}/permissions/{pid}` | `RecordPermission` |
87
+ | `records(t).permissions(r).revoke(pid)` | `DELETE /tables/{t}/records/{r}/permissions/{pid}` | `void` |
74
88
  | `users.me()` | `GET /auth/app/me` | `AppUser` |
75
89
  | `users.get(id)` | `GET /app/users/{id}` | `AppUser` |
76
90
  | `groups.listMine()` | `GET /app/groups/mine` | `Page<Group>` |
package/dist/client.js CHANGED
@@ -59,6 +59,42 @@ function buildAggregateQueryString(spec) {
59
59
  return parts.length ? `?${parts.join("&")}` : "";
60
60
  }
61
61
 
62
+ // Record-permission grants (REQ-ACL-06) cross the wire in the project's
63
+ // snake_case contract; this client presents the same camelCase face every
64
+ // other namespace uses (`filterMode`, `groupBy`, `AppUser.groupIds`, …).
65
+ // The field set is small and fixed, so an explicit map stays clearer than a
66
+ // generic deep transform — which this package intentionally avoids.
67
+ function serializePermissionBody(input) {
68
+ if (!input || typeof input !== "object") return {};
69
+ const out = {};
70
+ if (input.userId !== undefined) out.user_id = input.userId;
71
+ if (input.groupId !== undefined) out.group_id = input.groupId;
72
+ if (input.canRead !== undefined) out.can_read = input.canRead;
73
+ if (input.canWrite !== undefined) out.can_write = input.canWrite;
74
+ if (input.canDelete !== undefined) out.can_delete = input.canDelete;
75
+ if (input.canGrant !== undefined) out.can_grant = input.canGrant;
76
+ return out;
77
+ }
78
+
79
+ function deserializePermission(row) {
80
+ if (!row || typeof row !== "object") return row;
81
+ return {
82
+ id: row.id,
83
+ tableId: row.table_id,
84
+ recordId: row.record_id ?? null,
85
+ userId: row.user_id ?? null,
86
+ groupId: row.group_id ?? null,
87
+ canRead: !!row.can_read,
88
+ canWrite: !!row.can_write,
89
+ canDelete: !!row.can_delete,
90
+ canGrant: !!row.can_grant,
91
+ sourceKind: row.source_kind ?? null,
92
+ sourceId: row.source_id ?? null,
93
+ createdAt: row.created_at,
94
+ updatedAt: row.updated_at,
95
+ };
96
+ }
97
+
62
98
  /**
63
99
  * @param {object} opts
64
100
  * @param {string} opts.baseUrl
@@ -158,6 +194,44 @@ export function createDatastoreClient(opts) {
158
194
  "GET",
159
195
  `/tables/${enc}/records/aggregate${buildAggregateQueryString(spec)}`,
160
196
  ),
197
+ // REQ-ACL-06: row-level permission grants on a single record. A grant
198
+ // to a user OR group with `canRead` is what membership means — see the
199
+ // Chat widget's channel model. Mirrors the backend routes:
200
+ // GET …/permissions list grants ({ data, meta })
201
+ // POST …/permissions grant one (provide userId XOR groupId)
202
+ // PUT …/permissions/{permissionId} update one grant's flags
203
+ // DELETE …/permissions/{permissionId} revoke one grant
204
+ permissions: (recordId) => {
205
+ if (typeof recordId !== "string" || recordId.length === 0) {
206
+ throw new TypeError(
207
+ "records(table).permissions(recordId): recordId must be a non-empty string",
208
+ );
209
+ }
210
+ const base = `/tables/${enc}/records/${encodeURIComponent(recordId)}/permissions`;
211
+ return {
212
+ list: async () => {
213
+ const res = await request("GET", base);
214
+ const rows = Array.isArray(res?.data) ? res.data : [];
215
+ return { data: rows.map(deserializePermission), meta: res?.meta };
216
+ },
217
+ grant: async (grant) =>
218
+ deserializePermission(
219
+ await request("POST", base, {
220
+ body: serializePermissionBody(grant),
221
+ }),
222
+ ),
223
+ update: async (permissionId, patch) =>
224
+ deserializePermission(
225
+ await request(
226
+ "PUT",
227
+ `${base}/${encodeURIComponent(permissionId)}`,
228
+ { body: serializePermissionBody(patch) },
229
+ ),
230
+ ),
231
+ revoke: (permissionId) =>
232
+ request("DELETE", `${base}/${encodeURIComponent(permissionId)}`),
233
+ };
234
+ },
161
235
  };
162
236
  }
163
237
 
@@ -119,3 +119,97 @@ test("tables.list / tables.get paths", async () => {
119
119
  await client.tables.get("Tasks");
120
120
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks");
121
121
  });
122
+
123
+ test("records.permissions.list maps {data,meta} rows to camelCase", async () => {
124
+ const cap = {};
125
+ const client = makeClient(cap, {
126
+ data: [
127
+ {
128
+ id: "p1",
129
+ table_id: "Channels",
130
+ record_id: "c1",
131
+ user_id: "u1",
132
+ group_id: null,
133
+ can_read: true,
134
+ can_write: false,
135
+ can_delete: false,
136
+ can_grant: false,
137
+ source_kind: null,
138
+ source_id: null,
139
+ created_at: "2026-01-01T00:00:00Z",
140
+ updated_at: "2026-01-02T00:00:00Z",
141
+ },
142
+ ],
143
+ meta: { total: 1, limit: 1, offset: 0 },
144
+ });
145
+ const res = await client.records("Channels").permissions("c1").list();
146
+ assert.equal(cap.method, "GET");
147
+ assert.equal(
148
+ new URL(cap.url).pathname,
149
+ "/api/v1/tables/Channels/records/c1/permissions",
150
+ );
151
+ assert.deepEqual(res.meta, { total: 1, limit: 1, offset: 0 });
152
+ assert.equal(res.data[0].tableId, "Channels");
153
+ assert.equal(res.data[0].userId, "u1");
154
+ assert.equal(res.data[0].canRead, true);
155
+ assert.equal(res.data[0].canWrite, false);
156
+ });
157
+
158
+ test("records.permissions.grant POSTs a snake_case body", async () => {
159
+ const cap = {};
160
+ const client = makeClient(cap, {
161
+ id: "p2",
162
+ table_id: "Channels",
163
+ record_id: "c1",
164
+ user_id: "u2",
165
+ can_read: true,
166
+ can_write: true,
167
+ });
168
+ const row = await client
169
+ .records("Channels")
170
+ .permissions("c1")
171
+ .grant({ userId: "u2", canRead: true, canWrite: true });
172
+ assert.equal(cap.method, "POST");
173
+ assert.equal(
174
+ new URL(cap.url).pathname,
175
+ "/api/v1/tables/Channels/records/c1/permissions",
176
+ );
177
+ const sent = JSON.parse(cap.body);
178
+ assert.deepEqual(sent, { user_id: "u2", can_read: true, can_write: true });
179
+ // Response is mapped back to camelCase.
180
+ assert.equal(row.id, "p2");
181
+ assert.equal(row.userId, "u2");
182
+ assert.equal(row.canWrite, true);
183
+ });
184
+
185
+ test("records.permissions.update PUTs flags by permissionId", async () => {
186
+ const cap = {};
187
+ const client = makeClient(cap, { id: "p2", can_delete: true });
188
+ await client
189
+ .records("Channels")
190
+ .permissions("c1")
191
+ .update("p2", { canDelete: true });
192
+ assert.equal(cap.method, "PUT");
193
+ assert.equal(
194
+ new URL(cap.url).pathname,
195
+ "/api/v1/tables/Channels/records/c1/permissions/p2",
196
+ );
197
+ assert.deepEqual(JSON.parse(cap.body), { can_delete: true });
198
+ });
199
+
200
+ test("records.permissions.revoke DELETEs by permissionId", async () => {
201
+ const cap = {};
202
+ const client = makeClient(cap, {});
203
+ await client.records("Channels").permissions("c1").revoke("p2");
204
+ assert.equal(cap.method, "DELETE");
205
+ assert.equal(
206
+ new URL(cap.url).pathname,
207
+ "/api/v1/tables/Channels/records/c1/permissions/p2",
208
+ );
209
+ });
210
+
211
+ test("records.permissions requires a non-empty recordId", () => {
212
+ const cap = {};
213
+ const client = makeClient(cap);
214
+ assert.throws(() => client.records("Channels").permissions(""), TypeError);
215
+ });
package/dist/index.d.ts CHANGED
@@ -80,6 +80,59 @@ export interface Group {
80
80
  updatedAt?: string;
81
81
  }
82
82
 
83
+ // A single row-level grant on a record (REQ-ACL-06). Exactly one of
84
+ // `userId` / `groupId` is set; the four `can*` flags are the capability
85
+ // bits. `sourceKind` / `sourceId` are non-null only for grants cascaded
86
+ // from a parent record through an inheriting relation — directly-authored
87
+ // grants have both null.
88
+ export interface RecordPermission {
89
+ id: string;
90
+ tableId: string;
91
+ recordId: string | null;
92
+ userId: string | null;
93
+ groupId: string | null;
94
+ canRead: boolean;
95
+ canWrite: boolean;
96
+ canDelete: boolean;
97
+ canGrant: boolean;
98
+ sourceKind: string | null;
99
+ sourceId: string | null;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ }
103
+
104
+ // Grant body for `permissions(recordId).grant(...)`. Provide exactly ONE of
105
+ // `userId` or `groupId`. Flags default server-side to `canRead: true` and
106
+ // the rest `false` when omitted.
107
+ export interface RecordPermissionGrant {
108
+ userId?: string | null;
109
+ groupId?: string | null;
110
+ canRead?: boolean;
111
+ canWrite?: boolean;
112
+ canDelete?: boolean;
113
+ canGrant?: boolean;
114
+ }
115
+
116
+ // Flag patch for `permissions(recordId).update(...)`. Only the four
117
+ // capability bits are mutable; the grantee is fixed at grant time. Omitted
118
+ // flags are left unchanged.
119
+ export interface RecordPermissionPatch {
120
+ canRead?: boolean;
121
+ canWrite?: boolean;
122
+ canDelete?: boolean;
123
+ canGrant?: boolean;
124
+ }
125
+
126
+ export interface RecordPermissionsNamespace {
127
+ list(): Promise<Page<RecordPermission>>;
128
+ grant(grant: RecordPermissionGrant): Promise<RecordPermission>;
129
+ update(
130
+ permissionId: string,
131
+ patch: RecordPermissionPatch,
132
+ ): Promise<RecordPermission>;
133
+ revoke(permissionId: string): Promise<void>;
134
+ }
135
+
83
136
  export interface RecordsNamespace {
84
137
  list(query?: Query): Promise<Page<Record_>>;
85
138
  get(id: string): Promise<Record_>;
@@ -87,6 +140,7 @@ export interface RecordsNamespace {
87
140
  update(id: string, values: Partial<Record_>): Promise<Record_>;
88
141
  delete(id: string): Promise<void>;
89
142
  aggregate(spec: AggregateSpec): Promise<AggregateResult>;
143
+ permissions(recordId: string): RecordPermissionsNamespace;
90
144
  }
91
145
 
92
146
  export interface DatastoreClient {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/datastore-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Typed, scoped client for the AppStudio datastore API. Used by widgets through the injected WidgetContext.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",