@astram/guard 0.22.0 → 0.24.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 (40) hide show
  1. package/dist/auth/auth.module.d.ts +7 -0
  2. package/dist/auth/auth.module.d.ts.map +1 -1
  3. package/dist/auth/auth.module.js +43 -1
  4. package/dist/auth/tokens.d.ts +13 -0
  5. package/dist/auth/tokens.d.ts.map +1 -1
  6. package/dist/auth/tokens.js +14 -1
  7. package/dist/completeness/catalog-reconciler.d.ts +16 -1
  8. package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
  9. package/dist/completeness/catalog-reconciler.js +70 -6
  10. package/dist/completeness/ports/catalog-store.d.ts +38 -3
  11. package/dist/completeness/ports/catalog-store.d.ts.map +1 -1
  12. package/dist/config/notification-config.d.ts +20 -0
  13. package/dist/config/notification-config.d.ts.map +1 -0
  14. package/dist/config/notification-config.js +33 -0
  15. package/dist/identity/module-registry.bridge.d.ts +9 -1
  16. package/dist/identity/module-registry.bridge.d.ts.map +1 -1
  17. package/dist/identity/module-registry.bridge.js +19 -1
  18. package/dist/index.d.ts +8 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +51 -37
  21. package/dist/module/shield.module.d.ts +12 -0
  22. package/dist/module/shield.module.d.ts.map +1 -1
  23. package/dist/module/shield.module.js +11 -0
  24. package/dist/notifications/mariadb-notification-store.d.ts +38 -0
  25. package/dist/notifications/mariadb-notification-store.d.ts.map +1 -0
  26. package/dist/notifications/mariadb-notification-store.js +151 -0
  27. package/dist/notifications/notification-retention.job.d.ts +52 -0
  28. package/dist/notifications/notification-retention.job.d.ts.map +1 -0
  29. package/dist/notifications/notification-retention.job.js +97 -0
  30. package/dist/notifications/notification.service.d.ts +68 -0
  31. package/dist/notifications/notification.service.d.ts.map +1 -0
  32. package/dist/notifications/notification.service.js +247 -0
  33. package/dist/notifications/notifications.controller.d.ts +57 -0
  34. package/dist/notifications/notifications.controller.d.ts.map +1 -0
  35. package/dist/notifications/notifications.controller.js +257 -0
  36. package/dist/notifications/ports.d.ts +112 -0
  37. package/dist/notifications/ports.d.ts.map +1 -0
  38. package/dist/notifications/ports.js +63 -0
  39. package/migrations/1710000000107-AddPermissionTextAndFirstSeen.cjs +43 -0
  40. package/package.json +5 -5
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NotificationRetentionJob = exports.NOTIFICATION_RETENTION_BATCH = exports.NOTIFICATION_RETENTION_INTERVAL_MS = exports.NOTIFICATION_RETENTION_FIRST_RUN_MS = void 0;
13
+ /**
14
+ * `NotificationRetentionJob` — deletes expired notices (notification-store SPEC §5E, §10, §11).
15
+ *
16
+ * Registered with `ShieldModule.forRoot({ auth: true })` beside the store, over guard's core pool
17
+ * `SHIELD_DB_POOL` (it does not own the pool: `ConnectionLifecycle` ends it in
18
+ * `onApplicationShutdown`, after this job stopped).
19
+ *
20
+ * - First pass {@link NOTIFICATION_RETENTION_FIRST_RUN_MS} after bootstrap, then one pass every
21
+ * {@link NOTIFICATION_RETENTION_INTERVAL_MS} (hourly), scheduled only after the previous pass
22
+ * settled, so passes never overlap.
23
+ * - One pass = one `purge` of at most {@link NOTIFICATION_RETENTION_BATCH} rows: read notices older
24
+ * than `NOTIFICATION_READ_RETENTION_DAYS`, any notice older than `NOTIFICATION_MAX_RETENTION_DAYS`.
25
+ * - A failed pass is logged by error class only (never the driver message) and retried at the next
26
+ * interval; nothing here throws into Nest, so it never blocks boot (a missing table, migration
27
+ * 108 not applied, is just a logged pass).
28
+ * - Every timer is `unref()`'d. `onModuleDestroy` (Nest `close()` step 1) clears the timer and
29
+ * waits for a running pass. Off the request path (Shield #12).
30
+ */
31
+ const common_1 = require("@nestjs/common");
32
+ const audit_chain_sealer_js_1 = require("../audit/audit-chain-sealer.js");
33
+ exports.NOTIFICATION_RETENTION_FIRST_RUN_MS = 5_000;
34
+ exports.NOTIFICATION_RETENTION_INTERVAL_MS = 60 * 60 * 1000;
35
+ exports.NOTIFICATION_RETENTION_BATCH = 5000;
36
+ let NotificationRetentionJob = class NotificationRetentionJob {
37
+ store;
38
+ policy;
39
+ now;
40
+ logger;
41
+ stopped = false;
42
+ timer;
43
+ inFlight;
44
+ constructor(store, policy, options = {}) {
45
+ this.store = store;
46
+ this.policy = policy;
47
+ this.now = options.now ?? Date.now;
48
+ this.logger = options.logger ?? new common_1.Logger('Shield');
49
+ }
50
+ onApplicationBootstrap() {
51
+ this.schedule(exports.NOTIFICATION_RETENTION_FIRST_RUN_MS);
52
+ }
53
+ async onModuleDestroy() {
54
+ this.stopped = true;
55
+ if (this.timer !== undefined)
56
+ clearTimeout(this.timer);
57
+ this.timer = undefined;
58
+ await this.inFlight; // never rejects (runOnce catches)
59
+ }
60
+ /**
61
+ * One retention pass; resolves to the deleted count, or `null` when the pass failed (logged).
62
+ * Exposed for tests and operators; the timer calls it hourly.
63
+ */
64
+ async runOnce() {
65
+ try {
66
+ const deleted = await this.store.purge(this.now(), this.policy, exports.NOTIFICATION_RETENTION_BATCH);
67
+ if (deleted > 0)
68
+ this.logger.log(`[notifications] retention removed ${deleted} notices`);
69
+ return deleted;
70
+ }
71
+ catch (err) {
72
+ this.logger.error(`[notifications] retention pass failed (${(0, audit_chain_sealer_js_1.auditSealErrorClass)(err)}); retried next hour`);
73
+ return null;
74
+ }
75
+ }
76
+ schedule(delayMs) {
77
+ if (this.stopped || this.timer !== undefined)
78
+ return;
79
+ this.timer = setTimeout(() => {
80
+ this.timer = undefined;
81
+ if (this.stopped)
82
+ return;
83
+ this.inFlight = this.runOnce()
84
+ .then(() => undefined)
85
+ .finally(() => {
86
+ this.inFlight = undefined;
87
+ this.schedule(exports.NOTIFICATION_RETENTION_INTERVAL_MS);
88
+ });
89
+ }, delayMs);
90
+ this.timer.unref();
91
+ }
92
+ };
93
+ exports.NotificationRetentionJob = NotificationRetentionJob;
94
+ exports.NotificationRetentionJob = NotificationRetentionJob = __decorate([
95
+ (0, common_1.Injectable)(),
96
+ __metadata("design:paramtypes", [Object, Object, Object])
97
+ ], NotificationRetentionJob);
@@ -0,0 +1,68 @@
1
+ import type { Pool } from 'mariadb';
2
+ import { type FrameworkSchemaNames } from '../config/framework-schema-names.js';
3
+ import { type NotificationCategory, type NotificationKindRegistration, type NotificationStore, type NotificationWriteOptions } from './ports.js';
4
+ /** SPEC §10 limits. */
5
+ export declare const NOTIFICATION_TITLE_MAX = 160;
6
+ export declare const NOTIFICATION_BODY_MAX = 1000;
7
+ export declare const NOTIFICATION_LINK_MAX = 512;
8
+ export declare const NOTIFICATION_RECIPIENTS_MAX = 1000;
9
+ /** The prefix reserved for the framework's own kinds. */
10
+ export declare const FRAMEWORK_KIND_PREFIX = "shield.";
11
+ /** The framework's own kinds. Step 2 registers none (step 3 adds `shield.access_changed`). */
12
+ export declare const FRAMEWORK_NOTIFICATION_KINDS: readonly NotificationKindRegistration[];
13
+ export interface NotificationPostInput {
14
+ /** The company the notice belongs to; recipients must be active members of it. */
15
+ readonly tenantId: string;
16
+ /** User uuids, 1–1000. Service accounts are not users and never receive notices. */
17
+ readonly recipients: readonly string[];
18
+ readonly kind: string;
19
+ readonly title: string;
20
+ readonly body?: string | null;
21
+ /** App-relative path only (`/…`, not `//`, no `\`, no control characters). */
22
+ readonly link?: string | null;
23
+ /** Idempotency key per recipient and tenant, 1–64 of `[A-Za-z0-9._:-]`. */
24
+ readonly dedupeKey?: string | null;
25
+ }
26
+ export interface NotificationPostResult {
27
+ readonly created: number;
28
+ readonly skipped: number;
29
+ }
30
+ /** Validated kind registry: kind ⇒ category. */
31
+ export type NotificationKindRegistry = ReadonlyMap<string, NotificationCategory>;
32
+ /**
33
+ * Build the kind registry from the app's `notificationKinds` (validated at boot, SPEC §5C) and the
34
+ * framework's own kinds. Refuses a malformed or over-long kind, an unknown category, a duplicate,
35
+ * and an app kind under the reserved `shield.` prefix; each error names the kind.
36
+ */
37
+ export declare function resolveNotificationKinds(appKinds: readonly NotificationKindRegistration[] | undefined, frameworkKinds?: readonly NotificationKindRegistration[]): NotificationKindRegistry;
38
+ /** Logs counts only (Nest `Logger` shape). */
39
+ export interface NotificationServiceLogger {
40
+ debug(message: string): void;
41
+ }
42
+ export interface NotificationServiceOptions {
43
+ /** Default `Date.now`. */
44
+ readonly now?: () => number;
45
+ /** Default `new Logger('Shield')`. */
46
+ readonly logger?: NotificationServiceLogger;
47
+ }
48
+ export declare class NotificationService {
49
+ private readonly store;
50
+ /** Guard's core pool: the recipient filter runs here when no caller `query` is given. */
51
+ private readonly pool;
52
+ private readonly kinds;
53
+ private readonly usersTable;
54
+ private readonly membershipTable;
55
+ private readonly now;
56
+ private readonly logger;
57
+ constructor(store: NotificationStore,
58
+ /** Guard's core pool: the recipient filter runs here when no caller `query` is given. */
59
+ pool: Pick<Pool, 'query'>, kinds: NotificationKindRegistry,
60
+ /** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
61
+ schemas?: FrameworkSchemaNames, options?: NotificationServiceOptions);
62
+ /** Whether `kind` is registered (framework or app). */
63
+ hasKind(kind: string): boolean;
64
+ post(input: NotificationPostInput, opts?: NotificationWriteOptions): Promise<NotificationPostResult>;
65
+ /** ONE parameterized SELECT: active users with an active membership in `tenantId`. */
66
+ private activeRecipients;
67
+ }
68
+ //# sourceMappingURL=notification.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification.service.d.ts","sourceRoot":"","sources":["../../src/notifications/notification.service.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EAOL,KAAK,oBAAoB,EAEzB,KAAK,4BAA4B,EAEjC,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC9B,MAAM,YAAY,CAAC;AAEpB,uBAAuB;AACvB,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAC1C,eAAO,MAAM,qBAAqB,MAAM,CAAC;AACzC,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAOhD,yDAAyD;AACzD,eAAO,MAAM,qBAAqB,YAAY,CAAC;AAE/C,8FAA8F;AAC9F,eAAO,MAAM,4BAA4B,EAAE,SAAS,4BAA4B,EAAO,CAAC;AAExF,MAAM,WAAW,qBAAqB;IACpC,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,gDAAgD;AAChD,MAAM,MAAM,wBAAwB,GAAG,WAAW,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC;AAWjF;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,SAAS,4BAA4B,EAAE,GAAG,SAAS,EAC7D,cAAc,GAAE,SAAS,4BAA4B,EAAiC,GACrF,wBAAwB,CAqC1B;AAED,8CAA8C;AAC9C,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,0BAA0B;IACzC,0BAA0B;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,sCAAsC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,yBAAyB,CAAC;CAC7C;AAED,qBAAa,mBAAmB;IAO5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK;IATxB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;gBAGhC,KAAK,EAAE,iBAAiB;IACzC,yFAAyF;IACxE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,EACzB,KAAK,EAAE,wBAAwB;IAChD,yFAAyF;IACzF,OAAO,CAAC,EAAE,oBAAoB,EAC9B,OAAO,GAAE,0BAA+B;IAS1C,uDAAuD;IACvD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CACR,KAAK,EAAE,qBAAqB,EAC5B,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,sBAAsB,CAAC;IA6ClC,sFAAsF;YACxE,gBAAgB;CAwB/B"}
@@ -0,0 +1,247 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NotificationService = exports.FRAMEWORK_NOTIFICATION_KINDS = exports.FRAMEWORK_KIND_PREFIX = exports.NOTIFICATION_RECIPIENTS_MAX = exports.NOTIFICATION_LINK_MAX = exports.NOTIFICATION_BODY_MAX = exports.NOTIFICATION_TITLE_MAX = void 0;
4
+ exports.resolveNotificationKinds = resolveNotificationKinds;
5
+ /**
6
+ * `NotificationService` — the one way app and framework code posts notices (notification-store
7
+ * SPEC §5B, §5C, §10, §11). Bound under `SHIELD_NOTIFICATIONS` with `ShieldModule.forRoot({ auth:
8
+ * true })`; exported from the main entry.
9
+ *
10
+ * `post(input, opts?)`:
11
+ * - the kind must be registered (`notificationKinds`, plus the framework's own kinds) ⇒ else
12
+ * {@link NotificationKindError}; the CATEGORY comes from that registration, never the caller;
13
+ * - title / body / link / dedupe key / recipients follow SPEC §10 and §11 ⇒ else
14
+ * {@link NotificationValidationError} naming the field, never the value (core 50 #10);
15
+ * - recipients are canonicalised and de-duplicated, then ONE parameterized SELECT keeps the users
16
+ * with `u_status = 'active'` and an active `company_membership` in `tenantId` (on `opts.query`
17
+ * when given, so it reads the caller's transaction); the rest are skipped and counted;
18
+ * - rows go to the store in one statement (on `opts.query` when given: the caller commits or rolls
19
+ * back with its own write, step 3);
20
+ * - ⇒ `{ created, skipped }`; any store error ⇒ {@link NotificationUnavailableError}, the driver
21
+ * message dropped. No audit row per notice (not an admin action); a debug log with counts only.
22
+ *
23
+ * The tenant is the caller's server-side decision (e.g. the session's active company or the
24
+ * granting transaction's company), never request input passed through unchecked (core 50 #7).
25
+ */
26
+ const node_crypto_1 = require("node:crypto");
27
+ const common_1 = require("@nestjs/common");
28
+ const engine_1 = require("@astram/engine");
29
+ const framework_schema_names_js_1 = require("../config/framework-schema-names.js");
30
+ const ports_js_1 = require("./ports.js");
31
+ /** SPEC §10 limits. */
32
+ exports.NOTIFICATION_TITLE_MAX = 160;
33
+ exports.NOTIFICATION_BODY_MAX = 1000;
34
+ exports.NOTIFICATION_LINK_MAX = 512;
35
+ exports.NOTIFICATION_RECIPIENTS_MAX = 1000;
36
+ const DEDUPE_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
37
+ /** U+0000–U+001F and U+007F. */
38
+ // eslint-disable-next-line no-control-regex
39
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
40
+ // eslint-disable-next-line no-control-regex
41
+ const CONTROL_CHARS_EXCEPT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f]/;
42
+ /** The prefix reserved for the framework's own kinds. */
43
+ exports.FRAMEWORK_KIND_PREFIX = 'shield.';
44
+ /** The framework's own kinds. Step 2 registers none (step 3 adds `shield.access_changed`). */
45
+ exports.FRAMEWORK_NOTIFICATION_KINDS = [];
46
+ /** Length in characters (code points), as `VARCHAR(n)` in utf8mb4 counts them. */
47
+ function codePoints(s) {
48
+ return Array.from(s).length;
49
+ }
50
+ function isCategory(v) {
51
+ return ports_js_1.NOTIFICATION_CATEGORIES.includes(v);
52
+ }
53
+ /**
54
+ * Build the kind registry from the app's `notificationKinds` (validated at boot, SPEC §5C) and the
55
+ * framework's own kinds. Refuses a malformed or over-long kind, an unknown category, a duplicate,
56
+ * and an app kind under the reserved `shield.` prefix; each error names the kind.
57
+ */
58
+ function resolveNotificationKinds(appKinds, frameworkKinds = exports.FRAMEWORK_NOTIFICATION_KINDS) {
59
+ if (appKinds !== undefined && !Array.isArray(appKinds)) {
60
+ throw new TypeError('notificationKinds must be an array of { kind, category }');
61
+ }
62
+ const registry = new Map();
63
+ const add = (entry, framework) => {
64
+ const kind = entry?.kind;
65
+ const category = entry?.category;
66
+ if (typeof kind !== 'string') {
67
+ throw ports_js_1.NotificationKindError.registration(kind, 'must be a string');
68
+ }
69
+ if (kind.length > ports_js_1.NOTIFICATION_KIND_MAX_LENGTH) {
70
+ throw ports_js_1.NotificationKindError.registration(kind, `is longer than ${ports_js_1.NOTIFICATION_KIND_MAX_LENGTH} characters`);
71
+ }
72
+ if (!ports_js_1.NOTIFICATION_KIND_PATTERN.test(kind)) {
73
+ throw ports_js_1.NotificationKindError.registration(kind, 'must be dotted lower-case segments (e.g. app.event_name)');
74
+ }
75
+ if (!framework && kind.startsWith(exports.FRAMEWORK_KIND_PREFIX)) {
76
+ throw ports_js_1.NotificationKindError.registration(kind, 'uses the reserved shield. prefix');
77
+ }
78
+ if (!isCategory(category)) {
79
+ throw ports_js_1.NotificationKindError.registration(kind, 'needs a category: announcement or personal');
80
+ }
81
+ if (registry.has(kind)) {
82
+ throw ports_js_1.NotificationKindError.registration(kind, 'is registered more than once');
83
+ }
84
+ registry.set(kind, category);
85
+ };
86
+ for (const entry of frameworkKinds)
87
+ add(entry, true);
88
+ for (const entry of appKinds ?? [])
89
+ add(entry, false);
90
+ return registry;
91
+ }
92
+ class NotificationService {
93
+ store;
94
+ pool;
95
+ kinds;
96
+ usersTable;
97
+ membershipTable;
98
+ now;
99
+ logger;
100
+ constructor(store,
101
+ /** Guard's core pool: the recipient filter runs here when no caller `query` is given. */
102
+ pool, kinds,
103
+ /** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
104
+ schemas, options = {}) {
105
+ this.store = store;
106
+ this.pool = pool;
107
+ this.kinds = kinds;
108
+ const s = (0, framework_schema_names_js_1.schemaNamesAtConstruction)(schemas);
109
+ this.usersTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.app, 'users');
110
+ this.membershipTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.app, 'company_membership');
111
+ this.now = options.now ?? Date.now;
112
+ this.logger = options.logger ?? new common_1.Logger('Shield');
113
+ }
114
+ /** Whether `kind` is registered (framework or app). */
115
+ hasKind(kind) {
116
+ return this.kinds.has(kind);
117
+ }
118
+ async post(input, opts = {}) {
119
+ if (typeof input !== 'object' || input === null) {
120
+ throw new ports_js_1.NotificationValidationError('input', 'must be an object');
121
+ }
122
+ if (opts.query !== undefined && typeof opts.query !== 'function') {
123
+ throw new ports_js_1.NotificationValidationError('query', 'must be a function');
124
+ }
125
+ const tenantId = (0, engine_1.canonicalUuid)(input.tenantId);
126
+ if (tenantId === null)
127
+ throw new ports_js_1.NotificationValidationError('tenantId', 'must be a uuid');
128
+ const category = typeof input.kind === 'string' ? this.kinds.get(input.kind) : undefined;
129
+ if (category === undefined)
130
+ throw new ports_js_1.NotificationKindError(input.kind);
131
+ const title = validTitle(input.title);
132
+ const body = validBody(input.body);
133
+ const link = validLink(input.link);
134
+ const dedupeKey = validDedupeKey(input.dedupeKey);
135
+ const recipients = validRecipients(input.recipients);
136
+ const query = opts.query ?? ((sql, params) => this.pool.query(sql, params));
137
+ let created;
138
+ try {
139
+ const active = await this.activeRecipients(query, tenantId, recipients);
140
+ const createdAtMs = this.now();
141
+ const rows = active.map((userId) => ({
142
+ uuid: (0, node_crypto_1.randomUUID)(),
143
+ tenantId,
144
+ userId,
145
+ category,
146
+ kind: input.kind,
147
+ title,
148
+ body,
149
+ link,
150
+ dedupeKey,
151
+ createdAtMs,
152
+ }));
153
+ created = await this.store.insertMany(rows, opts.query ? { query: opts.query } : {});
154
+ }
155
+ catch {
156
+ // The driver message may name tables, values or hosts: never carried (core 50 #10).
157
+ throw new ports_js_1.NotificationUnavailableError();
158
+ }
159
+ const skipped = recipients.length - created;
160
+ this.logger.debug(`[notifications] posted: created=${created} skipped=${skipped}`);
161
+ return { created, skipped };
162
+ }
163
+ /** ONE parameterized SELECT: active users with an active membership in `tenantId`. */
164
+ async activeRecipients(query, tenantId, recipients) {
165
+ const marks = recipients.map(() => '?').join(', ');
166
+ // `cm_status = 'active'` only (SPEC §5B): `cm_is_active` is the user's ONE default-company
167
+ // pointer (identity `MembershipService.setActive`), so requiring it would drop members whose
168
+ // default company is another one.
169
+ const rows = await query(`SELECT u.u_uuid AS id FROM ${this.usersTable} u
170
+ WHERE u.u_uuid IN (${marks}) AND u.u_status = 'active'
171
+ AND EXISTS (SELECT 1 FROM ${this.membershipTable} cm
172
+ WHERE cm.cm_user_uuid = u.u_uuid AND cm.cm_company_uuid = ?
173
+ AND cm.cm_status = 'active')`, [...recipients, tenantId]);
174
+ if (!Array.isArray(rows))
175
+ throw new Error('unexpected recipient result');
176
+ const found = new Set(rows.map((r) => (0, engine_1.canonicalUuid)(r.id)).filter((v) => v !== null));
177
+ // Keep the caller's order; every id came from the canonical recipient list.
178
+ return recipients.filter((id) => found.has(id));
179
+ }
180
+ }
181
+ exports.NotificationService = NotificationService;
182
+ function validTitle(v) {
183
+ if (typeof v !== 'string')
184
+ throw new ports_js_1.NotificationValidationError('title', 'must be a string');
185
+ const title = v.trim();
186
+ const n = codePoints(title);
187
+ if (n < 1 || n > exports.NOTIFICATION_TITLE_MAX) {
188
+ throw new ports_js_1.NotificationValidationError('title', `must be 1 to ${exports.NOTIFICATION_TITLE_MAX} characters`);
189
+ }
190
+ if (CONTROL_CHARS.test(title)) {
191
+ throw new ports_js_1.NotificationValidationError('title', 'must not contain control characters');
192
+ }
193
+ return title;
194
+ }
195
+ function validBody(v) {
196
+ if (v === undefined || v === null || v === '')
197
+ return null;
198
+ if (typeof v !== 'string')
199
+ throw new ports_js_1.NotificationValidationError('body', 'must be a string');
200
+ if (codePoints(v) > exports.NOTIFICATION_BODY_MAX) {
201
+ throw new ports_js_1.NotificationValidationError('body', `must be at most ${exports.NOTIFICATION_BODY_MAX} characters`);
202
+ }
203
+ if (CONTROL_CHARS_EXCEPT_NEWLINE.test(v)) {
204
+ throw new ports_js_1.NotificationValidationError('body', 'must not contain control characters other than newline');
205
+ }
206
+ return v;
207
+ }
208
+ function validLink(v) {
209
+ if (v === undefined || v === null || v === '')
210
+ return null;
211
+ if (typeof v !== 'string')
212
+ throw new ports_js_1.NotificationValidationError('link', 'must be a string');
213
+ if (codePoints(v) > exports.NOTIFICATION_LINK_MAX) {
214
+ throw new ports_js_1.NotificationValidationError('link', `must be at most ${exports.NOTIFICATION_LINK_MAX} characters`);
215
+ }
216
+ // App-relative only: one leading `/` (never `//`, a protocol-relative URL), so no scheme or host
217
+ // can follow; no backslash (browsers treat `/\` like `//`); no control characters.
218
+ if (!v.startsWith('/') || v.startsWith('//')) {
219
+ throw new ports_js_1.NotificationValidationError('link', 'must be an app-relative path starting with /');
220
+ }
221
+ if (v.includes('\\') || CONTROL_CHARS.test(v)) {
222
+ throw new ports_js_1.NotificationValidationError('link', 'must not contain backslashes or control characters');
223
+ }
224
+ return v;
225
+ }
226
+ function validDedupeKey(v) {
227
+ if (v === undefined || v === null)
228
+ return null;
229
+ if (typeof v !== 'string' || !DEDUPE_KEY_PATTERN.test(v)) {
230
+ throw new ports_js_1.NotificationValidationError('dedupeKey', 'must be 1 to 64 of A-Z a-z 0-9 . _ : -');
231
+ }
232
+ return v;
233
+ }
234
+ function validRecipients(v) {
235
+ if (!Array.isArray(v) || v.length < 1 || v.length > exports.NOTIFICATION_RECIPIENTS_MAX) {
236
+ throw new ports_js_1.NotificationValidationError('recipients', `must be a list of 1 to ${exports.NOTIFICATION_RECIPIENTS_MAX} user uuids`);
237
+ }
238
+ const out = new Set();
239
+ for (const id of v) {
240
+ const canonical = (0, engine_1.canonicalUuid)(id);
241
+ if (canonical === null) {
242
+ throw new ports_js_1.NotificationValidationError('recipients', 'must contain user uuids only');
243
+ }
244
+ out.add(canonical);
245
+ }
246
+ return [...out];
247
+ }
@@ -0,0 +1,57 @@
1
+ import type { SessionTokenBinder } from '../request-auth/session-token-binder.js';
2
+ import type { MfaRequirementService } from '../session/mfa-requirement-service.js';
3
+ import { type NotificationCategory, type NotificationStore } from './ports.js';
4
+ /** One notice as the API returns it: no tenant id, user id or dedupe key (SPEC §5D). */
5
+ export interface NotificationView {
6
+ readonly uuid: string;
7
+ readonly category: NotificationCategory;
8
+ readonly kind: string;
9
+ readonly title: string;
10
+ readonly body: string | null;
11
+ readonly link: string | null;
12
+ /** ISO 8601. */
13
+ readonly createdAt: string;
14
+ /** ISO 8601; `null` ⇒ unread. */
15
+ readonly readAt: string | null;
16
+ }
17
+ export interface NotificationListView {
18
+ readonly total: number;
19
+ readonly rows: NotificationView[];
20
+ readonly page: number;
21
+ readonly limit: number;
22
+ }
23
+ export interface NotificationUnreadCountView {
24
+ readonly announcement: number;
25
+ readonly personal: number;
26
+ readonly total: number;
27
+ }
28
+ type QueryParams = Record<string, unknown> | undefined;
29
+ export declare class NotificationsController {
30
+ private readonly binder?;
31
+ private readonly store?;
32
+ private readonly mfaRequirement?;
33
+ constructor(binder?: SessionTokenBinder | undefined, store?: (NotificationStore | null) | undefined, mfaRequirement?: (MfaRequirementService | null) | undefined);
34
+ /**
35
+ * The caller's claims after the mandatory-2FA refresh and the restriction gate (mirrors
36
+ * `AccountController.requireClaims` + `enforceReenroll(claims, false)`): no notification route is
37
+ * allowed during a restriction.
38
+ */
39
+ private requireSession;
40
+ private requireStore;
41
+ /** `GET /account/notifications` — newest first ⇒ `{ total, rows, page, limit }`. */
42
+ list(authorization: string | undefined, query: QueryParams): Promise<NotificationListView>;
43
+ /** `GET /account/notifications/unread-count` ⇒ `{ announcement, personal, total }`. */
44
+ unreadCount(authorization: string | undefined): Promise<NotificationUnreadCountView>;
45
+ /** `POST /account/notifications/read-all?category=` ⇒ `{ updated }`. */
46
+ readAll(authorization: string | undefined, query: QueryParams): Promise<{
47
+ updated: number;
48
+ }>;
49
+ /**
50
+ * `POST /account/notifications/:uuid/read` ⇒ 204 (idempotent: the first read time is kept).
51
+ * The uuid is canonicalised here, after the session check (401 before 400), as
52
+ * `/account/mfa/devices/:id` does.
53
+ */
54
+ read(authorization: string | undefined, rawUuid: string): Promise<void>;
55
+ }
56
+ export {};
57
+ //# sourceMappingURL=notifications.controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications.controller.d.ts","sourceRoot":"","sources":["../../src/notifications/notifications.controller.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAEnF,OAAO,EAEL,KAAK,oBAAoB,EAEzB,KAAK,iBAAiB,EACvB,MAAM,YAAY,CAAC;AASpB,wFAAwF;AACxF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB;IAChB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,iCAAiC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,gBAAgB,EAAE,CAAC;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AA8ED,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;AAEvD,qBACa,uBAAuB;IAKhC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAIxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IAIvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;gBARf,MAAM,CAAC,EAAE,kBAAkB,YAAA,EAI3B,KAAK,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA,EAIhC,cAAc,CAAC,GAAE,qBAAqB,GAAG,IAAI,aAAA;IAGhE;;;;OAIG;YACW,cAAc;IAgB5B,OAAO,CAAC,YAAY;IAKpB,oFAAoF;IAI9E,IAAI,CACkB,aAAa,EAAE,MAAM,GAAG,SAAS,EAClD,KAAK,EAAE,WAAW,GAC1B,OAAO,CAAC,oBAAoB,CAAC;IAoBhC,uFAAuF;IAIjF,WAAW,CACW,aAAa,EAAE,MAAM,GAAG,SAAS,GAC1D,OAAO,CAAC,2BAA2B,CAAC;IAWvC,wEAAwE;IAIlE,OAAO,CACe,aAAa,EAAE,MAAM,GAAG,SAAS,EAClD,KAAK,EAAE,WAAW,GAC1B,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAU/B;;;;OAIG;IAIG,IAAI,CACkB,aAAa,EAAE,MAAM,GAAG,SAAS,EAC5C,OAAO,EAAE,MAAM,GAC7B,OAAO,CAAC,IAAI,CAAC;CAOjB"}