@astram/guard 0.23.0 → 0.25.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 (31) 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/config/notification-config.d.ts +20 -0
  8. package/dist/config/notification-config.d.ts.map +1 -0
  9. package/dist/config/notification-config.js +33 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +49 -36
  13. package/dist/module/shield.module.d.ts +12 -0
  14. package/dist/module/shield.module.d.ts.map +1 -1
  15. package/dist/module/shield.module.js +11 -0
  16. package/dist/notifications/mariadb-notification-store.d.ts +38 -0
  17. package/dist/notifications/mariadb-notification-store.d.ts.map +1 -0
  18. package/dist/notifications/mariadb-notification-store.js +151 -0
  19. package/dist/notifications/notification-retention.job.d.ts +52 -0
  20. package/dist/notifications/notification-retention.job.d.ts.map +1 -0
  21. package/dist/notifications/notification-retention.job.js +97 -0
  22. package/dist/notifications/notification.service.d.ts +71 -0
  23. package/dist/notifications/notification.service.d.ts.map +1 -0
  24. package/dist/notifications/notification.service.js +252 -0
  25. package/dist/notifications/notifications.controller.d.ts +57 -0
  26. package/dist/notifications/notifications.controller.d.ts.map +1 -0
  27. package/dist/notifications/notifications.controller.js +257 -0
  28. package/dist/notifications/ports.d.ts +112 -0
  29. package/dist/notifications/ports.d.ts.map +1 -0
  30. package/dist/notifications/ports.js +63 -0
  31. package/package.json +5 -5
