@managemint-solutions/sdk 0.53.0 → 0.55.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 (60) hide show
  1. package/README.md +75 -3
  2. package/dist/additional-details/index.d.ts +2 -2
  3. package/dist/additional-details/index.js +6 -10
  4. package/dist/audit/index.d.ts +11 -5
  5. package/dist/audit/index.js +20 -12
  6. package/dist/auth-client/index.d.ts +2 -0
  7. package/dist/auth-client/index.js +12 -0
  8. package/dist/auth.d.ts +21 -0
  9. package/dist/auth.js +47 -3
  10. package/dist/clients/dto.d.ts +5 -1
  11. package/dist/clients/dto.js +12 -1
  12. package/dist/clients/index.d.ts +5 -3
  13. package/dist/clients/index.js +12 -10
  14. package/dist/emulation-handshakes/index.d.ts +16 -0
  15. package/dist/emulation-handshakes/index.js +39 -0
  16. package/dist/errors.js +2 -2
  17. package/dist/index.d.ts +13 -9
  18. package/dist/index.js +22 -4
  19. package/dist/leads/dto.d.ts +5 -1
  20. package/dist/leads/dto.js +12 -1
  21. package/dist/leads/index.d.ts +5 -3
  22. package/dist/leads/index.js +24 -21
  23. package/dist/nest/index.d.ts +26 -1
  24. package/dist/nest/index.js +58 -1
  25. package/dist/notifications/index.d.ts +1 -3
  26. package/dist/organizations/dto.d.ts +9 -1
  27. package/dist/organizations/dto.js +40 -1
  28. package/dist/organizations/index.d.ts +11 -3
  29. package/dist/organizations/index.js +30 -2
  30. package/dist/permissions/index.d.ts +6 -4
  31. package/dist/permissions/index.js +17 -12
  32. package/dist/projects/dto.d.ts +5 -1
  33. package/dist/projects/dto.js +12 -1
  34. package/dist/projects/index.d.ts +5 -3
  35. package/dist/projects/index.js +12 -10
  36. package/dist/service-client/index.d.ts +32 -1
  37. package/dist/service-client/index.js +54 -1
  38. package/dist/staff/dto.d.ts +14 -0
  39. package/dist/staff/dto.js +63 -0
  40. package/dist/staff/index.d.ts +50 -0
  41. package/dist/staff/index.js +139 -0
  42. package/dist/staff-client.d.ts +37 -0
  43. package/dist/staff-client.js +52 -0
  44. package/dist/tasks/dto.d.ts +5 -1
  45. package/dist/tasks/dto.js +12 -1
  46. package/dist/tasks/index.d.ts +5 -3
  47. package/dist/tasks/index.js +18 -19
  48. package/dist/tenancy.d.ts +13 -0
  49. package/dist/tenancy.js +25 -0
  50. package/dist/user-api-credentials/dto.d.ts +12 -0
  51. package/dist/user-api-credentials/dto.js +56 -0
  52. package/dist/user-api-credentials/index.d.ts +47 -0
  53. package/dist/user-api-credentials/index.js +101 -0
  54. package/dist/users/dto.d.ts +5 -1
  55. package/dist/users/dto.js +13 -2
  56. package/dist/users/index.d.ts +5 -3
  57. package/dist/users/index.js +13 -29
  58. package/dist/validators.d.ts +2 -0
  59. package/dist/validators.js +6 -1
  60. package/package.json +2 -2
package/README.md CHANGED
@@ -210,9 +210,10 @@ A task's `task_id` (its per-organization number) is assigned by the database on
210
210
  task carries its embedded `status`, `assigned_to`, `project` and `client`. The detail read also carries its `parent`,
211
211
  `subtasks` and `related` tasks, read from `task_links` in both directions.
212
212
 
213
- The client decodes `{ mms_id, organization_id }` from the JWT itself and scopes every query
214
- by `organization_id`. `supabase.supabase` is the raw `@supabase/supabase-js` client
215
- (`TypedSupabaseClient`) - the escape hatch for queries not yet covered by a resource.
213
+ The client runs as the verified `{ mms_id, organization_id }` it is handed and scopes every
214
+ query by `organization_id`. Staff have a client of their own (see Staff below).
215
+ `supabase.supabase` is the raw `@supabase/supabase-js` client (`TypedSupabaseClient`) - the
216
+ escape hatch for queries not yet covered by a resource.
216
217
 
