@managemint-solutions/sdk 0.53.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 +75 -3
- package/dist/additional-details/index.d.ts +2 -2
- package/dist/additional-details/index.js +6 -10
- package/dist/audit/index.d.ts +11 -5
- package/dist/audit/index.js +20 -12
- 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 +5 -3
- package/dist/clients/index.js +8 -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 +13 -9
- package/dist/index.js +22 -4
- package/dist/leads/dto.d.ts +5 -1
- package/dist/leads/dto.js +12 -1
- package/dist/leads/index.d.ts +5 -3
- package/dist/leads/index.js +20 -19
- 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/organizations/dto.d.ts +9 -1
- package/dist/organizations/dto.js +40 -1
- package/dist/organizations/index.d.ts +11 -3
- package/dist/organizations/index.js +30 -2
- package/dist/permissions/index.d.ts +6 -4
- package/dist/permissions/index.js +17 -12
- package/dist/projects/dto.d.ts +5 -1
- package/dist/projects/dto.js +12 -1
- package/dist/projects/index.d.ts +5 -3
- package/dist/projects/index.js +8 -8
- package/dist/service-client/index.d.ts +32 -1
- package/dist/service-client/index.js +54 -1
- 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 +5 -3
- package/dist/tasks/index.js +18 -19
- package/dist/tenancy.d.ts +13 -0
- package/dist/tenancy.js +25 -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 +5 -3
- package/dist/users/index.js +13 -29
- package/dist/validators.d.ts +2 -0
- package/dist/validators.js +6 -1
- 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
|
|
214
|
-
by `organization_id`.
|
|
215
|
-
|
|
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 {
|
|
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:
|
|
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,
|
|
70
|
+
await (0, won_1.refuseWonLead)(this.supabase, organizationId, entityId);
|
|
75
71
|
}
|
|
76
72
|
const row = await this.table.insert({
|
|
77
|
-
organization_id:
|
|
73
|
+
organization_id: organizationId,
|
|
78
74
|
entity_type: entity,
|
|
79
75
|
entity_id: entityId,
|
|
80
76
|
key: input.key,
|
package/dist/audit/index.d.ts
CHANGED
|
@@ -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
|
|
49
|
-
* `notifications.rendered_content`, so the rendered email must
|
|
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:
|
|
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:
|
|
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:
|
|
186
|
+
export declare const auditedTable: (supabase: SupabaseJsClient, actor: ResourceActor, audit: AuditResource, options: AuditedTableOptions) => AuditedTable;
|
package/dist/audit/index.js
CHANGED
|
@@ -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
|
|
64
|
-
* `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).
|
|
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 =
|
|
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 =
|
|
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 (
|
|
401
|
-
query = query.eq('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 (
|
|
498
|
-
query = query.eq('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
|
|
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,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 {
|
|
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:
|
|
15
|
-
list(query: GetClientsDto
|
|
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>;
|
package/dist/clients/index.js
CHANGED
|
@@ -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,10 +121,7 @@ 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(CLIENT_DETAIL_SELECT), this.auth)
|
|
125
125
|
.eq('mms_id', clientId)
|
|
126
126
|
.single();
|
|
127
127
|
if (error)
|
|
@@ -143,7 +143,7 @@ class ClientsResource {
|
|
|
143
143
|
async create(input) {
|
|
144
144
|
const row = await this.table.insert({
|
|
145
145
|
...input,
|
|
146
|
-
organization_id: this.auth
|
|
146
|
+
organization_id: (0, tenancy_1.requireOrganization)(this.auth),
|
|
147
147
|
created_by: this.auth.mms_id,
|
|
148
148
|
created_at: new Date().toISOString(),
|
|
149
149
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
/** Opens a handshake for `userId`'s next sign-in, good for thirty seconds. Returns its id. */
|
|
10
|
+
open(userId: string, credentialId: string): Promise<number>;
|
|
11
|
+
/** Deletes the handshake, spent or not, once the mint is over. */
|
|
12
|
+
close(id: number): Promise<void>;
|
|
13
|
+
}
|