@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,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);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Notification store contract (G-027 step 2, notification-store SPEC §5A, §5B, §10).
3
+ *
4
+ * One per-user, tenant-walled inbox in `<ns>_identity.user_notification` (identity migration
5
+ * 108). Every per-user statement is scoped by BOTH the user and the tenant (the session's active
6
+ * company): a notice of another user or company is indistinguishable from a missing one (core 50
7
+ * #4, #7; Shield #4). Notices never grant anything and never touch catalog, grants or `decide()`.
8
+ *
9
+ * Errors carry the FIELD, kind or variable name only, never a submitted value (core 50 #10).
10
+ */
11
+ /** The two inbox categories (SPEC §10). The category always comes from the kind registration. */
12
+ export declare const NOTIFICATION_CATEGORIES: readonly ["announcement", "personal"];
13
+ export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number];
14
+ /** One `notificationKinds` registration (`ShieldModule.forRoot({ notificationKinds })`). */
15
+ export interface NotificationKindRegistration {
16
+ readonly kind: string;
17
+ readonly category: NotificationCategory;
18
+ }
19
+ /** A stored notice as the store returns it (no tenant id, user id or dedupe key). */
20
+ export interface NotificationRow {
21
+ readonly uuid: string;
22
+ readonly category: NotificationCategory;
23
+ readonly kind: string;
24
+ readonly title: string;
25
+ readonly body: string | null;
26
+ readonly link: string | null;
27
+ readonly createdAtMs: number;
28
+ /** `null` ⇒ unread. */
29
+ readonly readAtMs: number | null;
30
+ }
31
+ /** A row to insert; every field already validated and server-stamped by the service. */
32
+ export interface NotificationInsert {
33
+ readonly uuid: string;
34
+ readonly tenantId: string;
35
+ readonly userId: string;
36
+ readonly category: NotificationCategory;
37
+ readonly kind: string;
38
+ readonly title: string;
39
+ readonly body: string | null;
40
+ readonly link: string | null;
41
+ readonly dedupeKey: string | null;
42
+ readonly createdAtMs: number;
43
+ }
44
+ export interface NotificationListQuery {
45
+ /** Absent ⇒ both categories. */
46
+ readonly category?: NotificationCategory;
47
+ readonly unreadOnly: boolean;
48
+ /** ≥ 1. */
49
+ readonly page: number;
50
+ /** 1–100. */
51
+ readonly limit: number;
52
+ }
53
+ export interface NotificationListResult {
54
+ readonly total: number;
55
+ readonly rows: readonly NotificationRow[];
56
+ }
57
+ export interface NotificationUnreadCounts {
58
+ readonly announcement: number;
59
+ readonly personal: number;
60
+ }
61
+ /** Retention in days (`loadNotificationConfig`): read notices, and every notice. */
62
+ export interface NotificationRetentionPolicy {
63
+ readonly readDays: number;
64
+ readonly maxDays: number;
65
+ }
66
+ /** A query on the caller's open transaction connection (as `MfaResetQuery`). */
67
+ export type NotificationQuery = (sql: string, params?: unknown[]) => Promise<unknown>;
68
+ export interface NotificationWriteOptions {
69
+ /** Run on the caller's open transaction (step 3: notices commit or roll back with the grant). */
70
+ readonly query?: NotificationQuery;
71
+ }
72
+ /** The persistence port; `MariaDbNotificationStore` is the framework default. */
73
+ export interface NotificationStore {
74
+ list(userId: string, tenantId: string, query: NotificationListQuery): Promise<NotificationListResult>;
75
+ countUnread(userId: string, tenantId: string): Promise<NotificationUnreadCounts>;
76
+ /** `true` when the notice exists for this user in this tenant (read already or now). */
77
+ markRead(userId: string, tenantId: string, uuid: string, nowMs: number): Promise<boolean>;
78
+ /** Number of notices changed from unread to read. */
79
+ markAllRead(userId: string, tenantId: string, category: NotificationCategory | undefined, nowMs: number): Promise<number>;
80
+ /** Inserted count; a repeated dedupe key is a no-op, not an error. */
81
+ insertMany(rows: readonly NotificationInsert[], opts?: NotificationWriteOptions): Promise<number>;
82
+ /** Deleted count (at most `maxRows`). */
83
+ purge(nowMs: number, policy: NotificationRetentionPolicy, maxRows: number): Promise<number>;
84
+ }
85
+ /** Kind pattern (SPEC §10): dotted lower-case segments, at least two. */
86
+ export declare const NOTIFICATION_KIND_PATTERN: RegExp;
87
+ export declare const NOTIFICATION_KIND_MAX_LENGTH = 64;
88
+ /**
89
+ * A kind is not registered (`post`), or a `notificationKinds` registration is invalid (boot).
90
+ * The message names the kind only when it is well-formed, so no arbitrary input is echoed.
91
+ */
92
+ export declare class NotificationKindError extends Error {
93
+ readonly name = "NotificationKindError";
94
+ constructor(kind: unknown, reason?: string, printed?: string);
95
+ /**
96
+ * Boot-time refusal of an app's `notificationKinds` entry: names the kind as registered (a
97
+ * developer constant, quoted and cut to 80 characters) so the bad entry can be found.
98
+ */
99
+ static registration(kind: unknown, reason: string): NotificationKindError;
100
+ }
101
+ /** A `post` input field broke the text, link, dedupe or recipient rules (SPEC §10, §11). */
102
+ export declare class NotificationValidationError extends Error {
103
+ readonly field: string;
104
+ readonly name = "NotificationValidationError";
105
+ constructor(field: string, reason: string);
106
+ }
107
+ /** The store could not answer (DB error); the driver message is never carried. */
108
+ export declare class NotificationUnavailableError extends Error {
109
+ readonly name = "NotificationUnavailableError";
110
+ constructor();
111
+ }
112
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.d.ts","sourceRoot":"","sources":["../../src/notifications/ports.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,iGAAiG;AACjG,eAAO,MAAM,uBAAuB,uCAAwC,CAAC;AAC7E,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,uBAAuB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5E,4FAA4F;AAC5F,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC;CACzC;AAED,qFAAqF;AACrF,MAAM,WAAW,eAAe;IAC9B,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,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,uBAAuB;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,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,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,qBAAqB;IACpC,gCAAgC;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IACzC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,WAAW;IACX,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,aAAa;IACb,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,eAAe,EAAE,CAAC;CAC3C;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,oFAAoF;AACpF,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,gFAAgF;AAChF,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtF,MAAM,WAAW,wBAAwB;IACvC,iGAAiG;IACjG,QAAQ,CAAC,KAAK,CAAC,EAAE,iBAAiB,CAAC;CACpC;AAED,iFAAiF;AACjF,MAAM,WAAW,iBAAiB;IAChC,IAAI,CACF,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,qBAAqB,GAC3B,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACnC,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAC;IACjF,wFAAwF;IACxF,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1F,qDAAqD;IACrD,WAAW,CACT,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,oBAAoB,GAAG,SAAS,EAC1C,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,sEAAsE;IACtE,UAAU,CAAC,IAAI,EAAE,SAAS,kBAAkB,EAAE,EAAE,IAAI,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAClG,yCAAyC;IACzC,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,2BAA2B,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC7F;AAED,yEAAyE;AACzE,eAAO,MAAM,yBAAyB,QAA0C,CAAC;AACjF,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAW/C;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,SAAkB,IAAI,2BAA2B;gBACrC,IAAI,EAAE,OAAO,EAAE,MAAM,SAAsB,EAAE,OAAO,CAAC,EAAE,MAAM;IAIzE;;;OAGG;IACH,MAAM,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,qBAAqB;CAK1E;AAED,4FAA4F;AAC5F,qBAAa,2BAA4B,SAAQ,KAAK;IAGlD,QAAQ,CAAC,KAAK,EAAE,MAAM;IAFxB,SAAkB,IAAI,iCAAiC;gBAE5C,KAAK,EAAE,MAAM,EACtB,MAAM,EAAE,MAAM;CAIjB;AAED,kFAAkF;AAClF,qBAAa,4BAA6B,SAAQ,KAAK;IACrD,SAAkB,IAAI,kCAAkC;;CAIzD"}
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ /**
3
+ * Notification store contract (G-027 step 2, notification-store SPEC §5A, §5B, §10).
4
+ *
5
+ * One per-user, tenant-walled inbox in `<ns>_identity.user_notification` (identity migration
6
+ * 108). Every per-user statement is scoped by BOTH the user and the tenant (the session's active
7
+ * company): a notice of another user or company is indistinguishable from a missing one (core 50
8
+ * #4, #7; Shield #4). Notices never grant anything and never touch catalog, grants or `decide()`.
9
+ *
10
+ * Errors carry the FIELD, kind or variable name only, never a submitted value (core 50 #10).
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.NotificationUnavailableError = exports.NotificationValidationError = exports.NotificationKindError = exports.NOTIFICATION_KIND_MAX_LENGTH = exports.NOTIFICATION_KIND_PATTERN = exports.NOTIFICATION_CATEGORIES = void 0;
14
+ /** The two inbox categories (SPEC §10). The category always comes from the kind registration. */
15
+ exports.NOTIFICATION_CATEGORIES = ['announcement', 'personal'];
16
+ /** Kind pattern (SPEC §10): dotted lower-case segments, at least two. */
17
+ exports.NOTIFICATION_KIND_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
18
+ exports.NOTIFICATION_KIND_MAX_LENGTH = 64;
19
+ /** The kind when it is well-formed (safe to print), else a placeholder. */
20
+ function printableKind(kind) {
21
+ return typeof kind === 'string' &&
22
+ kind.length <= exports.NOTIFICATION_KIND_MAX_LENGTH &&
23
+ exports.NOTIFICATION_KIND_PATTERN.test(kind)
24
+ ? kind
25
+ : '(malformed kind)';
26
+ }
27
+ /**
28
+ * A kind is not registered (`post`), or a `notificationKinds` registration is invalid (boot).
29
+ * The message names the kind only when it is well-formed, so no arbitrary input is echoed.
30
+ */
31
+ class NotificationKindError extends Error {
32
+ name = 'NotificationKindError';
33
+ constructor(kind, reason = 'is not registered', printed) {
34
+ super(`notification kind ${printed ?? printableKind(kind)} ${reason}`);
35
+ }
36
+ /**
37
+ * Boot-time refusal of an app's `notificationKinds` entry: names the kind as registered (a
38
+ * developer constant, quoted and cut to 80 characters) so the bad entry can be found.
39
+ */
40
+ static registration(kind, reason) {
41
+ const quoted = typeof kind === 'string' ? JSON.stringify(kind.slice(0, 80)) : `(${typeof kind})`;
42
+ return new NotificationKindError(kind, reason, quoted);
43
+ }
44
+ }
45
+ exports.NotificationKindError = NotificationKindError;
46
+ /** A `post` input field broke the text, link, dedupe or recipient rules (SPEC §10, §11). */
47
+ class NotificationValidationError extends Error {
48
+ field;
49
+ name = 'NotificationValidationError';
50
+ constructor(field, reason) {
51
+ super(`notification ${field} ${reason}`);
52
+ this.field = field;
53
+ }
54
+ }
55
+ exports.NotificationValidationError = NotificationValidationError;
56
+ /** The store could not answer (DB error); the driver message is never carried. */
57
+ class NotificationUnavailableError extends Error {
58
+ name = 'NotificationUnavailableError';
59
+ constructor() {
60
+ super('notifications are unavailable');
61
+ }
62
+ }
63
+ exports.NotificationUnavailableError = NotificationUnavailableError;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Framework authz migration — G-027 step 1 (NAES G-034; docs/features/permission-notification/SPEC.md §4):
3
+ * a permission key's caution text and when it first appeared in the catalog.
4
+ *
5
+ * Adds to `permission`: `perm_caution` (app-declared caution, plain text), `perm_first_seen_at` /
6
+ * `perm_first_seen_version` (written once by the catalog reconcile when it first inserts the key;
7
+ * the version is `APP_RELEASE`, NULL when unset) and `perm_last_activated_at` (first insert and
8
+ * every orphaned → active flip). `perm_label` (007) and `perm_description` (006) are reused.
9
+ *
10
+ * No backfill: rows present when this runs keep NULL `perm_first_seen_at`, meaning "existed before
11
+ * tracking began", so a later admin-task step never raises tasks for them.
12
+ *
13
+ * Idempotent: `ADD COLUMN IF NOT EXISTS` / `DROP COLUMN IF EXISTS`. The timestamps are explicitly
14
+ * `NULL DEFAULT NULL` so MariaDB never auto-initialises them. Literal column names; run by the
15
+ * DDL account.
16
+ */
17
+
18
+ const { frameworkSchemasForMigration } = require('@astram/core');
19
+
20
+ const COLUMNS = [
21
+ ['perm_caution', 'VARCHAR(300) NULL DEFAULT NULL'],
22
+ ['perm_first_seen_at', 'TIMESTAMP NULL DEFAULT NULL'],
23
+ ['perm_first_seen_version', 'VARCHAR(64) NULL DEFAULT NULL'],
24
+ ['perm_last_activated_at', 'TIMESTAMP NULL DEFAULT NULL'],
25
+ ];
26
+
27
+ async function up(ctx) {
28
+ const { query } = ctx;
29
+ const SCHEMA = frameworkSchemasForMigration(ctx, process.env).authz;
30
+ for (const [name, type] of COLUMNS) {
31
+ await query(`ALTER TABLE \`${SCHEMA}\`.\`permission\` ADD COLUMN IF NOT EXISTS ${name} ${type}`);
32
+ }
33
+ }
34
+
35
+ async function down(ctx) {
36
+ const { query } = ctx;
37
+ const SCHEMA = frameworkSchemasForMigration(ctx, process.env).authz;
38
+ for (const [name] of [...COLUMNS].reverse()) {
39
+ await query(`ALTER TABLE \`${SCHEMA}\`.\`permission\` DROP COLUMN IF EXISTS ${name}`);
40
+ }
41
+ }
42
+
43
+ module.exports = { up, down };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guard",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the enforcement guard + real-world adapters (the 'hands'). One global deny-by-default gate every request passes through (ADR-0002), classification decorators, boot-pool assertion (ADR-0018), the forRoot() registration seam, and the DB/Redis/crypto adapters that connect the sealed core to a real backend. Depends on @astram/core (ADR-0003, ADR-0023).",
6
6
  "type": "commonjs",
@@ -36,8 +36,8 @@
36
36
  "argon2": "^0.41.1",
37
37
  "ioredis": "^5.4.1",
38
38
  "mariadb": "^3.5.4",
39
- "@astram/engine": "0.15.0",
40
- "@astram/core": "1.12.0"
39
+ "@astram/core": "1.13.0",
40
+ "@astram/engine": "0.16.1"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@nestjs/common": "^11",
@@ -45,7 +45,7 @@
45
45
  "@nestjs/platform-express": "^11",
46
46
  "reflect-metadata": "^0.2",
47
47
  "rxjs": "^7",
48
- "@astram/messaging": "^0.1.7"
48
+ "@astram/messaging": "^0.1.9"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "@astram/messaging": {
@@ -68,7 +68,7 @@
68
68
  "typescript": "^5.7.0",
69
69
  "unplugin-swc": "^1.5.1",
70
70
  "vitest": "^2.1.8",
71
- "@astram/messaging": "0.1.7"
71
+ "@astram/messaging": "0.1.9"
72
72
  },
73
73
  "repository": {
74
74
  "type": "git",