-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy patherrors.ts
More file actions
476 lines (463 loc) · 23.9 KB
/
Copy patherrors.ts
File metadata and controls
476 lines (463 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { unresolvedPostureRemedy } from './unresolved-posture.js';
/**
* Typed sentinel error thrown by `SecurityPlugin` when an operation is
* denied. Caught by `@objectstack/runtime`'s HTTP dispatcher and translated
* to HTTP 403.
*
* ## Two messages, two audiences (#7414)
*
* `message` is what an END USER reads: both transports ship it verbatim as the
* body's human-readable string (`mapDataError`'s `error`, the dispatcher's
* `error.message`) and Console renders it as-is in a toast. `developerMessage`
* is the operator's half — English, API names, the authorization vocabulary
* that explains WHY — and it is the throw site's job to route it somewhere a
* developer reads.
*
* ⛔ `developerMessage` is a sibling of `details`, deliberately NOT a member of
* it. `details` is SERIALISED to the client on the dispatcher transport
* (`http-dispatcher.ts`: `this.error(e.message, 403, { code, ...e.details })`,
* which `buildApiError` puts on the wire as `error.details`), so anything
* placed inside it reaches the browser. A developer sentence that names
* positions and permission sets must not travel that way — see the throw site
* in `security-plugin.ts` and the measurement recorded in
* `permission-denied-user-copy.test.ts`.
*
* ## Both `status` and `statusCode`, like every class in this file
*
* This class used to declare `statusCode` alone — the only class here that
* did — so a door that reads `status` alone derived no status from it.
* Measured on a showcase boot: a plain member's `POST /api/v1/share-links` on
* a record they cannot read answered `500 PERMISSION_DENIED` through
* `plugin-sharing`'s share-link route door (`err?.status ?? 500`), and `403`
* through the runtime dispatcher's `/share-links` domain, for the same throw.
* The class now carries both spellings with equal values, for the reason the
* note below gives its siblings, so a door that reads either one answers
* `403`. `errors.test.ts` holds every error class this module exports to that
* rule.
*/
export class PermissionDeniedError extends Error {
readonly code = 'PERMISSION_DENIED';
readonly status = 403;
readonly statusCode = 403;
readonly details?: Record<string, unknown>;
/**
* The operator-facing half of a refusal whose `message` has been localized
* for an end user. Optional: a denial that never localized its message has
* exactly one audience and carries none.
*/
readonly developerMessage?: string;
constructor(message: string, details?: Record<string, unknown>, developerMessage?: string) {
super(message);
this.name = 'PermissionDeniedError';
this.details = details;
if (developerMessage !== undefined) this.developerMessage = developerMessage;
}
}
/**
* ## The three NON-VERDICT legs of `assertControlledByParentWrite` (#7474)
*
* That gate used to funnel SIX distinct conditions through one `deny()` helper,
* so all six answered `403 PERMISSION_DENIED` with one sentence — "requires
* edit access to its master record". Three of them are genuine authorization
* verdicts (no object-level `update` on the master / the master row outside the
* caller's write RLS / no `edit`-level share grant) and keep that answer
* verbatim. The other three are not verdicts at all, and the sentence was a
* false statement carrying a false remedy: "ask whoever owns the parent record"
* cannot fix a null FK, a deleted row, or a broken `master_detail` declaration.
*
* Maintainer ruling of 2026-08-11 on #7474 split them by true semantics. The
* classes below are that split; the codes come from ADR-0112's closed
* vocabulary rather than new spellings of conditions the catalog already names.
*
* ### Why each carries BOTH `status` and `statusCode`
*
* The doors read different property names, and this gate throws on the DATA
* path, which reaches all of them. `status` is the spelling every door reads
* first, and some read nothing else: `plugin-sharing`'s share-link route door
* (`err?.status ?? 500`), and the sandbox boundary, whose passthrough list
* carries `status` and not `statusCode` (`SANDBOX_ERROR_PASSTHROUGH` in
* `runtime/src/sandbox/quickjs-runner.ts`). The runtime dispatcher's
* `errorFromThrown` and `@objectstack/rest`'s `mapDataError` read `.status`
* then fall back to `.statusCode`. Declaring one spelling would leave a door
* that reads the other deriving a status from nothing — which is the defect
* this split exists to remove, reintroduced at the edge.
*
* ### Why none of them starts with `[Security] Access denied`
*
* That exact prefix is a MATCHER, not a house style: `isPermissionDeniedError`
* below, `mapDataError`, and `rest-server`'s sanitiser all read it as "this is a
* 403". A configuration defect or a missing row phrased with that opening would
* be re-flattened to `403 PERMISSION_DENIED` at the transport and the split
* would be invisible on the wire — so each class opens with a prefix of its
* own, and that is load-bearing.
*
* ### Why the explanation lives in `message`, never in `details`
*
* `details` is not a reliable carrier (#7450 rules it off the dispatcher wire
* entirely, and `mapDataError`'s 4xx passthrough keeps only `error`, `code` and
* `object`), so every one of these messages has to stand on its own. Each is
* written for the APP AUTHOR — the audience the false 403 was hiding the defect
* from — and each names the object, the operation, and the remedy.
*/
/**
* `sharingModel: 'controlled_by_parent'` on an object with no `master_detail`
* relation to derive access from → `422 INVALID_METADATA`.
*
* This is a precisely detectable AUTHORING defect, not an access verdict: the
* object declares that its access is derived from a master and then gives the
* platform no master to derive it from. Disguised as a routine permission
* denial it was the class of thing an author never sees, because 403s on a
* detail object read as ordinary RBAC noise — which is exactly the "declared =
* enforced, and say so loudly" principle inverted.
*
* `INVALID_METADATA` at 422 is the shape `@objectstack/metadata-protocol`
* already uses for "your metadata is broken" (its publish/validate refusals),
* reused here rather than spelled a second way. 422 also keeps the message on
* the wire: the 5xx band drops a producer's prose unconditionally at both
* transports, and prose is the entire remedy for this condition.
*/
export class MasterDetailRelationMissingError extends Error {
readonly code = 'INVALID_METADATA';
readonly status = 422;
readonly statusCode = 422;
constructor(object: string, operation: string) {
super(
`[Security] Configuration defect: ${operation} on '${object}' cannot be authorized because the object `
+ `declares sharingModel 'controlled_by_parent' but has no master_detail relation to derive access from. `
+ `Declare a required master_detail field pointing at the master object, or change sharingModel.`,
);
this.name = 'MasterDetailRelationMissingError';
}
}
/**
* The by-id write names a detail row that does not exist → `404
* RECORD_NOT_FOUND`.
*
* A concurrent delete answered `403 "requires edit access to its master"`
* before this: an answer that leaks less but says something untrue, and one an
* SDK cannot retry or reconcile against.
*
* The 404 does NOT widen what a caller can learn. The row is read under a
* SYSTEM context (`readRowById(…, { isSystem: true })`), so a row hidden from
* the caller by read RLS is still FOUND here and falls to the authorization
* legs; and the object-level CRUD gate (middleware step 2) plus the row-level
* write pre-image check (step 2.7) both run BEFORE this gate — so a caller who
* reaches it already holds `update` on the detail. Absence here is real
* absence.
*/
export class DetailRecordNotFoundError extends Error {
readonly code = 'RECORD_NOT_FOUND';
readonly status = 404;
readonly statusCode = 404;
readonly recordId: unknown;
constructor(object: string, operation: string, recordId: unknown) {
super(
`[Security] Record not found: ${operation} on '${object}' targets record '${String(recordId)}', `
+ `which does not exist.`,
);
this.name = 'DetailRecordNotFoundError';
this.recordId = recordId;
}
}
/**
* The detail carries no value in its master reference, so there is no master to
* derive access from → `422 MISSING_REQUIRED_FIELD`.
*
* Two shapes of one condition, and the wording says which (#5240 — one
* condition, one wording): on `insert` the REQUEST omitted the master FK, which
* the caller fixes by sending it; on any other by-id write the STORED row's FK
* is null, which is a data-integrity defect the caller cannot fix by asking for
* permissions. `MISSING_REQUIRED_FIELD` is the standard catalog's name for both
* — a controlled_by_parent detail without its master reference is precisely a
* required value that is absent.
*
* ## [#8688] …and that reasoning is why the INSERT shape mostly no longer
* reaches this class
*
* The maintainer ruling of 2026-08-15 took the sentence above to its
* conclusion: if the condition is "a required value that is absent", then the
* platform's contract for it is the one every adjacent missing-required-field
* case already gets — `400 VALIDATION_FAILED` carrying `fields[]`, which is
* what lets a form highlight the input — not a `[Security]`-prefixed 422 with
* no `fields[]`. So `assertControlledByParentWrite` now stands down on an
* insert whose master FK is absent and lets `validateRecord` answer, wherever
* validation provably covers that omission.
*
* This class keeps BOTH shapes, and both are live:
*
* • the STORED-ROW shape, unconditionally — no reordering of insert-path
* validation can reach a persisted null FK, and `fields[]` would name a
* field that was never in the request;
* • the INSERT shape for the three declarations validation does NOT cover
* (a `master_detail` with no `required`; `required` + `readonly`;
* `required` + `system`) — there this gate is the only refusal there is,
* and its 422 is what stops an unreadable orphan detail row being minted.
* The 2026-08-16 ruling (commit 8abada3ba) orders a publish-time lint ramp, but
* it has not landed — nothing refuses them at publish today, so an app
* can newly declare one and land here (#8959). This is the answer those
* shapes get for as long as they stay declarable, not merely until some
* legacy app is republished.
*
* The wording is unchanged in both, so a surface that pins either sentence sees
* the same string it always did — what moved is WHICH inserts arrive here.
*/
export class MasterReferenceMissingError extends Error {
readonly code = 'MISSING_REQUIRED_FIELD';
readonly status = 422;
readonly statusCode = 422;
readonly field: string;
readonly recordId: unknown;
constructor(object: string, operation: string, field: string, recordId?: unknown) {
super(
recordId == null
? `[Security] Missing master reference: ${operation} on '${object}' did not supply '${field}'. `
+ `A controlled_by_parent detail derives its access from its master, so '${field}' must carry a `
+ `master record id on every write.`
: `[Security] Missing master reference: ${operation} on '${object}' cannot be authorized because `
+ `record '${String(recordId)}' has no value in '${field}'. A controlled_by_parent detail derives `
+ `its access from its master, so that reference must be populated.`,
);
this.name = 'MasterReferenceMissingError';
this.field = field;
this.recordId = recordId;
}
}
/**
* [#8993] A write round-tripped a masked placeholder — the payload value for a
* `maskingRule` field is a fixed point of its own rule and carries the mask
* character, i.e. it is the string a partially-masked READ served this caller
* (`138****5678`), re-submitted. Writing it through would silently replace the
* real stored value with its mask; refusing loudly is the same stance the FLS
* write gate takes (silent drops hide the boundary from honest clients) →
* `400 VALIDATION_ERROR`.
*
* NOT a permission verdict: the caller may hold full edit rights on the field
* — the payload VALUE is what cannot be meant. Hence the standard-catalog
* validation code at 400, a message prefix outside the `[Security] Access
* denied` matcher (which would re-flatten it to 403 at the transports), and
* both `status` and `statusCode` (the two transports read different property
* names — see the classes above).
*/
export class MaskedValueWriteError extends Error {
readonly code = 'VALIDATION_ERROR';
readonly status = 400;
readonly statusCode = 400;
readonly fields: string[];
constructor(object: string, operation: string, fields: string[]) {
super(
`[Security] Masked value write refused: ${operation} on '${object}' supplies masked placeholder `
+ `values for [${fields.join(', ')}]. These look like a round-trip of a partially masked read `
+ `(field.maskingRule); writing them back would replace the stored values with their masks. `
+ `Resubmit the real values, or omit these fields from the write.`,
);
this.name = 'MaskedValueWriteError';
this.fields = fields;
}
}
/**
* The DB-authored permission-set read did not answer → `500 DATABASE_ERROR`.
*
* ## Why this exists at all — a read that did not happen is not an empty answer
*
* `SecurityPlugin`'s `sys_permission_set` loader is the ENFORCEMENT plane's door
* to DB-authored sets. It used to map three different facts onto one output, the
* empty list: the read succeeded and the catalog is empty; the read THREW; the
* read resolved something the loader could not read. The last two are invented
* answers, and the invention is not benign — "this principal has no permission
* sets" silently withdraws grants that exist, while every request keeps looking
* completely normal. That is the failure the loader's own neighbouring comment
* calls out as the thing not to do: dropping a row "revokes standing access with
* no signal at the moment of loss".
*
* ## Why REFUSING is the right direction, and not this seam's own invention
*
* The consumer already declares the handling. `PermissionEvaluator
* .resolvePermissionSets` catches a throwing loader, keeps the request
* fail-closed (the unresolved sets grant nothing — unchanged), and NAMES the
* failure in a warn, because "without the warn, a transient DB error makes
* custom permission sets silently vanish and the resulting 403s are
* undiagnosable" (#2565). A loader that swallowed its own read failure made
* that warn unreachable: the diagnostic the repo had already built could never
* fire. Throwing restores it. It also matches the maintainer's 2026-08-11
* store-fault ruling for this plugin — FAIL-CLOSED, and a fault PROPAGATES;
* absent means absent and only absent.
*
* ⚠️ Enforcement is unchanged in both directions: before and after, a read that
* did not answer grants nothing. What changes is that it is now DISTINGUISHABLE
* from an empty catalog.
*
* `DATABASE_ERROR` comes from ADR-0112's closed vocabulary rather than a new
* spelling — the condition is exactly "a database operation did not answer".
* The status is declared for consistency with this file's other classes, not
* because the error is expected to reach a transport: its only consumer catches
* it one frame up.
*/
export class PermissionSetReadUnansweredError extends Error {
readonly code = 'DATABASE_ERROR';
readonly status = 500;
readonly statusCode = 500;
/** The set names the unanswered read was asked for. */
readonly names: readonly string[];
constructor(names: readonly string[], detail: string) {
super(
`[Security] The sys_permission_set read for [${names.join(', ')}] did not answer: ${detail}. `
+ `Refusing rather than reporting an empty catalog — an unanswered read reported as "no permission `
+ `sets" silently withdraws grants that exist while every request still looks normal.`,
);
this.name = 'PermissionSetReadUnansweredError';
this.names = [...names];
}
}
/**
* [#18253] `explain` was asked about an object name that no declaration on this
* runtime answers to → `404 OBJECT_NOT_FOUND`.
*
* ## Why this is not a verdict, and why that matters
*
* The explain engine used to walk its nine layers for an unknown name and
* report `object_crud: denies` / `allowed: false` — the byte-identical pair a
* REAL denial produces, on a `200`. A misspelled API name therefore arrived at
* the caller wearing the costume of a permission decision, and the only channel
* that distinguished the two was the layer's prose (#10401/#10424), which no
* client branches on. The tool an administrator opens to answer "why can this
* person see this record" answered a question about a record that does not
* exist, confidently.
*
* Refusing is the honest answer: nothing was evaluated, so there is no verdict
* to report. The maintainer ruling of 2026-09-17 on #18253 (letter B) puts it
* as "a typo must not look like a permission decision".
*
* ## Why `OBJECT_NOT_FOUND` at 404, and nothing newly minted
*
* That pair is what this platform already answers for "the name you asked about
* is not a registered object" — `mapDataError` (`packages/rest/src/error-response.ts`)
* maps a thrown `OBJECT_NOT_FOUND` to 404 for the whole data path, and
* `OBJECT_NOT_FOUND` is a member of the standard catalog (`StandardErrorCode`,
* `packages/spec/src/api/errors.zod.ts`), so it needs no ledger row and no spec
* change. The ruling binds this fix to the envelope the endpoint already uses;
* the REST door emits it through the `/security/explain` family's ONE refusal
* emitter (#8073), so the body is the ADR-0112 D5 envelope by construction.
*
* ## Why BOTH `status` and `statusCode`, and why the prefix is not the matcher
*
* Same reasons the classes above record: the two transports read different
* property names, and `[Security] Access denied` is a MATCHER that would
* re-flatten this refusal into the 403 it exists to stop being.
*
* ## The three unresolved causes are NOT one condition
*
* Only the cause that says "neither the live schema nor the metadata service
* returned a declaration" reaches this class. An unpublished DRAFT is a
* declaration that exists (the remedy is "publish it", and explain already says
* so), and a metadata-store OUTAGE is a read that did not answer — claiming
* either is absent would be manufacturing a fact, which is the same species of
* error as the one this class fixes, pointed the other way.
*/
export class ExplainObjectNotFoundError extends Error {
readonly code = 'OBJECT_NOT_FOUND';
readonly status = 404;
readonly statusCode = 404;
readonly object: string;
readonly operation: string;
constructor(object: string, operation: string) {
super(
`[Security] Unknown object: explain was asked to report '${operation}' access on '${object}', but `
+ `neither the live schema nor the metadata service returned a declaration for it on this runtime. `
+ `No principal was evaluated, so this is NOT an access decision. `
+ `${unresolvedPostureRemedy('unknown')}`,
);
this.name = 'ExplainObjectNotFoundError';
this.object = object;
this.operation = operation;
}
}
/**
* The ADR-0112 code {@link PermissionSetNameConflictError} stamps — see that
* class for why this collision is `UNIQUE_VIOLATION` and why the value is a
* named constant rather than a class-field literal.
*/
export const PERMISSION_SET_NAME_CONFLICT_CODE = 'UNIQUE_VIOLATION';
/** The HTTP status {@link PermissionSetNameConflictError} declares. */
export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409;
/**
* [commit 8f6d83147] The data door's duplicate-name refusal on `sys_permission_set`:
* a set with this machine name already exists in the caller's organization, so
* the insert is refused.
*
* ## Why this is a CLASS and not a bare `Error` with `.status = 409`
*
* It was the bare form until now, and the bare form has no `code`. The flat
* `{ error, code }` responder in `packages/rest` puts a thrown `code` on the
* wire and invents nothing when the producer declared none, so the refusal
* reached the client as prose alone — against ADR-0112's 2026-08-17 amendment
* (#9232), under which the flat door carries the closed member too. Measured
* before the fix: `409 {"error":"[Security] permission set 'showcase_manager'
* already exists","object":"sys_permission_set"}`, with no `code` key at all,
* while an unauthenticated write on the same resource answered
* `401 UNAUTHENTICATED` — so the absence was this producer's, never the door's.
* A dialog that has to branch on the refusal was pushed to string-matching.
*
* ## Why `UNIQUE_VIOLATION` and not a newly minted code
*
* It is the wire identity this platform ALREADY answers for this exact
* condition on this exact column. `sys_permission_set` declares
* `{ fields: ['name'], unique: 'organization' }`, and a collision that reaches
* the storage layer comes back as `409 UNIQUE_VIOLATION` — the reading
* recorded on that index's own comment (#8554) is `org_yi 409
* UNIQUE_VIOLATION`. This middleware refuses the same collision one layer
* earlier, so a second spelling here would make ONE condition answer two
* envelopes depending only on whether the projection's pre-check or the index
* caught it — the drift `@objectstack/rest` and `@objectstack/driver-memory`
* already registered the SAME code to avoid ("the wire identity is
* deliberately the SAME"). #5240's one-condition-one-wording, on the code axis.
*
* ⛔ Not `RESOURCE_CONFLICT` (the standard member 409 derives from): that is
* what the door would supply for a producer that named no condition, and it
* would be the second spelling described above.
*
* ## Why BOTH `status` and `statusCode`
*
* The same reason every class above records: the doors read different property
* names (see "Why each carries BOTH `status` and `statusCode`" above), and this
* throws on the DATA path, which reaches them all.
*
* The message is byte-identical to the bare `Error`'s — the wording was never
* the defect, and the flat door's 4xx arm ships it verbatim.
*
* ## Why the code is a NAMED CONSTANT and not a bare class-field literal
*
* Same spelling `@objectstack/driver-memory` uses for its own registration of
* this code ("via the package's exported `UNIQUE_VIOLATION_CODE` /
* `UNIQUE_VIOLATION_STATUS`"), and the reason is mechanical rather than
* stylistic: `check:error-code-provenance` recognises `objlit`, `assign` and
* `*_CODE` `constdef` stamp sites and is blind to class fields by its own
* declared bounds. Written as a class-field literal this package would have
* become an unlisted EMITTER of a registered code with every gate in the repo
* green — the exact invisibility the ledger header names ("no admission rule
* checks WHO emits, so an unlisted emitter is invisible to every gate the repo
* has", three hand sweeps, #7504 / #13254 / #13353). The constant puts this
* emitter inside the gate's field of view, so the provenance row under
* `@objectstack/plugin-security` is enforced and not merely intended.
*/
export class PermissionSetNameConflictError extends Error {
readonly code = PERMISSION_SET_NAME_CONFLICT_CODE;
readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS;
readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS;
/** The permission-set machine name that was already taken. */
readonly setName: string;
constructor(setName: string) {
super(`[Security] permission set '${setName}' already exists`);
this.name = 'PermissionSetNameConflictError';
this.setName = setName;
}
}
export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError {
if (!e || typeof e !== 'object') return false;
const anyE = e as any;
return (
anyE.name === 'PermissionDeniedError' ||
anyE.code === 'PERMISSION_DENIED' ||
(typeof anyE.message === 'string' && anyE.message.startsWith('[Security] Access denied'))
);
}