@managemint-solutions/sdk 0.52.0 → 0.54.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 +92 -13
- package/dist/additional-details/index.d.ts +2 -2
- package/dist/additional-details/index.js +6 -10
- package/dist/audit/dto.d.ts +3 -8
- package/dist/audit/dto.js +7 -48
- package/dist/audit/index.d.ts +71 -10
- package/dist/audit/index.js +234 -55
- package/dist/auth-client/index.d.ts +2 -0
- package/dist/auth-client/index.js +12 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +47 -3
- package/dist/clients/dto.d.ts +5 -1
- package/dist/clients/dto.js +12 -1
- package/dist/clients/index.d.ts +8 -4
- package/dist/clients/index.js +11 -8
- package/dist/emulation-handshakes/index.d.ts +13 -0
- package/dist/emulation-handshakes/index.js +36 -0
- package/dist/errors.js +2 -2
- package/dist/index.d.ts +14 -10
- package/dist/index.js +23 -4
- package/dist/leads/dto.d.ts +5 -1
- package/dist/leads/dto.js +12 -1
- package/dist/leads/index.d.ts +8 -5
- package/dist/leads/index.js +23 -21
- package/dist/leave/leave-adjustments.js +1 -0
- package/dist/leave/leave-entitlements.d.ts +3 -1
- package/dist/leave/leave-entitlements.js +3 -0
- package/dist/leave/leave-requests.d.ts +3 -1
- package/dist/leave/leave-requests.js +3 -0
- package/dist/nest/index.d.ts +26 -1
- package/dist/nest/index.js +58 -1
- package/dist/notifications/index.d.ts +1 -3
- package/dist/organization-payment-methods/index.d.ts +5 -6
- package/dist/organization-payment-methods/index.js +4 -17
- package/dist/organizations/dto.d.ts +9 -1
- package/dist/organizations/dto.js +40 -1
- package/dist/organizations/index.d.ts +13 -3
- package/dist/organizations/index.js +59 -3
- package/dist/permissions/index.d.ts +6 -4
- package/dist/permissions/index.js +19 -12
- package/dist/projects/dto.d.ts +5 -1
- package/dist/projects/dto.js +12 -1
- package/dist/projects/index.d.ts +8 -4
- package/dist/projects/index.js +11 -8
- package/dist/service-client/index.d.ts +39 -1
- package/dist/service-client/index.js +89 -2
- package/dist/staff/dto.d.ts +14 -0
- package/dist/staff/dto.js +63 -0
- package/dist/staff/index.d.ts +50 -0
- package/dist/staff/index.js +139 -0
- package/dist/staff-client.d.ts +37 -0
- package/dist/staff-client.js +52 -0
- package/dist/tasks/dto.d.ts +5 -1
- package/dist/tasks/dto.js +12 -1
- package/dist/tasks/index.d.ts +15 -10
- package/dist/tasks/index.js +34 -31
- package/dist/tenancy.d.ts +13 -0
- package/dist/tenancy.js +25 -0
- package/dist/timesheets/timesheet-entries.js +1 -0
- package/dist/timesheets/timesheets.d.ts +3 -1
- package/dist/timesheets/timesheets.js +3 -0
- package/dist/user-api-credentials/dto.d.ts +12 -0
- package/dist/user-api-credentials/dto.js +56 -0
- package/dist/user-api-credentials/index.d.ts +47 -0
- package/dist/user-api-credentials/index.js +101 -0
- package/dist/users/dto.d.ts +5 -1
- package/dist/users/dto.js +13 -2
- package/dist/users/index.d.ts +7 -3
- package/dist/users/index.js +16 -29
- package/dist/validators.d.ts +2 -0
- package/dist/validators.js +6 -1
- package/package.json +2 -2
package/dist/audit/index.js
CHANGED
|
@@ -14,9 +14,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.auditedTable = exports.AuditResource = exports.snapshotChanges = exports.diffChanges = exports.isRedactedColumn = exports.AUDIT_REDACTED_COLUMNS = exports.AUDIT_EXCLUDED_COLUMNS = exports.withoutInternalId = void 0;
|
|
17
|
+
exports.auditedTable = exports.EntityAuditTrail = exports.AuditResource = exports.withAuditBatch = exports.snapshotChanges = exports.diffChanges = exports.isRedactedColumn = exports.AUDIT_REDACTED_COLUMNS = exports.AUDIT_EXCLUDED_COLUMNS = exports.withoutInternalId = void 0;
|
|
18
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
18
19
|
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
19
20
|
const errors_1 = require("../errors");
|
|
21
|
+
const tenancy_1 = require("../tenancy");
|
|
20
22
|
const transforms_1 = require("../transforms");
|
|
21
23
|
__exportStar(require("./dto"), exports);
|
|
22
24
|
/**
|
|
@@ -59,8 +61,9 @@ exports.AUDIT_EXCLUDED_COLUMNS = new Set([
|
|
|
59
61
|
* `read_logs`, so a value copied into `changes` is a value published to the whole organization -
|
|
60
62
|
* and these are either provider credentials and identifiers (the `paystack_*` customer and
|
|
61
63
|
* subscription codes, card metadata, tokens, an idempotency key that would let a replay be
|
|
62
|
-
* forged) or content the base table deliberately withholds
|
|
63
|
-
* `notifications.rendered_content`, so the rendered email must
|
|
64
|
+
* forged, an api credential's secret hash) or content the base table deliberately withholds
|
|
65
|
+
* (`authenticated` has no grant on `notifications.rendered_content`, so the rendered email must
|
|
66
|
+
* not arrive by the back door).
|
|
64
67
|
* The column still shows up in the trail - that it changed is the point - with both sides
|
|
65
68
|
* replaced by `'[redacted]'`.
|
|
66
69
|
*/
|
|
@@ -75,6 +78,7 @@ exports.AUDIT_REDACTED_COLUMNS = new Set([
|
|
|
75
78
|
'access_token',
|
|
76
79
|
'refresh_token',
|
|
77
80
|
'password',
|
|
81
|
+
'secret_hash',
|
|
78
82
|
]);
|
|
79
83
|
/** True for a named redacted column, and for every provider-issued `paystack_*` identifier. */
|
|
80
84
|
const isRedactedColumn = (name) => exports.AUDIT_REDACTED_COLUMNS.has(name) || name.startsWith('paystack_');
|
|
@@ -121,6 +125,34 @@ const snapshotChanges = (row, side) => {
|
|
|
121
125
|
return changes;
|
|
122
126
|
};
|
|
123
127
|
exports.snapshotChanges = snapshotChanges;
|
|
128
|
+
/**
|
|
129
|
+
* Where each table's readable name comes from (`select`, `name`) and which columns a history
|
|
130
|
+
* search matches it by (`search`). People are read through `user_profiles`, which exposes
|
|
131
|
+
* nothing beyond the name; the view carries no `organization_id`, but it runs as the invoker,
|
|
132
|
+
* so the users RLS keeps it to the caller's tenant.
|
|
133
|
+
*/
|
|
134
|
+
const NAME_SOURCES = {
|
|
135
|
+
users: {
|
|
136
|
+
from: 'user_profiles',
|
|
137
|
+
select: 'mms_id, name, surname',
|
|
138
|
+
search: ['name', 'surname'],
|
|
139
|
+
scoped: false,
|
|
140
|
+
name: (row) => [row.name, row.surname].filter(Boolean).join(' '),
|
|
141
|
+
},
|
|
142
|
+
statuses: { from: 'statuses', select: 'mms_id, label', search: ['label'], scoped: true, name: (row) => String(row.label) },
|
|
143
|
+
clients: { from: 'clients', select: 'mms_id, name', search: ['name'], scoped: true, name: (row) => String(row.name) },
|
|
144
|
+
projects: { from: 'projects', select: 'mms_id, name', search: ['name'], scoped: true, name: (row) => String(row.name) },
|
|
145
|
+
tasks: {
|
|
146
|
+
from: 'tasks',
|
|
147
|
+
select: 'mms_id, task_id, name',
|
|
148
|
+
search: ['name'],
|
|
149
|
+
scoped: true,
|
|
150
|
+
name: (row) => `TASK-${row.task_id} ${row.name}`,
|
|
151
|
+
},
|
|
152
|
+
leave_types: { from: 'leave_types', select: 'mms_id, name', search: ['name'], scoped: true, name: (row) => String(row.name) },
|
|
153
|
+
};
|
|
154
|
+
// How many matching names per table a history search widens itself by.
|
|
155
|
+
const SEARCH_NAME_LIMIT = 20;
|
|
124
156
|
const AUDIT_TRAIL_SELECT = `
|
|
125
157
|
mms_id,
|
|
126
158
|
created_at,
|
|
@@ -132,9 +164,62 @@ const AUDIT_TRAIL_SELECT = `
|
|
|
132
164
|
related_entity_id,
|
|
133
165
|
changes
|
|
134
166
|
`;
|
|
167
|
+
/** The batch the current event records into, when there is one - see `withAuditBatch`. */
|
|
168
|
+
const auditBatch = new node_async_hooks_1.AsyncLocalStorage();
|
|
169
|
+
/**
|
|
170
|
+
* Runs `work` as one event: every audit entry recorded inside it - by any client or service
|
|
171
|
+
* scope - is held, then written once it settles (or throws, since its writes still happened)
|
|
172
|
+
* as **one row per record**. The api wraps each request in it, so a save that writes a task and
|
|
173
|
+
* its labels, or a webhook that moves an organization twice, logs one row, whatever code wrote it.
|
|
174
|
+
*/
|
|
175
|
+
const withAuditBatch = async (work) => {
|
|
176
|
+
const pending = [];
|
|
177
|
+
try {
|
|
178
|
+
return await auditBatch.run(pending, work);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
// Entries about the same record fold together: each field keeps its first `old` and its
|
|
182
|
+
// last `new`. A record created in the event stays created; otherwise a lifecycle move
|
|
183
|
+
// (archive, delete, restore ...) says more than an update. The first writer writes the row.
|
|
184
|
+
const merged = new Map();
|
|
185
|
+
for (const { entry, writer } of pending) {
|
|
186
|
+
const key = `${entry.entityType}:${entry.entityId}`;
|
|
187
|
+
const earlier = merged.get(key);
|
|
188
|
+
if (!earlier) {
|
|
189
|
+
merged.set(key, { entry: { ...entry, changes: { ...entry.changes } }, writer });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const [field, change] of Object.entries(entry.changes)) {
|
|
193
|
+
const first = earlier.entry.changes[field];
|
|
194
|
+
earlier.entry.changes[field] = { old: first ? first.old : change.old, new: change.new };
|
|
195
|
+
}
|
|
196
|
+
if (earlier.entry.action !== enum_1.AuditTrailAction.INSERT && entry.action !== enum_1.AuditTrailAction.UPDATE) {
|
|
197
|
+
earlier.entry.action = entry.action;
|
|
198
|
+
}
|
|
199
|
+
earlier.entry.relatedEntityType ??= entry.relatedEntityType;
|
|
200
|
+
earlier.entry.relatedEntityId ??= entry.relatedEntityId;
|
|
201
|
+
}
|
|
202
|
+
const byWriter = new Map();
|
|
203
|
+
for (const { entry, writer } of merged.values()) {
|
|
204
|
+
// A field that ended where it started is no change (a redacted one reads the same on both
|
|
205
|
+
// sides by design, so it stays).
|
|
206
|
+
for (const [field, change] of Object.entries(entry.changes)) {
|
|
207
|
+
if (change.old !== REDACTED && JSON.stringify(change.old) === JSON.stringify(change.new)) {
|
|
208
|
+
delete entry.changes[field];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Updates that cancelled out changed nothing: no row.
|
|
212
|
+
if (entry.action === enum_1.AuditTrailAction.UPDATE && Object.keys(entry.changes).length === 0)
|
|
213
|
+
continue;
|
|
214
|
+
byWriter.set(writer, [...(byWriter.get(writer) ?? []), entry]);
|
|
215
|
+
}
|
|
216
|
+
await Promise.all([...byWriter].map(([writer, entries]) => writer.write(entries)));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
exports.withAuditBatch = withAuditBatch;
|
|
135
220
|
/**
|
|
136
|
-
* The `audit_logs`
|
|
137
|
-
*
|
|
221
|
+
* The `audit_logs` writer. Every audited write calls `record` for itself - no feature ever
|
|
222
|
+
* writes an audit line by hand. Reads are per record, through `EntityAuditTrail`.
|
|
138
223
|
*/
|
|
139
224
|
class AuditResource {
|
|
140
225
|
supabase;
|
|
@@ -146,14 +231,27 @@ class AuditResource {
|
|
|
146
231
|
this.onError = onError;
|
|
147
232
|
}
|
|
148
233
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* (default `console.error`) instead.
|
|
234
|
+
* Records the entries: held by the current event's batch when there is one (`withAuditBatch`),
|
|
235
|
+
* written straight away when there is not.
|
|
152
236
|
*/
|
|
153
237
|
async record(entry) {
|
|
154
238
|
const entries = Array.isArray(entry) ? entry : [entry];
|
|
155
239
|
if (entries.length === 0)
|
|
156
240
|
return;
|
|
241
|
+
const batch = auditBatch.getStore();
|
|
242
|
+
if (batch) {
|
|
243
|
+
batch.push(...entries.map((item) => ({ entry: item, writer: this })));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await this.write(entries);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Inserts the audit row(s) - one insert, however many entries. **Never throws**: a logging
|
|
250
|
+
* problem must not fail the business write, so a failure goes to `onAuditError`
|
|
251
|
+
* (default `console.error`) instead. Called by `record`, and by `withAuditBatch` when it
|
|
252
|
+
* writes what it held.
|
|
253
|
+
*/
|
|
254
|
+
async write(entries) {
|
|
157
255
|
try {
|
|
158
256
|
const { error } = await this.supabase.from('audit_logs').insert(entries.map((item) => ({
|
|
159
257
|
created_by: this.actor.mms_id,
|
|
@@ -173,43 +271,103 @@ class AuditResource {
|
|
|
173
271
|
handle(error, entries);
|
|
174
272
|
}
|
|
175
273
|
}
|
|
176
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Readable names for the ids a page of history holds (`status_id`, `assigned_to_id`, ...),
|
|
276
|
+
* keyed by id: one query per table asked about. An id with no row - the record was hard
|
|
277
|
+
* deleted - is simply absent, and the caller says so.
|
|
278
|
+
*/
|
|
279
|
+
async names(ids) {
|
|
280
|
+
const lookups = Object.entries(ids).filter(([, list]) => list.length > 0);
|
|
281
|
+
const pages = await Promise.all(lookups.map(async ([table, list]) => {
|
|
282
|
+
const source = NAME_SOURCES[table];
|
|
283
|
+
// `eq`/`in` return `this`, so the org filter can be reassigned onto the builder.
|
|
284
|
+
let query = this.supabase.from(source.from).select(source.select).in('mms_id', [...new Set(list)]);
|
|
285
|
+
if (source.scoped)
|
|
286
|
+
query = (0, tenancy_1.scopeToOrganization)(query, this.actor);
|
|
287
|
+
const { data, error } = await query;
|
|
288
|
+
if (error)
|
|
289
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
290
|
+
return (data ?? []).map((row) => [String(row.mms_id), source.name(row)]);
|
|
291
|
+
}));
|
|
292
|
+
return Object.fromEntries(pages.flat());
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
exports.AuditResource = AuditResource;
|
|
296
|
+
/**
|
|
297
|
+
* One record's history: the rows about the record itself and every row that hangs off it (a
|
|
298
|
+
* note, a file, a timesheet entry, a user's permissions), newest first. Each resource with a
|
|
299
|
+
* record page exposes one as `audit`, so a client's history is `supabase.clients.audit.list(id)`.
|
|
300
|
+
*/
|
|
301
|
+
class EntityAuditTrail {
|
|
302
|
+
supabase;
|
|
303
|
+
actor;
|
|
304
|
+
entityType;
|
|
305
|
+
constructor(supabase, actor, entityType) {
|
|
306
|
+
this.supabase = supabase;
|
|
307
|
+
this.actor = actor;
|
|
308
|
+
this.entityType = entityType;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* A page of the history. `entity_type` keeps the entries about one kind of record (the record
|
|
312
|
+
* itself, or its notes ...). `search` matches what a change recorded - the stored text, field
|
|
313
|
+
* names included - and, because a foreign key is stored as an id, also any change holding the
|
|
314
|
+
* id of a person, status, client, project, task or leave type whose name matches, and any
|
|
315
|
+
* change made by a person whose name matches.
|
|
316
|
+
*/
|
|
317
|
+
async list(entityId, query) {
|
|
177
318
|
const page = query.page;
|
|
178
319
|
const pageSize = query.page_size;
|
|
179
320
|
const offset = (page - 1) * pageSize;
|
|
180
|
-
// `
|
|
181
|
-
//
|
|
182
|
-
let
|
|
321
|
+
// `entityId` is interpolated into the filter: callers pass a uuid their route DTO has
|
|
322
|
+
// already validated. The two halves are served by the by-entity and by-related indexes.
|
|
323
|
+
let filter = `or(and(entity_type.eq.${this.entityType},entity_id.eq.${entityId}),` +
|
|
324
|
+
`and(related_entity_type.eq.${this.entityType},related_entity_id.eq.${entityId}))`;
|
|
325
|
+
const search = query.search?.trim();
|
|
326
|
+
if (search) {
|
|
327
|
+
const named = await Promise.all(Object.entries(NAME_SOURCES).map(async ([table, source]) => {
|
|
328
|
+
// `or`/`eq` return `this`, so the org filter can be reassigned onto the builder.
|
|
329
|
+
let lookup = this.supabase
|
|
330
|
+
.from(source.from)
|
|
331
|
+
.select('mms_id')
|
|
332
|
+
.or((0, transforms_1.orIlike)(source.search, search))
|
|
333
|
+
.limit(SEARCH_NAME_LIMIT);
|
|
334
|
+
if (source.scoped)
|
|
335
|
+
lookup = (0, tenancy_1.scopeToOrganization)(lookup, this.actor);
|
|
336
|
+
const { data: rows, error: lookupError } = await lookup;
|
|
337
|
+
if (lookupError)
|
|
338
|
+
throw (0, errors_1.mapPostgrestError)(lookupError);
|
|
339
|
+
return { table, ids: (rows ?? []).map((row) => row.mms_id) };
|
|
340
|
+
}));
|
|
341
|
+
const people = named.find((match) => match.table === 'users')?.ids ?? [];
|
|
342
|
+
const matches = [
|
|
343
|
+
`search_text.ilike.${(0, transforms_1.ilikeContains)(search)}`,
|
|
344
|
+
...named.flatMap((match) => match.ids.map((id) => `search_text.ilike.${(0, transforms_1.ilikeContains)(id)}`)),
|
|
345
|
+
...(people.length > 0 ? [`created_by.in.(${people.join(',')})`] : []),
|
|
346
|
+
];
|
|
347
|
+
filter = `and(${filter},or(${matches.join(',')}))`;
|
|
348
|
+
}
|
|
349
|
+
// `eq` returns `this`, so the type filter can be reassigned onto the builder.
|
|
350
|
+
let historyQuery = (0, tenancy_1.scopeToOrganization)(this.supabase
|
|
183
351
|
.from('audit_logs')
|
|
184
|
-
|
|
185
|
-
|
|
352
|
+
// Exact, not estimated: one record's history is small and index-backed, and the pager
|
|
353
|
+
// must not offer a page that is not there.
|
|
354
|
+
.select(AUDIT_TRAIL_SELECT, { count: 'exact' }), this.actor)
|
|
355
|
+
// One element, so the record and the search can nest as `and(or(...),or(...))`.
|
|
356
|
+
.or(filter);
|
|
357
|
+
if (query.entity_type)
|
|
358
|
+
historyQuery = historyQuery.eq('entity_type', query.entity_type);
|
|
359
|
+
const { data, error, count } = await historyQuery
|
|
186
360
|
.order('created_at', { ascending: false })
|
|
361
|
+
// A multi-row insert stamps every row with the same `created_at`; the time-ordered v7
|
|
362
|
+
// `mms_id` keeps the pages stable.
|
|
363
|
+
.order('mms_id', { ascending: false })
|
|
187
364
|
.range(offset, offset + pageSize - 1);
|
|
188
|
-
if (query.created_after) {
|
|
189
|
-
auditTrailQuery = auditTrailQuery.gte('created_at', query.created_after);
|
|
190
|
-
}
|
|
191
|
-
if (query.created_before) {
|
|
192
|
-
auditTrailQuery = auditTrailQuery.lte('created_at', query.created_before);
|
|
193
|
-
}
|
|
194
|
-
if (query.created_by) {
|
|
195
|
-
auditTrailQuery = auditTrailQuery.eq('created_by', query.created_by);
|
|
196
|
-
}
|
|
197
|
-
if (query.action) {
|
|
198
|
-
auditTrailQuery = auditTrailQuery.eq('action', query.action);
|
|
199
|
-
}
|
|
200
|
-
if (query.entity_type) {
|
|
201
|
-
auditTrailQuery = auditTrailQuery.eq('entity_type', query.entity_type);
|
|
202
|
-
}
|
|
203
|
-
if (query.entity_id) {
|
|
204
|
-
auditTrailQuery = auditTrailQuery.eq('entity_id', query.entity_id);
|
|
205
|
-
}
|
|
206
|
-
const { data, error, count } = await auditTrailQuery;
|
|
207
365
|
if (error)
|
|
208
366
|
throw (0, errors_1.mapPostgrestError)(error);
|
|
209
367
|
return (0, transforms_1.toMmsList)(data, page, pageSize, count);
|
|
210
368
|
}
|
|
211
369
|
}
|
|
212
|
-
exports.
|
|
370
|
+
exports.EntityAuditTrail = EntityAuditTrail;
|
|
213
371
|
/** The keyed update every audited write builds, before any caller-supplied guard. */
|
|
214
372
|
const buildUpdate = (supabase, table, id, payload, organizationId) => {
|
|
215
373
|
// `eq` returns `this`, so the org filter can be reassigned onto the builder without a cast.
|
|
@@ -225,32 +383,49 @@ const buildUpdate = (supabase, table, id, payload, organizationId) => {
|
|
|
225
383
|
*/
|
|
226
384
|
const auditedTable = (supabase, actor, audit, options) => {
|
|
227
385
|
const scoped = options.scopeByOrganization !== false;
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
386
|
+
// Null for staff too, who never get this far: every write below refuses them first.
|
|
387
|
+
const organizationFilter = scoped ? actor.organization_id : null;
|
|
388
|
+
// The record a row belongs to: the declared foreign key, or the owner the polymorphic tables
|
|
389
|
+
// (notes, additional details, supporting files) carry in `entity_type`/`entity_id`. A row with
|
|
390
|
+
// a type and no id (statuses and labels name the kind of record they apply to) has no owner.
|
|
391
|
+
const relatedOf = (row) => {
|
|
392
|
+
const { related } = options;
|
|
393
|
+
const relatedEntityId = row[related?.column ?? 'entity_id'] ?? null;
|
|
394
|
+
if (!relatedEntityId)
|
|
395
|
+
return { relatedEntityType: null, relatedEntityId: null };
|
|
396
|
+
return {
|
|
397
|
+
relatedEntityType: related?.entityType ?? row.entity_type,
|
|
398
|
+
relatedEntityId,
|
|
399
|
+
};
|
|
400
|
+
};
|
|
234
401
|
const read = async (id) => {
|
|
235
402
|
// `eq` returns `this`, so the org filter can be reassigned onto the builder without a cast.
|
|
236
403
|
let query = supabase.from(options.table).select('*').eq('mms_id', id);
|
|
237
|
-
if (
|
|
238
|
-
query = query.eq('organization_id',
|
|
404
|
+
if (organizationFilter !== null)
|
|
405
|
+
query = query.eq('organization_id', organizationFilter);
|
|
239
406
|
const { data, error } = await query.single();
|
|
240
407
|
if (error)
|
|
241
408
|
throw (0, errors_1.mapPostgrestError)(error);
|
|
242
409
|
return data;
|
|
243
410
|
};
|
|
244
|
-
const organizationFilter = scoped ? actor.organization_id : null;
|
|
245
411
|
const updateOf = (id, before, payload) => buildUpdate(supabase, options.table, id, typeof payload === 'function' ? payload(before) : payload, organizationFilter);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
changes
|
|
251
|
-
|
|
252
|
-
|
|
412
|
+
// The changes with the table's untracked columns taken out.
|
|
413
|
+
const tracked = (changes) => Object.fromEntries(Object.entries(changes).filter(([column]) => !options.untracked?.includes(column)));
|
|
414
|
+
// An update that changed nothing tracked is not an event: no row.
|
|
415
|
+
const recordWrite = async (id, action, before, after) => {
|
|
416
|
+
const changes = tracked((0, exports.diffChanges)(before, after));
|
|
417
|
+
if (action === enum_1.AuditTrailAction.UPDATE && Object.keys(changes).length === 0)
|
|
418
|
+
return;
|
|
419
|
+
await audit.record({
|
|
420
|
+
action,
|
|
421
|
+
entityType: options.entityType,
|
|
422
|
+
entityId: id,
|
|
423
|
+
changes,
|
|
424
|
+
...relatedOf(before),
|
|
425
|
+
});
|
|
426
|
+
};
|
|
253
427
|
const write = async (id, action, payload) => {
|
|
428
|
+
(0, tenancy_1.requireOrganization)(actor);
|
|
254
429
|
const before = await read(id);
|
|
255
430
|
const { data, error } = await updateOf(id, before, payload).select('*').single();
|
|
256
431
|
if (error)
|
|
@@ -262,6 +437,7 @@ const auditedTable = (supabase, actor, audit, options) => {
|
|
|
262
437
|
return (0, exports.withoutInternalId)(after);
|
|
263
438
|
};
|
|
264
439
|
const writeWhere = async (id, guard, payload) => {
|
|
440
|
+
(0, tenancy_1.requireOrganization)(actor);
|
|
265
441
|
const before = await read(id);
|
|
266
442
|
// `maybeSingle`, not `single`: matching nothing is the guard doing its job, not an error.
|
|
267
443
|
const { data, error } = await guard(updateOf(id, before, payload))
|
|
@@ -278,6 +454,7 @@ const auditedTable = (supabase, actor, audit, options) => {
|
|
|
278
454
|
};
|
|
279
455
|
return {
|
|
280
456
|
async insert(payload) {
|
|
457
|
+
(0, tenancy_1.requireOrganization)(actor);
|
|
281
458
|
const { data, error } = await supabase
|
|
282
459
|
.from(options.table)
|
|
283
460
|
.insert(payload)
|
|
@@ -290,12 +467,13 @@ const auditedTable = (supabase, actor, audit, options) => {
|
|
|
290
467
|
action: enum_1.AuditTrailAction.INSERT,
|
|
291
468
|
entityType: options.entityType,
|
|
292
469
|
entityId: row.mms_id,
|
|
293
|
-
changes: (0, exports.snapshotChanges)(row, 'new'),
|
|
470
|
+
changes: tracked((0, exports.snapshotChanges)(row, 'new')),
|
|
294
471
|
...relatedOf(row),
|
|
295
472
|
});
|
|
296
473
|
return (0, exports.withoutInternalId)(row);
|
|
297
474
|
},
|
|
298
475
|
async insertMany(payloads) {
|
|
476
|
+
(0, tenancy_1.requireOrganization)(actor);
|
|
299
477
|
if (payloads.length === 0)
|
|
300
478
|
return [];
|
|
301
479
|
const { data, error } = await supabase.from(options.table).insert(payloads).select('*');
|
|
@@ -306,7 +484,7 @@ const auditedTable = (supabase, actor, audit, options) => {
|
|
|
306
484
|
action: enum_1.AuditTrailAction.INSERT,
|
|
307
485
|
entityType: options.entityType,
|
|
308
486
|
entityId: row.mms_id,
|
|
309
|
-
changes: (0, exports.snapshotChanges)(row, 'new'),
|
|
487
|
+
changes: tracked((0, exports.snapshotChanges)(row, 'new')),
|
|
310
488
|
...relatedOf(row),
|
|
311
489
|
})));
|
|
312
490
|
return rows.map(exports.withoutInternalId);
|
|
@@ -321,10 +499,11 @@ const auditedTable = (supabase, actor, audit, options) => {
|
|
|
321
499
|
return write(id, action, payload);
|
|
322
500
|
},
|
|
323
501
|
async remove(id) {
|
|
502
|
+
(0, tenancy_1.requireOrganization)(actor);
|
|
324
503
|
const before = await read(id);
|
|
325
504
|
let query = supabase.from(options.table).delete().eq('mms_id', id);
|
|
326
|
-
if (
|
|
327
|
-
query = query.eq('organization_id',
|
|
505
|
+
if (organizationFilter !== null)
|
|
506
|
+
query = query.eq('organization_id', organizationFilter);
|
|
328
507
|
const { error } = await query;
|
|
329
508
|
if (error)
|
|
330
509
|
throw (0, errors_1.mapPostgrestError)(error);
|
|
@@ -332,7 +511,7 @@ const auditedTable = (supabase, actor, audit, options) => {
|
|
|
332
511
|
action: enum_1.AuditTrailAction.DELETE,
|
|
333
512
|
entityType: options.entityType,
|
|
334
513
|
entityId: id,
|
|
335
|
-
changes: (0, exports.snapshotChanges)(before, 'old'),
|
|
514
|
+
changes: tracked((0, exports.snapshotChanges)(before, 'old')),
|
|
336
515
|
...relatedOf(before),
|
|
337
516
|
});
|
|
338
517
|
return (0, exports.withoutInternalId)(before);
|
|
@@ -31,6 +31,8 @@ export declare class SupabaseAuthClient {
|
|
|
31
31
|
sendPasswordResetEmail(input: SendPasswordResetEmailDto, redirectTo: string): Promise<void>;
|
|
32
32
|
exchangeRecoveryToken(input: ExchangeTokenHashDto): Promise<ExchangeTokenResponse>;
|
|
33
33
|
exchangeConfirmToken(input: ExchangeTokenHashDto): Promise<ExchangeTokenResponse>;
|
|
34
|
+
/** Spends a `mintMagicLink` hash for tokens - how the admin api mints an emulated session. */
|
|
35
|
+
exchangeMagicLinkToken(input: ExchangeTokenHashDto): Promise<ExchangeTokenResponse>;
|
|
34
36
|
resetPassword(input: ResetPasswordDto): Promise<void>;
|
|
35
37
|
/**
|
|
36
38
|
* Stamps `users.last_logged_in` as the user who just signed in - the users UPDATE policy
|
|
@@ -102,6 +102,18 @@ class SupabaseAuthClient {
|
|
|
102
102
|
throw noSession('Token exchange failed: No session data returned');
|
|
103
103
|
return toTokens(data.session);
|
|
104
104
|
}
|
|
105
|
+
/** Spends a `mintMagicLink` hash for tokens - how the admin api mints an emulated session. */
|
|
106
|
+
async exchangeMagicLinkToken(input) {
|
|
107
|
+
const { data, error } = await this.supabase.auth.verifyOtp({
|
|
108
|
+
type: 'magiclink',
|
|
109
|
+
token_hash: input.token_hash,
|
|
110
|
+
});
|
|
111
|
+
if (error)
|
|
112
|
+
throw (0, errors_1.mapAuthError)(error);
|
|
113
|
+
if (!data.session)
|
|
114
|
+
throw noSession('Token exchange failed: No session data returned');
|
|
115
|
+
return toTokens(data.session);
|
|
116
|
+
}
|
|
105
117
|
async resetPassword(input) {
|
|
106
118
|
const { error: sessionError } = await this.supabase.auth.setSession({
|
|
107
119
|
access_token: input.access_token,
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SupabaseClient as SupabaseJsClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { StaffEntity } from '@managemint-solutions/entities/staff';
|
|
2
3
|
/**
|
|
3
4
|
* A raw supabase-js client, built the one way the SDK ever needs one: server-side and stateless -
|
|
4
5
|
* nothing is stored, refreshed or read out of a URL - carrying the caller's bearer token when
|
|
@@ -10,6 +11,18 @@ export type AuthContext = {
|
|
|
10
11
|
mms_id: string;
|
|
11
12
|
organization_id: string;
|
|
12
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* A ManageMint Solutions staff member: no tenant of their own, so reads span every organization
|
|
16
|
+
* (the `staff` role's policies decide what they see) and every write is refused.
|
|
17
|
+
*/
|
|
18
|
+
export type StaffContext = {
|
|
19
|
+
mms_id: string;
|
|
20
|
+
organization_id: null;
|
|
21
|
+
};
|
|
22
|
+
/** Who a resource the tenant and staff clients share runs as: a tenant user or staff. */
|
|
23
|
+
export type ClientContext = AuthContext | StaffContext;
|
|
24
|
+
/** What a verified staff token resolves to: the GoTrue subject plus their `staff` row. */
|
|
25
|
+
export type VerifiedStaff = Pick<StaffEntity, 'mms_id' | 'email' | 'name'>;
|
|
13
26
|
/**
|
|
14
27
|
* The billing state of the caller's organization, read in the same query as their tenancy so the
|
|
15
28
|
* api's payment and module gates cost no extra round trip. `billing_status` is a
|
|
@@ -27,6 +40,8 @@ export type VerifiedAuth = AuthContext & {
|
|
|
27
40
|
name: string | null;
|
|
28
41
|
surname: string | null;
|
|
29
42
|
organization: VerifiedOrganization;
|
|
43
|
+
/** True when the token was minted through an emulation handshake; false for the user's own session. */
|
|
44
|
+
emulated: boolean;
|
|
30
45
|
};
|
|
31
46
|
export type VerifyAccessTokenConfig = {
|
|
32
47
|
url: string;
|
|
@@ -50,3 +65,9 @@ export type VerifyAccessTokenConfig = {
|
|
|
50
65
|
* The token itself is never logged: a failure says only that it was rejected.
|
|
51
66
|
*/
|
|
52
67
|
export declare const verifyAccessToken: ({ url, key, accessToken, client, }: VerifyAccessTokenConfig) => Promise<VerifiedAuth>;
|
|
68
|
+
/**
|
|
69
|
+
* `verifyAccessToken` for a staff session: the same signature check, then the `staff` role
|
|
70
|
+
* (`auth.users.role`, set by `createStaffAuthUser`), then the `staff` row, read under the staff
|
|
71
|
+
* member's own RLS.
|
|
72
|
+
*/
|
|
73
|
+
export declare const verifyStaffAccessToken: ({ url, key, accessToken, client, }: VerifyAccessTokenConfig) => Promise<VerifiedStaff>;
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.verifyAccessToken = exports.createRawClient = void 0;
|
|
3
|
+
exports.verifyStaffAccessToken = exports.verifyAccessToken = exports.createRawClient = void 0;
|
|
4
4
|
const supabase_js_1 = require("@supabase/supabase-js");
|
|
5
5
|
const errors_1 = require("./errors");
|
|
6
6
|
/**
|
|
@@ -32,8 +32,10 @@ const verifyAccessToken = async ({ url, key, accessToken, client = (0, exports.c
|
|
|
32
32
|
// Verifies the signature locally against the project's JWKS for asymmetric keys, and against
|
|
33
33
|
// the auth server for HS256 projects. Either way the token is checked, not merely decoded.
|
|
34
34
|
const { data, error } = await client.auth.getClaims(accessToken);
|
|
35
|
-
const
|
|
36
|
-
|
|
35
|
+
const claims = data?.claims;
|
|
36
|
+
const mms_id = claims?.sub;
|
|
37
|
+
// A staff token is signed by the same project; its role keeps it off every tenant route.
|
|
38
|
+
if (error || typeof mms_id !== 'string' || mms_id === '' || claims?.role !== 'authenticated') {
|
|
37
39
|
throw authError('Invalid or expired token');
|
|
38
40
|
}
|
|
39
41
|
const { data: profileData, error: profileError } = await client
|
|
@@ -70,6 +72,48 @@ const verifyAccessToken = async ({ url, key, accessToken, client = (0, exports.c
|
|
|
70
72
|
access_expires_at: organization.access_expires_at ?? null,
|
|
71
73
|
modules: organization.modules ?? [],
|
|
72
74
|
},
|
|
75
|
+
// Stamped by the custom access token hook, inside the signature - a user cannot add it to
|
|
76
|
+
// their own session.
|
|
77
|
+
emulated: claims.emulated === true,
|
|
73
78
|
};
|
|
74
79
|
};
|
|
75
80
|
exports.verifyAccessToken = verifyAccessToken;
|
|
81
|
+
/**
|
|
82
|
+
* `verifyAccessToken` for a staff session: the same signature check, then the `staff` role
|
|
83
|
+
* (`auth.users.role`, set by `createStaffAuthUser`), then the `staff` row, read under the staff
|
|
84
|
+
* member's own RLS.
|
|
85
|
+
*/
|
|
86
|
+
const verifyStaffAccessToken = async ({ url, key, accessToken, client = (0, exports.createRawClient)(url, key, accessToken), }) => {
|
|
87
|
+
const { data, error } = await client.auth.getClaims(accessToken);
|
|
88
|
+
const claims = data?.claims;
|
|
89
|
+
const mms_id = claims?.sub;
|
|
90
|
+
if (error || typeof mms_id !== 'string' || mms_id === '') {
|
|
91
|
+
throw authError('Invalid or expired token');
|
|
92
|
+
}
|
|
93
|
+
if (claims?.role !== 'staff') {
|
|
94
|
+
throw new errors_1.SupabaseClientError({
|
|
95
|
+
status: 403,
|
|
96
|
+
error: 'Authentication Error',
|
|
97
|
+
message: 'Not a staff session',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const { data: staffData, error: staffError } = await client
|
|
101
|
+
.from('staff')
|
|
102
|
+
.select('email, name, active')
|
|
103
|
+
.eq('mms_id', mms_id)
|
|
104
|
+
.maybeSingle();
|
|
105
|
+
if (staffError)
|
|
106
|
+
throw (0, errors_1.mapPostgrestError)(staffError);
|
|
107
|
+
const staff = staffData;
|
|
108
|
+
if (!staff)
|
|
109
|
+
throw authError('No staff profile exists for this account');
|
|
110
|
+
if (staff.active === false) {
|
|
111
|
+
throw new errors_1.SupabaseClientError({
|
|
112
|
+
status: 403,
|
|
113
|
+
error: 'Authentication Error',
|
|
114
|
+
message: 'Your staff account has been deactivated',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return { mms_id: mms_id, email: staff.email, name: staff.name };
|
|
118
|
+
};
|
|
119
|
+
exports.verifyStaffAccessToken = verifyStaffAccessToken;
|
package/dist/clients/dto.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { UUID } from 'crypto';
|
|
2
|
-
import type { ClientIdDto as ClientIdDtoType, CreateClientDto as CreateClientDtoType, GetClientsDto as GetClientsDtoType, UpdateClientDto as UpdateClientDtoType } from '@managemint-solutions/entities/clients/dto';
|
|
2
|
+
import type { ClientIdDto as ClientIdDtoType, CreateClientDto as CreateClientDtoType, GetClientsDto as GetClientsDtoType, StaffGetClientsDto as StaffGetClientsDtoType, UpdateClientDto as UpdateClientDtoType } from '@managemint-solutions/entities/clients/dto';
|
|
3
3
|
export declare class GetClientsDto implements Omit<GetClientsDtoType, 'status_id_in'> {
|
|
4
4
|
readonly search?: string | null;
|
|
5
5
|
readonly page: number;
|
|
@@ -9,6 +9,10 @@ export declare class GetClientsDto implements Omit<GetClientsDtoType, 'status_id
|
|
|
9
9
|
readonly status_id_in?: string[] | null;
|
|
10
10
|
readonly archive: boolean;
|
|
11
11
|
}
|
|
12
|
+
/** A staff read across organizations, optionally narrowed to one of them. */
|
|
13
|
+
export declare class StaffGetClientsDto extends GetClientsDto implements Omit<StaffGetClientsDtoType, 'status_id_in'> {
|
|
14
|
+
readonly organization_id?: string | null;
|
|
15
|
+
}
|
|
12
16
|
export declare class ClientIdDto implements ClientIdDtoType {
|
|
13
17
|
readonly clientId: string;
|
|
14
18
|
}
|
package/dist/clients/dto.js
CHANGED
|
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.UpdateClientDto = exports.CreateClientDto = exports.ClientIdDto = exports.GetClientsDto = void 0;
|
|
12
|
+
exports.UpdateClientDto = exports.CreateClientDto = exports.ClientIdDto = exports.StaffGetClientsDto = exports.GetClientsDto = void 0;
|
|
13
13
|
const class_validator_1 = require("class-validator");
|
|
14
14
|
const class_transformer_1 = require("class-transformer");
|
|
15
15
|
const validators_1 = require("../validators");
|
|
@@ -78,6 +78,17 @@ __decorate([
|
|
|
78
78
|
(0, class_validator_1.IsBoolean)({ message: 'Include archived must be a boolean' }),
|
|
79
79
|
__metadata("design:type", Boolean)
|
|
80
80
|
], GetClientsDto.prototype, "archive", void 0);
|
|
81
|
+
/** A staff read across organizations, optionally narrowed to one of them. */
|
|
82
|
+
class StaffGetClientsDto extends GetClientsDto {
|
|
83
|
+
organization_id;
|
|
84
|
+
}
|
|
85
|
+
exports.StaffGetClientsDto = StaffGetClientsDto;
|
|
86
|
+
__decorate([
|
|
87
|
+
(0, class_validator_1.IsOptional)(),
|
|
88
|
+
(0, validators_1.IsNullable)(),
|
|
89
|
+
(0, class_validator_1.IsUUID)('7', { message: 'Invalid organization ID format' }),
|
|
90
|
+
__metadata("design:type", Object)
|
|
91
|
+
], StaffGetClientsDto.prototype, "organization_id", void 0);
|
|
81
92
|
class ClientIdDto {
|
|
82
93
|
clientId;
|
|
83
94
|
}
|
package/dist/clients/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ClientEntity, ClientSummary } from '@managemint-solutions/entities/clients';
|
|
2
2
|
import type { MmsList } from '@managemint-solutions/entities/common';
|
|
3
|
-
import type {
|
|
4
|
-
import { type AuditResource } from '../audit';
|
|
3
|
+
import type { ClientContext } from '../auth';
|
|
4
|
+
import { EntityAuditTrail, type AuditResource } from '../audit';
|
|
5
5
|
import type { TypedSupabaseClient } from '../client';
|
|
6
6
|
import type { CreateClientDto, GetClientsDto, UpdateClientDto } from './dto';
|
|
7
7
|
export * from './dto';
|
|
@@ -9,8 +9,12 @@ export declare class ClientsResource {
|
|
|
9
9
|
private readonly supabase;
|
|
10
10
|
private readonly auth;
|
|
11
11
|
private readonly table;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** This record's history - its own rows and the rows that hang off it. */
|
|
13
|
+
readonly audit: EntityAuditTrail;
|
|
14
|
+
constructor(supabase: TypedSupabaseClient, auth: ClientContext, audit: AuditResource);
|
|
15
|
+
list(query: GetClientsDto & {
|
|
16
|
+
organization_id?: string | null;
|
|
17
|
+
}): Promise<MmsList<ClientSummary>>;
|
|
14
18
|
getById(clientId: string): Promise<ClientEntity>;
|
|
15
19
|
create(input: CreateClientDto): Promise<ClientEntity>;
|
|
16
20
|
update(clientId: string, input: UpdateClientDto): Promise<ClientEntity>;
|