217
218
  ## Audit
218
219
 
@@ -344,6 +345,77 @@ export class AuthController {
344
345
  }
345
346
  ```
346
347
 
348
+ ## Staff
349
+
350
+ ManageMint Solutions employees sign in to the admin api with a token whose role is `staff`. Their
351
+ client is a `SupabaseStaffClient`, which carries only what the admin api reads: `clients`, `leads`,
352
+ `projects`, `tasks`, `users`, `organizations`, `staff`, `staffPermissions` and
353
+ `userApiCredentials`. A resource not on it is not available to staff.
354
+
355
+ - **Reads span every organization.** The shared resources drop their organization filter and the
356
+ `staff` role's policies decide what comes back. The `clients`, `leads`, `projects`, `tasks` and
357
+ `users` lists embed each row's `organization` (`{ mms_id, name }`) and take an `organization_id`
358
+ filter through the staff query DTOs (`StaffGetClientsDto`, ...); a tenant's request and response
359
+ are unchanged. `organizations.list` pages every organization (`search` over name, trading
360
+ name, email).
361
+ - **Every write is refused** with a `403 Permission Error` before any statement runs, as is the
362
+ tenant-only `organizations.get()`.
363
+
364
+ ```ts
365
+ import { StaffGetClientsDto, type SupabaseStaffClient as StaffClient } from '@managemint-solutions/sdk';
366
+ import { attachSupabaseStaffRequestContext, SupabaseStaffClient } from '@managemint-solutions/sdk/nest';
367
+
368
+ // In the admin api's guard: verifies the token (role `staff`, an active `staff` row) and
369
+ // stores the context under its own key, `request.supabaseStaff`.
370
+ const { staff, client } = await attachSupabaseStaffRequestContext(request);
371
+ const flags = await client.staffPermissions.flagsFor(staff.mms_id, 'read_clients'); // is_admin + the flag
372
+
373
+ // In a controller:
374
+ list(@SupabaseStaffClient() supabase: StaffClient, @Query() query: StaffGetClientsDto) {
375
+ return supabase.clients.list(query); // every organization's, each row with its organization
376
+ }
377
+ ```
378
+
379
+ `verifyAccessToken` refuses any token whose role is not `authenticated`, so a staff token never
380
+ reaches a tenant route.
381
+
382
+ Staff and their flags are written on the service client only - staff hold SELECT alone:
383
+
384
+ ```ts
385
+ await service.staff.preRegister({ email, name, created_by: staffId }); // mms_id null until linked
386
+ try {
387
+ await service.createStaffAuthUser({ email, password }); // role staff: the trigger links the row
388
+ } catch (error) {
389
+ await service.staff.removePending(email);
390
+ throw error;
391
+ }
392
+ await service.staff.setActive(staffId, false, byStaffId); // stamps deactivated_at/by
393
+ await service.setAuthUserBanned(staffId, true); // and ends their sign-ins
394
+ await service.staffPermissions.updateForStaff(staffId, patch, byStaffId); // UpdateStaffPermissionsDto
395
+ ```
396
+
397
+ ### User api credentials and emulation
398
+
399
+ A credential is a client id and secret staff issue for one tenant user. Exchanging it mints that
400
+ user's own token, which the custom access token hook marks `emulated: true` and gives a one-hour
401
+ `exp`; `verifyAccessToken` returns it as `emulated: true` (`false` for a user's own session).
402
+
403
+ The mint itself lives in the admin api. The SDK calls it is made of:
404
+
405
+ - `service.userApiCredentials.findByClientId(clientId)` - the credential with its `secret_hash`,
406
+ or null.
407
+ - `service.emulationHandshakes.open(userId, credentialId)` - a 30-second handshake; returns its id.
408
+ - `service.mintMagicLink(email)`, then `auth.exchangeMagicLinkToken({ token_hash })` - the sign-in
409
+ the hook spends the handshake on.
410
+ - `service.emulationHandshakes.close(id)` - in a `finally`, whatever happened above.
411
+ - `auth.signOut(accessToken)` afterwards, so only the access token outlives the mint.
412
+ - `service.userApiCredentials.markUsed(credentialId)` - stamps `last_used_at`, unaudited.
413
+
414
+ `service.userApiCredentials.create` and `revoke` (null when already revoked) are audited against
415
+ the credential's organization as the system and show in the user's history; `secret_hash` is a
416
+ redacted column. Staff list a user's credentials on their own client
417
+ (`supabase.userApiCredentials.listForUser`), through a column grant that never includes the hash.
418
+
347
419
  ## Billing and invoices
348
420
 
349
421
  Billing is split by who may write. On the caller's client, `supabase.billing` and
@@ -1,7 +1,7 @@
1
1
  import type { AdditionalDetailSummary, AdditionalDetailsEntity } from '@managemint-solutions/entities/additional-details';
2
2
  import type { AddAdditionalDetailsDto, UpdateAdditionalDetailsDto } from '@managemint-solutions/entities/additional-details/dto';
3
3
  import { AdditionalDetailsEntityTypes } from '@managemint-solutions/entities/additional-details/enum';
4
- import type { AuthContext } from '../auth';
4
+ import type { ClientContext } from '../auth';
5
5
  import { type AuditResource } from '../audit';
6
6
  import type { TypedSupabaseClient } from '../client';
7
7
  export * from './dto';
@@ -10,7 +10,7 @@ export declare class AdditionalDetailsResource {
10
10
  private readonly supabase;
11
11
  private readonly auth;
12
12
  private readonly table;
13
- constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
+ constructor(supabase: TypedSupabaseClient, auth: ClientContext, audit: AuditResource);
14
14
  getById(additionalDetailsId: string): Promise<AdditionalDetailsEntity>;
15
15
  list(entity: AdditionalDetailsEntityTypes, entityId: string): Promise<AdditionalDetailSummary[]>;
16
16
  create(entity: AdditionalDetailsEntityTypes, entityId: string, input: AddAdditionalDetailsDto): Promise<AdditionalDetailsEntity>;
@@ -19,6 +19,7 @@ const enum_1 = require("@managemint-solutions/entities/additional-details/enum")
19
19
  const enum_2 = require("@managemint-solutions/entities/audit-trail/enum");
20
20
  const audit_1 = require("../audit");
21
21
  const errors_1 = require("../errors");
22
+ const tenancy_1 = require("../tenancy");
22
23
  const won_1 = require("../leads/won");
23
24
  const values_1 = require("./values");
24
25
  __exportStar(require("./dto"), exports);
@@ -47,10 +48,7 @@ class AdditionalDetailsResource {
47
48
  });
48
49
  }
49
50
  async getById(additionalDetailsId) {
50
- const { data, error } = await this.supabase
51
- .from('additional_details')
52
- .select(ADDITIONAL_DETAILS_SELECT)
53
- .eq('organization_id', this.auth.organization_id)
51
+ const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('additional_details').select(ADDITIONAL_DETAILS_SELECT), this.auth)
54
52
  .eq('mms_id', additionalDetailsId)
55
53
  .single();
56
54
  if (error)
@@ -58,10 +56,7 @@ class AdditionalDetailsResource {
58
56
  return withCastValue(data);
59
57
  }
60
58
  async list(entity, entityId) {
61
- const { data, error } = await this.supabase
62
- .from('additional_details')
63
- .select(ADDITIONAL_DETAILS_SUMMARY_SELECT)
64
- .eq('organization_id', this.auth.organization_id)
59
+ const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('additional_details').select(ADDITIONAL_DETAILS_SUMMARY_SELECT), this.auth)
65
60
  .eq('entity_type', entity)
66
61
  .eq('entity_id', entityId)
67
62
  .order('key', { ascending: false });
@@ -70,11 +65,12 @@ class AdditionalDetailsResource {
70
65
  return data.map(withCastValue);
71
66
  }
72
67
  async create(entity, entityId, input) {
68
+ const organizationId = (0, tenancy_1.requireOrganization)(this.auth);
73
69
  if (entity === enum_1.AdditionalDetailsEntityTypes.LEAD) {
74
- await (0, won_1.refuseWonLead)(this.supabase, this.auth.organization_id, entityId);
70
+ await (0, won_1.refuseWonLead)(this.supabase, organizationId, entityId);
75
71
  }
76
72
  const row = await this.table.insert({
77
- organization_id: this.auth.organization_id,
73
+ organization_id: organizationId,
78
74
  entity_type: entity,
79
75
  entity_id: entityId,
80
76
  key: input.key,
@@ -2,6 +2,7 @@ import type { SupabaseClient as SupabaseJsClient } from '@supabase/supabase-js';
2
2
  import type { AuditTrailEntity } from '@managemint-solutions/entities/audit-trail';
3
3
  import { AuditTrailAction, type AuditTrailEntityType } from '@managemint-solutions/entities/audit-trail/enum';
4
4
  import type { MmsList } from '@managemint-solutions/entities/common';
5
+ import type { StaffContext } from '../auth';
5
6
  import type { GetAuditTrailDto } from './dto';
6
7
  export * from './dto';
7
8
  /**
@@ -12,6 +13,8 @@ export type AuditActor = {
12
13
  mms_id: string | null;
13
14
  organization_id: string;
14
15
  };
16
+ /** Who a resource runs as: a write's actor, or staff, who read across organizations and write nothing. */
17
+ export type ResourceActor = AuditActor | StaffContext;
15
18
  /** A flat database row - always `select('*')`, never an entity shape with embedded joins. */
16
19
  export type AuditRow = Record<string, unknown>;
17
20
  /**
@@ -45,8 +48,9 @@ export declare const AUDIT_EXCLUDED_COLUMNS: ReadonlySet<string>;
45
48
  * `read_logs`, so a value copied into `changes` is a value published to the whole organization -
46
49
  * and these are either provider credentials and identifiers (the `paystack_*` customer and
47
50
  * subscription codes, card metadata, tokens, an idempotency key that would let a replay be
48
- * forged) or content the base table deliberately withholds (`authenticated` has no grant on
49
- * `notifications.rendered_content`, so the rendered email must not arrive by the back door).
51
+ * forged, an api credential's secret hash) or content the base table deliberately withholds
52
+ * (`authenticated` has no grant on `notifications.rendered_content`, so the rendered email must
53
+ * not arrive by the back door).
50
54
  * The column still shows up in the trail - that it changed is the point - with both sides
51
55
  * replaced by `'[redacted]'`.
52
56
  */
@@ -83,7 +87,7 @@ export declare class AuditResource {
83
87
  private readonly supabase;
84
88
  private readonly actor;
85
89
  private readonly onError?;
86
- constructor(supabase: SupabaseJsClient, actor: AuditActor, onError?: AuditErrorHandler | undefined);
90
+ constructor(supabase: SupabaseJsClient, actor: ResourceActor, onError?: AuditErrorHandler | undefined);
87
91
  /**
88
92
  * Records the entries: held by the current event's batch when there is one (`withAuditBatch`),
89
93
  * written straight away when there is not.
@@ -112,7 +116,7 @@ export declare class EntityAuditTrail {
112
116
  private readonly supabase;
113
117
  private readonly actor;
114
118
  private readonly entityType;
115
- constructor(supabase: SupabaseJsClient, actor: AuditActor, entityType: AuditTrailEntityType);
119
+ constructor(supabase: SupabaseJsClient, actor: ResourceActor, entityType: AuditTrailEntityType);
116
120
  /**
117
121
  * A page of the history. `entity_type` keeps the entries about one kind of record (the record
118
122
  * itself, or its notes ...). `search` matches what a change recorded - the stored text, field
@@ -172,9 +176,11 @@ export type AuditedTable = {
172
176
  /** Hard delete. Returns the row as it was, so the caller can still act on it. */
173
177
  remove(id: string): Promise<AuditRow>;
174
178
  };
179
+ /** How a service-client resource reaches an audited table once it knows a row's organization. */
180
+ export type OrganizationTableFactory = (organizationId: string) => AuditedTable;
175
181
  /**
176
182
  * The audited write helper every resource writes through: it does the pre-read, the write, the
177
183
  * diff and the `audit_logs` insert itself, so no feature carries audit code. The write fails
178
184
  * loudly (`SupabaseClientError`); only the audit insert is swallowed.
179
185
  */
180
- export declare const auditedTable: (supabase: SupabaseJsClient, actor: AuditActor, audit: AuditResource, options: AuditedTableOptions) => AuditedTable;
186
+ export declare const auditedTable: (supabase: SupabaseJsClient, actor: ResourceActor, audit: AuditResource, options: AuditedTableOptions) => AuditedTable;
@@ -18,6 +18,7 @@ exports.auditedTable = exports.EntityAuditTrail = exports.AuditResource = export
18
18
  const node_async_hooks_1 = require("node:async_hooks");
19
19
  const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
20
20
  const errors_1 = require("../errors");
21
+ const tenancy_1 = require("../tenancy");
21
22
  const transforms_1 = require("../transforms");
22
23
  __exportStar(require("./dto"), exports);
23
24
  /**
@@ -60,8 +61,9 @@ exports.AUDIT_EXCLUDED_COLUMNS = new Set([
60
61
  * `read_logs`, so a value copied into `changes` is a value published to the whole organization -
61
62
  * and these are either provider credentials and identifiers (the `paystack_*` customer and
62
63
  * subscription codes, card metadata, tokens, an idempotency key that would let a replay be
63
- * forged) or content the base table deliberately withholds (`authenticated` has no grant on
64
- * `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).
65
67
  * The column still shows up in the trail - that it changed is the point - with both sides
