@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 +15 -1
- package/dist/client.js +74 -0
- package/dist/client.test.js +94 -0
- package/dist/index.d.ts +54 -0
- package/package.json +1 -1
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.
|
|
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
|
|
package/dist/client.test.js
CHANGED
|
@@ -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