@@ -0,0 +1,252 @@
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
+ /**
45
+ * The framework's own kinds. G-027 step 3: `shield.access_changed` (identity's
46
+ * `AccessChangeNotifier`, a personal notice listing the recipient's gained / lost capabilities).
47
+ */
48
+ exports.FRAMEWORK_NOTIFICATION_KINDS = [
49
+ { kind: `${exports.FRAMEWORK_KIND_PREFIX}access_changed`, category: 'personal' },
50
+ ];
51
+ /** Length in characters (code points), as `VARCHAR(n)` in utf8mb4 counts them. */
52
+ function codePoints(s) {
53
+ return Array.from(s).length;
54
+ }
55
+ function isCategory(v) {
56
+ return ports_js_1.NOTIFICATION_CATEGORIES.includes(v);
57
+ }
58
+ /**
59
+ * Build the kind registry from the app's `notificationKinds` (validated at boot, SPEC §5C) and the
60
+ * framework's own kinds. Refuses a malformed or over-long kind, an unknown category, a duplicate,
61
+ * and an app kind under the reserved `shield.` prefix; each error names the kind.
62
+ */
63
+ function resolveNotificationKinds(appKinds, frameworkKinds = exports.FRAMEWORK_NOTIFICATION_KINDS) {
64
+ if (appKinds !== undefined && !Array.isArray(appKinds)) {
65
+ throw new TypeError('notificationKinds must be an array of { kind, category }');
66
+ }
67
+ const registry = new Map();
68
+ const add = (entry, framework) => {
69
+ const kind = entry?.kind;
70
+ const category = entry?.category;
71
+ if (typeof kind !== 'string') {
72
+ throw ports_js_1.NotificationKindError.registration(kind, 'must be a string');
73
+ }
74
+ if (kind.length > ports_js_1.NOTIFICATION_KIND_MAX_LENGTH) {
75
+ throw ports_js_1.NotificationKindError.registration(kind, `is longer than ${ports_js_1.NOTIFICATION_KIND_MAX_LENGTH} characters`);
76
+ }
77
+ if (!ports_js_1.NOTIFICATION_KIND_PATTERN.test(kind)) {
78
+ throw ports_js_1.NotificationKindError.registration(kind, 'must be dotted lower-case segments (e.g. app.event_name)');
79
+ }
80
+ if (!framework && kind.startsWith(exports.FRAMEWORK_KIND_PREFIX)) {
81
+ throw ports_js_1.NotificationKindError.registration(kind, 'uses the reserved shield. prefix');
82
+ }
83
+ if (!isCategory(category)) {
84
+ throw ports_js_1.NotificationKindError.registration(kind, 'needs a category: announcement or personal');
85
+ }
86
+ if (registry.has(kind)) {
87
+ throw ports_js_1.NotificationKindError.registration(kind, 'is registered more than once');
88
+ }
89
+ registry.set(kind, category);
90
+ };
91
+ for (const entry of frameworkKinds)
92
+ add(entry, true);
93
+ for (const entry of appKinds ?? [])
94
+ add(entry, false);
95
+ return registry;
96
+ }
97
+ class NotificationService {
98
+ store;
99
+ pool;
100
+ kinds;
101
+ usersTable;
102
+ membershipTable;
103
+ now;
104
+ logger;
105
+ constructor(store,
106
+ /** Guard's core pool: the recipient filter runs here when no caller `query` is given. */
107
+ pool, kinds,
108
+ /** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
109
+ schemas, options = {}) {
110
+ this.store = store;
111
+ this.pool = pool;
112
+ this.kinds = kinds;
113
+ const s = (0, framework_schema_names_js_1.schemaNamesAtConstruction)(schemas);
114
+ this.usersTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.app, 'users');
115
+ this.membershipTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.app, 'company_membership');
116
+ this.now = options.now ?? Date.now;
117
+ this.logger = options.logger ?? new common_1.Logger('Shield');
118
+ }
119
+ /** Whether `kind` is registered (framework or app). */
120
+ hasKind(kind) {
121
+ return this.kinds.has(kind);
122
+ }
123
+ async post(input, opts = {}) {
124
+ if (typeof input !== 'object' || input === null) {
125
+ throw new ports_js_1.NotificationValidationError('input', 'must be an object');
126
+ }
127
+ if (opts.query !== undefined && typeof opts.query !== 'function') {
128
+ throw new ports_js_1.NotificationValidationError('query', 'must be a function');
129
+ }
130
+ const tenantId = (0, engine_1.canonicalUuid)(input.tenantId);
131
+ if (tenantId === null)
132
+ throw new ports_js_1.NotificationValidationError('tenantId', 'must be a uuid');
133
+ const category = typeof input.kind === 'string' ? this.kinds.get(input.kind) : undefined;
134
+ if (category === undefined)
135
+ throw new ports_js_1.NotificationKindError(input.kind);
136
+ const title = validTitle(input.title);
137
+ const body = validBody(input.body);
138
+ const link = validLink(input.link);
139
+ const dedupeKey = validDedupeKey(input.dedupeKey);
140
+ const recipients = validRecipients(input.recipients);
141
+ const query = opts.query ?? ((sql, params) => this.pool.query(sql, params));
142
+ let created;
143
+ try {
144
+ const active = await this.activeRecipients(query, tenantId, recipients);
145
+ const createdAtMs = this.now();
146
+ const rows = active.map((userId) => ({
147
+ uuid: (0, node_crypto_1.randomUUID)(),
148
+ tenantId,
149
+ userId,
150
+ category,
151
+ kind: input.kind,
152
+ title,
153
+ body,
154
+ link,
155
+ dedupeKey,
156
+ createdAtMs,
157
+ }));
158
+ created = await this.store.insertMany(rows, opts.query ? { query: opts.query } : {});
159
+ }
160
+ catch {
161
+ // The driver message may name tables, values or hosts: never carried (core 50 #10).
162
+ throw new ports_js_1.NotificationUnavailableError();
163
+ }
164
+ const skipped = recipients.length - created;
165
+ this.logger.debug(`[notifications] posted: created=${created} skipped=${skipped}`);
166
+ return { created, skipped };
167
+ }
168
+ /** ONE parameterized SELECT: active users with an active membership in `tenantId`. */
169
+ async activeRecipients(query, tenantId, recipients) {
170
+ const marks = recipients.map(() => '?').join(', ');
171
+ // `cm_status = 'active'` only (SPEC §5B): `cm_is_active` is the user's ONE default-company
172
+ // pointer (identity `MembershipService.setActive`), so requiring it would drop members whose
173
+ // default company is another one.
174
+ const rows = await query(`SELECT u.u_uuid AS id FROM ${this.usersTable} u
175
+ WHERE u.u_uuid IN (${marks}) AND u.u_status = 'active'
176
+ AND EXISTS (SELECT 1 FROM ${this.membershipTable} cm
177
+ WHERE cm.cm_user_uuid = u.u_uuid AND cm.cm_company_uuid = ?
178
+ AND cm.cm_status = 'active')`, [...recipients, tenantId]);
179
+ if (!Array.isArray(rows))
180
+ throw new Error('unexpected recipient result');
181
+ const found = new Set(rows.map((r) => (0, engine_1.canonicalUuid)(r.id)).filter((v) => v !== null));
182
+ // Keep the caller's order; every id came from the canonical recipient list.
183
+ return recipients.filter((id) => found.has(id));
184
+ }
185
+ }
186
+ exports.NotificationService = NotificationService;
187
+ function validTitle(v) {
188
+ if (typeof v !== 'string')
189
+ throw new ports_js_1.NotificationValidationError('title', 'must be a string');
190
+ const title = v.trim();
191
+ const n = codePoints(title);
192
+ if (n < 1 || n > exports.NOTIFICATION_TITLE_MAX) {
193
+ throw new ports_js_1.NotificationValidationError('title', `must be 1 to ${exports.NOTIFICATION_TITLE_MAX} characters`);
194
+ }
195
+ if (CONTROL_CHARS.test(title)) {
196
+ throw new ports_js_1.NotificationValidationError('title', 'must not contain control characters');
197
+ }
198
+ return title;
199
+ }
200
+ function validBody(v) {
201
+ if (v === undefined || v === null || v === '')
202
+ return null;
203
+ if (typeof v !== 'string')
204
+ throw new ports_js_1.NotificationValidationError('body', 'must be a string');
205
+ if (codePoints(v) > exports.NOTIFICATION_BODY_MAX) {
206
+ throw new ports_js_1.NotificationValidationError('body', `must be at most ${exports.NOTIFICATION_BODY_MAX} characters`);
207
+ }
208
+ if (CONTROL_CHARS_EXCEPT_NEWLINE.test(v)) {
209
+ throw new ports_js_1.NotificationValidationError('body', 'must not contain control characters other than newline');
210
+ }
211
+ return v;
212
+ }
213
+ function validLink(v) {
214
+ if (v === undefined || v === null || v === '')
215
+ return null;
216
+ if (typeof v !== 'string')
217
+ throw new ports_js_1.NotificationValidationError('link', 'must be a string');
218
+ if (codePoints(v) > exports.NOTIFICATION_LINK_MAX) {
219
+ throw new ports_js_1.NotificationValidationError('link', `must be at most ${exports.NOTIFICATION_LINK_MAX} characters`);
220
+ }
221
+ // App-relative only: one leading `/` (never `//`, a protocol-relative URL), so no scheme or host
222
+ // can follow; no backslash (browsers treat `/\` like `//`); no control characters.
223
+ if (!v.startsWith('/') || v.startsWith('//')) {
224
+ throw new ports_js_1.NotificationValidationError('link', 'must be an app-relative path starting with /');
225
+ }
226
+ if (v.includes('\\') || CONTROL_CHARS.test(v)) {
227
+ throw new ports_js_1.NotificationValidationError('link', 'must not contain backslashes or control characters');
228
+ }
229
+ return v;
230
+ }
231
+ function validDedupeKey(v) {
232
+ if (v === undefined || v === null)
233
+ return null;
234
+ if (typeof v !== 'string' || !DEDUPE_KEY_PATTERN.test(v)) {
235
+ throw new ports_js_1.NotificationValidationError('dedupeKey', 'must be 1 to 64 of A-Z a-z 0-9 . _ : -');
236
+ }
237
+ return v;
238
+ }
239
+ function validRecipients(v) {
240
+ if (!Array.isArray(v) || v.length < 1 || v.length > exports.NOTIFICATION_RECIPIENTS_MAX) {
241
+ throw new ports_js_1.NotificationValidationError('recipients', `must be a list of 1 to ${exports.NOTIFICATION_RECIPIENTS_MAX} user uuids`);
242
+ }
243
+ const out = new Set();
244
+ for (const id of v) {
245
+ const canonical = (0, engine_1.canonicalUuid)(id);
246
+ if (canonical === null) {
247
+ throw new ports_js_1.NotificationValidationError('recipients', 'must contain user uuids only');
248
+ }
249
+ out.add(canonical);
250
+ }
251
+ return [...out];
252
+ }
@@ -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"}
@@ -0,0 +1,257 @@
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
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.NotificationsController = void 0;
16
+ /**
17
+ * `/account/notifications` — the signed-in user's own notice inbox (G-027 step 2,
18
+ * notification-store SPEC §3, §5D, §13).
19
+ *
20
+ * Self-authenticated exactly like {@link AccountController} (`packages/guard/src/auth/account.controller.ts`):
21
+ * every handler is `@Public()` to the classification guard, resolves the bearer through the
22
+ * {@link SessionTokenBinder} (session tokens only, so an `ask_…` API key is the generic 401), runs
23
+ * the mandatory-2FA refresh on a permission-version move, then the restriction gate. No `decide()`:
24
+ * a user always may read their own notices.
25
+ *
26
+ * Scope is ONLY `claims.id` in `claims.tenantId` (the session's active company); nothing scoping is
27
+ * read from the request (core 50 #4, #7; Shield #4). A notice of another user or company is the
28
+ * same 404 as a missing one.
29
+ *
30
+ * Order on every route: 401 (no/invalid session) → 403 (restricted session) → 501 (store not
31
+ * wired) → 400 (bad query parameter or `:uuid`, naming the parameter, never echoing the value) →
32
+ * store call (any throw ⇒ 503 `notifications_unavailable`, the driver message never surfaces).
33
+ */
34
+ const common_1 = require("@nestjs/common");
35
+ const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
36
+ const uuid_pipe_js_1 = require("../enforcement/uuid.pipe.js");
37
+ const request_principal_js_1 = require("../request-auth/request-principal.js");
38
+ const tokens_js_1 = require("../request-auth/tokens.js");
39
+ const tokens_js_2 = require("../auth/tokens.js");
40
+ const session_restriction_js_1 = require("../session/session-restriction.js");
41
+ const ports_js_1 = require("./ports.js");
42
+ /** Default and maximum page size (SPEC §10). */
43
+ const DEFAULT_LIMIT = 20;
44
+ const MAX_LIMIT = 100;
45
+ /** A decimal integer as a query string (no sign, no exponent, no whitespace). */
46
+ const DIGITS = /^[0-9]{1,16}$/;
47
+ /** The generic 401, as `/account/*` (never distinguishes no token / bad token / API key). */
48
+ function unauthorized() {
49
+ throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
50
+ }
51
+ /** The honest 501 when the store is not wired (as `/account/*` seams). */
52
+ function notAvailable() {
53
+ throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
54
+ }
55
+ /** The one 404 body for a foreign or unknown notice. */
56
+ function notFound() {
57
+ throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
58
+ }
59
+ /** The store could not answer: 503 without the driver message (core 50 #10). */
60
+ function unavailable() {
61
+ throw new common_1.HttpException({ error: 'notifications_unavailable' }, common_1.HttpStatus.SERVICE_UNAVAILABLE);
62
+ }
63
+ /** 400 naming the parameter only. */
64
+ function badParameter(name) {
65
+ throw new common_1.BadRequestException(`${name} is invalid`);
66
+ }
67
+ /** Optional `category`: absent ⇒ both; otherwise exactly one of the two categories. */
68
+ function parseCategory(value) {
69
+ if (value === undefined)
70
+ return undefined;
71
+ if (typeof value !== 'string' ||
72
+ !ports_js_1.NOTIFICATION_CATEGORIES.includes(value)) {
73
+ badParameter('category');
74
+ }
75
+ return value;
76
+ }
77
+ /** Optional `unread`: absent ⇒ false; otherwise `true` or `false`. */
78
+ function parseUnread(value) {
79
+ if (value === undefined)
80
+ return false;
81
+ if (value === 'true')
82
+ return true;
83
+ if (value === 'false')
84
+ return false;
85
+ return badParameter('unread');
86
+ }
87
+ /** Optional positive integer in `[1, max]`; absent ⇒ `fallback`. */
88
+ function parseBoundedInt(name, value, fallback, max) {
89
+ if (value === undefined)
90
+ return fallback;
91
+ if (typeof value !== 'string' || !DIGITS.test(value))
92
+ badParameter(name);
93
+ const n = Number(value);
94
+ if (!Number.isSafeInteger(n) || n < 1 || n > max)
95
+ badParameter(name);
96
+ return n;
97
+ }
98
+ function toView(row) {
99
+ return {
100
+ uuid: row.uuid,
101
+ category: row.category,
102
+ kind: row.kind,
103
+ title: row.title,
104
+ body: row.body,
105
+ link: row.link,
106
+ createdAt: new Date(row.createdAtMs).toISOString(),
107
+ readAt: row.readAtMs === null ? null : new Date(row.readAtMs).toISOString(),
108
+ };
109
+ }
110
+ /** Run one store call; any throw is the fixed 503. */
111
+ async function guarded(call) {
112
+ try {
113
+ return await call();
114
+ }
115
+ catch {
116
+ return unavailable();
117
+ }
118
+ }
119
+ let NotificationsController = class NotificationsController {
120
+ binder;
121
+ store;
122
+ mfaRequirement;
123
+ constructor(binder, store, mfaRequirement) {
124
+ this.binder = binder;
125
+ this.store = store;
126
+ this.mfaRequirement = mfaRequirement;
127
+ }
128
+ /**
129
+ * The caller's claims after the mandatory-2FA refresh and the restriction gate (mirrors
130
+ * `AccountController.requireClaims` + `enforceReenroll(claims, false)`): no notification route is
131
+ * allowed during a restriction.
132
+ */
133
+ async requireSession(authorization) {
134
+ if (this.binder === undefined || this.binder === null)
135
+ unauthorized();
136
+ const token = (0, request_principal_js_1.extractBearerToken)(authorization);
137
+ if (token === null)
138
+ unauthorized();
139
+ let claims = await this.binder.resolve(token);
140
+ if (claims === null)
141
+ unauthorized();
142
+ if (this.mfaRequirement !== undefined && this.mfaRequirement !== null) {
143
+ claims = await this.mfaRequirement.refresh(token, claims);
144
+ }
145
+ const reason = (0, session_restriction_js_1.restrictionOf)(claims, Date.now());
146
+ if (reason !== null) {
147
+ throw new common_1.HttpException({ error: (0, session_restriction_js_1.restrictionErrorCode)(reason) }, common_1.HttpStatus.FORBIDDEN);
148
+ }
149
+ return claims;
150
+ }
151
+ requireStore() {
152
+ if (this.store === undefined || this.store === null)
153
+ notAvailable();
154
+ return this.store;
155
+ }
156
+ /** `GET /account/notifications` — newest first ⇒ `{ total, rows, page, limit }`. */
157
+ async list(authorization, query) {
158
+ const claims = await this.requireSession(authorization);
159
+ const store = this.requireStore();
160
+ const category = parseCategory(query?.category);
161
+ const unreadOnly = parseUnread(query?.unread);
162
+ const limit = parseBoundedInt('limit', query?.limit, DEFAULT_LIMIT, MAX_LIMIT);
163
+ const page = parseBoundedInt('page', query?.page, 1, Number.MAX_SAFE_INTEGER);
164
+ // The store refuses an offset past the safe-integer range; answer that as the caller's 400.
165
+ if (!Number.isSafeInteger((page - 1) * limit))
166
+ badParameter('page');
167
+ const result = await guarded(() => store.list(claims.id, claims.tenantId, {
168
+ ...(category !== undefined ? { category } : {}),
169
+ unreadOnly,
170
+ page,
171
+ limit,
172
+ }));
173
+ return { total: result.total, rows: result.rows.map(toView), page, limit };
174
+ }
175
+ /** `GET /account/notifications/unread-count` ⇒ `{ announcement, personal, total }`. */
176
+ async unreadCount(authorization) {
177
+ const claims = await this.requireSession(authorization);
178
+ const store = this.requireStore();
179
+ const counts = await guarded(() => store.countUnread(claims.id, claims.tenantId));
180
+ return {
181
+ announcement: counts.announcement,
182
+ personal: counts.personal,
183
+ total: counts.announcement + counts.personal,
184
+ };
185
+ }
186
+ /** `POST /account/notifications/read-all?category=` ⇒ `{ updated }`. */
187
+ async readAll(authorization, query) {
188
+ const claims = await this.requireSession(authorization);
189
+ const store = this.requireStore();
190
+ const category = parseCategory(query?.category);
191
+ const updated = await guarded(() => store.markAllRead(claims.id, claims.tenantId, category, Date.now()));
192
+ return { updated };
193
+ }
194
+ /**
195
+ * `POST /account/notifications/:uuid/read` ⇒ 204 (idempotent: the first read time is kept).
196
+ * The uuid is canonicalised here, after the session check (401 before 400), as
197
+ * `/account/mfa/devices/:id` does.
198
+ */
199
+ async read(authorization, rawUuid) {
200
+ const claims = await this.requireSession(authorization);
201
+ const store = this.requireStore();
202
+ const uuid = (0, uuid_pipe_js_1.uuidField)(rawUuid, 'uuid');
203
+ const found = await guarded(() => store.markRead(claims.id, claims.tenantId, uuid, Date.now()));
204
+ if (!found)
205
+ notFound();
206
+ }
207
+ };
208
+ exports.NotificationsController = NotificationsController;
209
+ __decorate([
210
+ (0, classification_decorator_js_1.Public)(),
211
+ (0, common_1.Get)(),
212
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
213
+ __param(0, (0, common_1.Headers)('authorization')),
214
+ __param(1, (0, common_1.Query)()),
215
+ __metadata("design:type", Function),
216
+ __metadata("design:paramtypes", [Object, Object]),
217
+ __metadata("design:returntype", Promise)
218
+ ], NotificationsController.prototype, "list", null);
219
+ __decorate([
220
+ (0, classification_decorator_js_1.Public)(),
221
+ (0, common_1.Get)('unread-count'),
222
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
223
+ __param(0, (0, common_1.Headers)('authorization')),
224
+ __metadata("design:type", Function),
225
+ __metadata("design:paramtypes", [Object]),
226
+ __metadata("design:returntype", Promise)
227
+ ], NotificationsController.prototype, "unreadCount", null);
228
+ __decorate([
229
+ (0, classification_decorator_js_1.Public)(),
230
+ (0, common_1.Post)('read-all'),
231
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
232
+ __param(0, (0, common_1.Headers)('authorization')),
233
+ __param(1, (0, common_1.Query)()),
234
+ __metadata("design:type", Function),
235
+ __metadata("design:paramtypes", [Object, Object]),
236
+ __metadata("design:returntype", Promise)
237
+ ], NotificationsController.prototype, "readAll", null);
238
+ __decorate([
239
+ (0, classification_decorator_js_1.Public)(),
240
+ (0, common_1.Post)(':uuid/read'),
241
+ (0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
242
+ __param(0, (0, common_1.Headers)('authorization')),
243
+ __param(1, (0, common_1.Param)('uuid')),
244
+ __metadata("design:type", Function),
245
+ __metadata("design:paramtypes", [Object, String]),
246
+ __metadata("design:returntype", Promise)
247
+ ], NotificationsController.prototype, "read", null);
248
+ exports.NotificationsController = NotificationsController = __decorate([
249
+ (0, common_1.Controller)('account/notifications'),
250
+ __param(0, (0, common_1.Optional)()),
251
+ __param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_TOKEN_BINDER)),
252
+ __param(1, (0, common_1.Optional)()),
253
+ __param(1, (0, common_1.Inject)(tokens_js_2.SHIELD_NOTIFICATION_STORE)),
254
+ __param(2, (0, common_1.Optional)()),
255
+ __param(2, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_REQUIREMENT)),
256
+ __metadata("design:paramtypes", [Function, Object, Object])
257
+ ], NotificationsController);