@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.
Files changed (72) hide show
  1. package/README.md +92 -13
  2. package/dist/additional-details/index.d.ts +2 -2
  3. package/dist/additional-details/index.js +6 -10
  4. package/dist/audit/dto.d.ts +3 -8
  5. package/dist/audit/dto.js +7 -48
  6. package/dist/audit/index.d.ts +71 -10
  7. package/dist/audit/index.js +234 -55
  8. package/dist/auth-client/index.d.ts +2 -0
  9. package/dist/auth-client/index.js +12 -0
  10. package/dist/auth.d.ts +21 -0
  11. package/dist/auth.js +47 -3
  12. package/dist/clients/dto.d.ts +5 -1
  13. package/dist/clients/dto.js +12 -1
  14. package/dist/clients/index.d.ts +8 -4
  15. package/dist/clients/index.js +11 -8
  16. package/dist/emulation-handshakes/index.d.ts +13 -0
  17. package/dist/emulation-handshakes/index.js +36 -0
  18. package/dist/errors.js +2 -2
  19. package/dist/index.d.ts +14 -10
  20. package/dist/index.js +23 -4
  21. package/dist/leads/dto.d.ts +5 -1
  22. package/dist/leads/dto.js +12 -1
  23. package/dist/leads/index.d.ts +8 -5
  24. package/dist/leads/index.js +23 -21
  25. package/dist/leave/leave-adjustments.js +1 -0
  26. package/dist/leave/leave-entitlements.d.ts +3 -1
  27. package/dist/leave/leave-entitlements.js +3 -0
  28. package/dist/leave/leave-requests.d.ts +3 -1
  29. package/dist/leave/leave-requests.js +3 -0
  30. package/dist/nest/index.d.ts +26 -1
  31. package/dist/nest/index.js +58 -1
  32. package/dist/notifications/index.d.ts +1 -3
  33. package/dist/organization-payment-methods/index.d.ts +5 -6
  34. package/dist/organization-payment-methods/index.js +4 -17
  35. package/dist/organizations/dto.d.ts +9 -1
  36. package/dist/organizations/dto.js +40 -1
  37. package/dist/organizations/index.d.ts +13 -3
  38. package/dist/organizations/index.js +59 -3
  39. package/dist/permissions/index.d.ts +6 -4
  40. package/dist/permissions/index.js +19 -12
  41. package/dist/projects/dto.d.ts +5 -1
  42. package/dist/projects/dto.js +12 -1
  43. package/dist/projects/index.d.ts +8 -4
  44. package/dist/projects/index.js +11 -8
  45. package/dist/service-client/index.d.ts +39 -1
  46. package/dist/service-client/index.js +89 -2
  47. package/dist/staff/dto.d.ts +14 -0
  48. package/dist/staff/dto.js +63 -0
  49. package/dist/staff/index.d.ts +50 -0
  50. package/dist/staff/index.js +139 -0
  51. package/dist/staff-client.d.ts +37 -0
  52. package/dist/staff-client.js +52 -0
  53. package/dist/tasks/dto.d.ts +5 -1
  54. package/dist/tasks/dto.js +12 -1
  55. package/dist/tasks/index.d.ts +15 -10
  56. package/dist/tasks/index.js +34 -31
  57. package/dist/tenancy.d.ts +13 -0
  58. package/dist/tenancy.js +25 -0
  59. package/dist/timesheets/timesheet-entries.js +1 -0
  60. package/dist/timesheets/timesheets.d.ts +3 -1
  61. package/dist/timesheets/timesheets.js +3 -0
  62. package/dist/user-api-credentials/dto.d.ts +12 -0
  63. package/dist/user-api-credentials/dto.js +56 -0
  64. package/dist/user-api-credentials/index.d.ts +47 -0
  65. package/dist/user-api-credentials/index.js +101 -0
  66. package/dist/users/dto.d.ts +5 -1
  67. package/dist/users/dto.js +13 -2
  68. package/dist/users/index.d.ts +7 -3
  69. package/dist/users/index.js +16 -29
  70. package/dist/validators.d.ts +2 -0
  71. package/dist/validators.js +6 -1
  72. package/package.json +2 -2
@@ -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 (`authenticated` has no grant on
63
- * `notifications.rendered_content`, so the rendered email must not arrive by the back door).
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` table. Reads are the audit trail endpoint; writes go through `record`, which
137
- * every audited write calls for itself - no feature ever writes an audit line by hand.
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
- * Writes the audit row(s) - one insert, however many entries. **Never throws**: a logging
150
- * problem must not fail the business write, so a failure goes to `onAuditError`
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
- async list(query) {
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
- // `order`/`range` return `this`, so the builder stays a filter builder and the conditional
181
- // filters below can be reassigned onto it without a cast.
182
- let auditTrailQuery = this.supabase
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
- .select(AUDIT_TRAIL_SELECT, { count: 'estimated' })
185
- .eq('organization_id', this.actor.organization_id)
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.AuditResource = AuditResource;
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
- // Tables that hang off another entity (notes, additional details, supporting files) carry the
229
- // owner in `entity_type`/`entity_id`; the audit row records it as the related entity.
230
- const relatedOf = (row) => ({
231
- relatedEntityType: row.entity_type ?? null,
232
- relatedEntityId: row.entity_id ?? null,
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 (scoped)
238
- query = query.eq('organization_id', actor.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
- const recordWrite = (id, action, before, after) => audit.record({
247
- action,
248
- entityType: options.entityType,
249
- entityId: id,
250
- changes: (0, exports.diffChanges)(before, after),
251
- ...relatedOf(before),
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 (scoped)
327
- query = query.eq('organization_id', actor.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 mms_id = data?.claims?.sub;
36
- if (error || typeof mms_id !== 'string' || mms_id === '') {
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;
@@ -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
  }
@@ -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
  }
@@ -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 { AuthContext } from '../auth';
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
- constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
- list(query: GetClientsDto): Promise<MmsList<ClientSummary>>;
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>;