66
68
  * replaced by `'[redacted]'`.
67
69
  */
@@ -76,6 +78,7 @@ exports.AUDIT_REDACTED_COLUMNS = new Set([
76
78
  'access_token',
77
79
  'refresh_token',
78
80
  'password',
81
+ 'secret_hash',
79
82
  ]);
80
83
  /** True for a named redacted column, and for every provider-issued `paystack_*` identifier. */
81
84
  const isRedactedColumn = (name) => exports.AUDIT_REDACTED_COLUMNS.has(name) || name.startsWith('paystack_');
@@ -280,7 +283,7 @@ class AuditResource {
280
283
  // `eq`/`in` return `this`, so the org filter can be reassigned onto the builder.
281
284
  let query = this.supabase.from(source.from).select(source.select).in('mms_id', [...new Set(list)]);
282
285
  if (source.scoped)
283
- query = query.eq('organization_id', this.actor.organization_id);
286
+ query = (0, tenancy_1.scopeToOrganization)(query, this.actor);
284
287
  const { data, error } = await query;
285
288
  if (error)
286
289
  throw (0, errors_1.mapPostgrestError)(error);
@@ -329,7 +332,7 @@ class EntityAuditTrail {
329
332
  .or((0, transforms_1.orIlike)(source.search, search))
330
333
  .limit(SEARCH_NAME_LIMIT);
331
334
  if (source.scoped)
332
- lookup = lookup.eq('organization_id', this.actor.organization_id);
335
+ lookup = (0, tenancy_1.scopeToOrganization)(lookup, this.actor);
333
336
  const { data: rows, error: lookupError } = await lookup;
334
337
  if (lookupError)
335
338
  throw (0, errors_1.mapPostgrestError)(lookupError);
@@ -344,12 +347,11 @@ class EntityAuditTrail {
344
347
  filter = `and(${filter},or(${matches.join(',')}))`;
345
348
  }
346
349
  // `eq` returns `this`, so the type filter can be reassigned onto the builder.
347
- let historyQuery = this.supabase
350
+ let historyQuery = (0, tenancy_1.scopeToOrganization)(this.supabase
348
351
  .from('audit_logs')
349
352
  // Exact, not estimated: one record's history is small and index-backed, and the pager
350
353
  // must not offer a page that is not there.
351
- .select(AUDIT_TRAIL_SELECT, { count: 'exact' })
352
- .eq('organization_id', this.actor.organization_id)
354
+ .select(AUDIT_TRAIL_SELECT, { count: 'exact' }), this.actor)
353
355
  // One element, so the record and the search can nest as `and(or(...),or(...))`.
354
356
  .or(filter);
355
357
  if (query.entity_type)
@@ -381,6 +383,8 @@ const buildUpdate = (supabase, table, id, payload, organizationId) => {
381
383
  */
382
384
  const auditedTable = (supabase, actor, audit, options) => {
383
385
  const scoped = options.scopeByOrganization !== false;
386
+ // Null for staff too, who never get this far: every write below refuses them first.
387
+ const organizationFilter = scoped ? actor.organization_id : null;
384
388
  // The record a row belongs to: the declared foreign key, or the owner the polymorphic tables
385
389
  // (notes, additional details, supporting files) carry in `entity_type`/`entity_id`. A row with
386
390
  // a type and no id (statuses and labels name the kind of record they apply to) has no owner.
@@ -397,14 +401,13 @@ const auditedTable = (supabase, actor, audit, options) => {
397
401
  const read = async (id) => {
398
402
  // `eq` returns `this`, so the org filter can be reassigned onto the builder without a cast.
399
403
  let query = supabase.from(options.table).select('*').eq('mms_id', id);
400
- if (scoped)
401
- query = query.eq('organization_id', actor.organization_id);
404
+ if (organizationFilter !== null)
405
+ query = query.eq('organization_id', organizationFilter);
402
406
  const { data, error } = await query.single();
403
407
  if (error)
404
408
  throw (0, errors_1.mapPostgrestError)(error);
405
409
  return data;
406
410
  };
407
- const organizationFilter = scoped ? actor.organization_id : null;
408
411
  const updateOf = (id, before, payload) => buildUpdate(supabase, options.table, id, typeof payload === 'function' ? payload(before) : payload, organizationFilter);
409
412
  // The changes with the table's untracked columns taken out.
410
413
  const tracked = (changes) => Object.fromEntries(Object.entries(changes).filter(([column]) => !options.untracked?.includes(column)));
@@ -422,6 +425,7 @@ const auditedTable = (supabase, actor, audit, options) => {
422
425
  });
423
426
  };
424
427
  const write = async (id, action, payload) => {
428
+ (0, tenancy_1.requireOrganization)(actor);
425
429
  const before = await read(id);
426
430
  const { data, error } = await updateOf(id, before, payload).select('*').single();
427
431
  if (error)
@@ -433,6 +437,7 @@ const auditedTable = (supabase, actor, audit, options) => {
433
437
  return (0, exports.withoutInternalId)(after);
434
438
  };
435
439
  const writeWhere = async (id, guard, payload) => {
440
+ (0, tenancy_1.requireOrganization)(actor);
436
441
  const before = await read(id);
437
442
  // `maybeSingle`, not `single`: matching nothing is the guard doing its job, not an error.
438
443
  const { data, error } = await guard(updateOf(id, before, payload))
@@ -449,6 +454,7 @@ const auditedTable = (supabase, actor, audit, options) => {
449
454
  };
450
455
  return {
451
456
  async insert(payload) {
457
+ (0, tenancy_1.requireOrganization)(actor);
452
458
  const { data, error } = await supabase
453
459
  .from(options.table)
454
460
  .insert(payload)
@@ -467,6 +473,7 @@ const auditedTable = (supabase, actor, audit, options) => {
467
473
  return (0, exports.withoutInternalId)(row);
468
474
  },
469
475
  async insertMany(payloads) {
476
+ (0, tenancy_1.requireOrganization)(actor);
470
477
  if (payloads.length === 0)
471
478
  return [];
472
479
  const { data, error } = await supabase.from(options.table).insert(payloads).select('*');
@@ -492,10 +499,11 @@ const auditedTable = (supabase, actor, audit, options) => {
492
499
  return write(id, action, payload);
493
500
  },
494
501
  async remove(id) {
502
+ (0, tenancy_1.requireOrganization)(actor);
495
503
  const before = await read(id);
496
504
  let query = supabase.from(options.table).delete().eq('mms_id', id);
497
- if (scoped)
498
- query = query.eq('organization_id', actor.organization_id);
505
+ if (organizationFilter !== null)
506
+ query = query.eq('organization_id', organizationFilter);
499
507
  const { error } = await query;
500
508
  if (error)
501
509
  throw (0, errors_1.mapPostgrestError)(error);
@@ -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,6 +1,6 @@
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';
3
+ import type { ClientContext } from '../auth';
4
4
  import { EntityAuditTrail, type AuditResource } from '../audit';
5
5
  import type { TypedSupabaseClient } from '../client';
6
6
  import type { CreateClientDto, GetClientsDto, UpdateClientDto } from './dto';
@@ -11,8 +11,10 @@ export declare class ClientsResource {
11
11
  private readonly table;
12
12
  /** This record's history - its own rows and the rows that hang off it. */
13
13
  readonly audit: EntityAuditTrail;
14
- constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
15
- list(query: GetClientsDto): Promise<MmsList<ClientSummary>>;
14
+ constructor(supabase: TypedSupabaseClient, auth: ClientContext, audit: AuditResource);
15
+ list(query: GetClientsDto & {
16
+ organization_id?: string | null;
17
+ }): Promise<MmsList<ClientSummary>>;
16
18
  getById(clientId: string): Promise<ClientEntity>;
17
19
  create(input: CreateClientDto): Promise<ClientEntity>;
18
20
  update(clientId: string, input: UpdateClientDto): Promise<ClientEntity>;
@@ -18,6 +18,7 @@ exports.ClientsResource = void 0;
18
18
  const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
19
19
  const audit_1 = require("../audit");
20
20
  const errors_1 = require("../errors");
21
+ const tenancy_1 = require("../tenancy");
21
22
  const transforms_1 = require("../transforms");
22
23
  __exportStar(require("./dto"), exports);
23
24
  // The public contract is the shared ClientSummary / ClientEntity from
@@ -92,14 +93,16 @@ class ClientsResource {
92
93
  const trimmedSearch = query.search?.trim();
93
94
  // `order`/`range` return `this`, so the builder stays a filter builder and the conditional
94
95
  // filters below can be reassigned onto it without a cast.
95
- let clientsQuery = this.supabase
96
+ let clientsQuery = (0, tenancy_1.scopeToOrganization)(this.supabase
96
97
  .from('clients')
97
- .select(CLIENT_LIST_SELECT, { count: 'estimated' })
98
- .eq('organization_id', this.auth.organization_id)
98
+ .select((0, tenancy_1.withOrganization)(CLIENT_LIST_SELECT, this.auth), { count: 'estimated' }), this.auth)
99
99
  .eq('archived', query.archive)
100
100
  .eq('deleted', false)
101
101
  .order('created_at', { ascending: false })
102
102
  .range(offset, offset + pageSize - 1);
103
+ if (query.organization_id) {
104
+ clientsQuery = clientsQuery.eq('organization_id', query.organization_id);
105
+ }
103
106
  if (trimmedSearch) {
104
107
  clientsQuery = clientsQuery.or((0, transforms_1.orIlike)(['name', 'email'], trimmedSearch));
105
108
  }
@@ -118,18 +121,17 @@ class ClientsResource {
118
121
  return (0, transforms_1.toMmsList)(data, page, pageSize, count);
119
122
  }
120
123
  async getById(clientId) {
121
- const { data, error } = await this.supabase
122
- .from('clients')
123
- .select(CLIENT_DETAIL_SELECT)
124
- .eq('organization_id', this.auth.organization_id)
124
+ const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('clients').select((0, tenancy_1.withOrganization)(CLIENT_DETAIL_SELECT, this.auth)), this.auth)
125
125
  .eq('mms_id', clientId)
126
126
  .single();
127
127
  if (error)
128
128
  throw (0, errors_1.mapPostgrestError)(error);
129
+ // The select carries the staff embed, so its row type is not inferred from the string.
130
+ const row = data;
129
131
  // A soft-deleted client keeps its audit trail but gives up its details.
130
- if (data.deleted) {
132
+ if (row.deleted) {
131
133
  return {
132
- ...data,
134
+ ...row,
133
135
  name: '',
134
136
  email: '',
135
137
  phone: '',
@@ -143,7 +145,7 @@ class ClientsResource {
143
145
  async create(input) {
144
146
  const row = await this.table.insert({
145
147
  ...input,
146
- organization_id: this.auth.organization_id,
148
+ organization_id: (0, tenancy_1.requireOrganization)(this.auth),
147
149
  created_by: this.auth.mms_id,
148
150
  created_at: new Date().toISOString(),
149
151
  });
@@ -0,0 +1,16 @@
1
+ import type { TypedSupabaseClient } from '../client';
2
+ /**
3
+ * `emulation_handshakes`, service client only: proof that a user's next sign-in is the admin
4
+ * api's own mint, which the custom access token hook spends to mark that token emulated.
5
+ */
6
+ export declare class EmulationHandshakesResource {
7
+ private readonly supabase;
8
+ constructor(supabase: TypedSupabaseClient);
9
+ /**
10
+ * Opens a handshake for `userId`'s next sign-in, good for thirty seconds. Returns its id. The
11
+ * credential is the one a public mint presented; a staff member's own mint has none.
12
+ */
13
+ open(userId: string, credentialId: string | null): Promise<number>;
14
+ /** Deletes the handshake, spent or not, once the mint is over. */
15
+ close(id: number): Promise<void>;
16
+ }