@webpieces/core-context 0.4.605 → 0.4.607

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/core-context",
3
- "version": "0.4.605",
3
+ "version": "0.4.607",
4
4
  "description": "AsyncLocalStorage-based context management for request-scoped data",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,7 +22,7 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@webpieces/core-util": "0.4.605",
25
+ "@webpieces/core-util": "0.4.607",
26
26
  "@inversifyjs/binding-decorators": "1.1.5",
27
27
  "inversify": "7.10.4",
28
28
  "reflect-metadata": "0.2.2"
@@ -0,0 +1,231 @@
1
+ /**
2
+ * A capability TOKEN, not data — the thing you must be holding to build a {@link CapturedContext} or
3
+ * to unpack one.
4
+ *
5
+ * It exists because `CapturedContext` needs a producer that {@link RequestContext} (a different class,
6
+ * in a different file) can call, and TypeScript's `private` is class-scoped: a plain public
7
+ * `CapturedContext.capture(map)` would be exactly the hand-assembled-payload hole this class exists to
8
+ * close. So the producer takes a token whose constructor is private and whose only instance is
9
+ * {@link INTERNAL}, and this module is deliberately NOT re-exported from the package barrel — in any
10
+ * form, type or value.
11
+ *
12
+ * On its own that stops a consumer NAMING the token but not SUPPLYING one, since `null as never`
13
+ * type-checks. It is the barrel's `export type { CapturedContext }` that finishes the job: with no
14
+ * class object on the package surface there is no `capture(...)` to call, cast or not. The two halves
15
+ * are load-bearing together, which is why each names the other.
16
+ *
17
+ * Per CLAUDE.md this is a class rather than a `Symbol` or an object literal: it is nominal (the private
18
+ * `brand` field stops a structurally-identical `{}` from satisfying it) and it has exactly one
19
+ * instantiation point.
20
+ */
21
+ export declare class ContextCaptureAuthority {
22
+ /**
23
+ * Nominal brand. Without a private member the class is structurally `{}`, and any object at all
24
+ * would typecheck as an authority.
25
+ */
26
+ private readonly brand;
27
+ /** The ONE token that exists. The constructor below is private, so no other can be made. */
28
+ static readonly INTERNAL: ContextCaptureAuthority;
29
+ private constructor();
30
+ /** Kept honest — the brand is read here so it is a real field, not a type-only fiction. */
31
+ describe(): string;
32
+ }
33
+ /**
34
+ * CapturedContext — an OPAQUE snapshot of a {@link RequestContext} scope.
35
+ *
36
+ * ## THE THREE CASES — pick by where the values came from and what may keep the identity
37
+ *
38
+ * | you have | you want | write |
39
+ * |---|---|---|
40
+ * | a genuine prior scope | ALL of it, trusted values included | `runWithContext(snapshot.withTrusted(), fn)` |
41
+ * | a genuine prior scope | the trace fields but NOT the identity | `runWithContext(snapshot.withoutTrusted(), fn)` |
42
+ * | values from OUTSIDE this process | exactly what you re-state, nothing inherited | `RequestContext.runDetachedScope(fn)` |
43
+ *
44
+ * Row 1 is a faithful re-root of a broken async chain — the work continues AS that user. Row 2 is a
45
+ * deliberate PRIVILEGE DROP: a background job keeps `requestId`/`actionId` so it stays greppable, and
46
+ * loses `userId`/`orgId`/roles because it runs as the system (see {@link withoutTrusted}). Row 3 is a
47
+ * different question entirely — the values were never in a scope here, so there is no snapshot to take;
48
+ * see `RequestContext.runDetachedScope`, where each value is written inside the closure with the trust
49
+ * verbs.
50
+ *
51
+ * There is NO bare form of rows 1 and 2. `copyContext()` hands back a `CapturedContext`, and
52
+ * `runWithContext`/`restoreContext` do not accept one — they take the {@link RestorableContext} that
53
+ * {@link withTrusted} and {@link withoutTrusted} produce. Every call site therefore STATES whether the
54
+ * proven identity travels, and neither intent is shorter to type than the other. That is CLAUDE.md shim
55
+ * shape #5 applied here: a bare snapshot silently carrying a user identity is a widening that is an
56
+ * absence rather than a token, and it is ungreppable. Now `grep -rn withTrusted` enumerates every place
57
+ * an identity crosses a scope boundary and `grep -rn withoutTrusted` every deliberate drop.
58
+ *
59
+ * ## What it is for
60
+ *
61
+ * `AsyncLocalStorage` follows `await`, `.then()` and ordinary callbacks on its own, so the vast
62
+ * majority of code never needs this. What it does NOT follow is work whose async chain was BROKEN and
63
+ * re-rooted somewhere else: an item pushed onto an in-memory queue during a request and drained later
64
+ * by a background loop, a batch flushed on a scheduler tick, an `EventEmitter` listener fired from a
65
+ * socket the request does not own, a retry re-armed from a top-level timer, a hand-off to a worker
66
+ * pool. In each of those the work executes under a DIFFERENT (or no) context, so the request id, the
67
+ * log fields and the proven identity would silently vanish from everything the work logs or calls.
68
+ *
69
+ * The answer is two halves: `RequestContext.copyContext()` where the work is ENQUEUED, and
70
+ * `RequestContext.runWithContext(captured.withTrusted(), fn)` — or `.withoutTrusted()`, or
71
+ * `restoreContext(...)` of either — where it RUNS. The narrowing is not optional; see the table above.
72
+ *
73
+ * ## Why it is opaque instead of a `Map<string, unknown>`
74
+ *
75
+ * A restored context legitimately contains TRUSTED values — reinstating what the original scope had
76
+ * proven is the entire point — so the restore side cannot type-check its payload the way
77
+ * `putTrusted`/`getTrusted` do. That left the `Map`-taking signature that this class DELETED (a
78
+ * now-removed `setContext(map)`, and `runWithContext(map, fn)`) as a complete bypass of the trust
79
+ * system: handing it `new Map([['userId', 'victim']])` forged a proven identity in one line, without
80
+ * ever typing a trust verb, and the only thing standing against it was a doc comment saying "the Map
81
+ * must come from copyContext()". An agent picks whatever compiles, so a doc comment is not
82
+ * enforcement.
83
+ *
84
+ * Making the PAYLOAD opaque solves it without type-checking the contents: the only way to obtain one is
85
+ * a real capture of a real scope, so whatever it holds was, by construction, already in a context that
86
+ * something legitimately wrote. Concretely:
87
+ *
88
+ * - the constructor is `private`, and there is no public factory — {@link capture} demands a
89
+ * {@link ContextCaptureAuthority} that cannot be named outside this package;
90
+ * - the package barrel exports this class as a TYPE ONLY, so a consumer never receives the class
91
+ * object at all and cannot reach `capture` even with a cast. That second half matters: a token whose
92
+ * TYPE is unexported still stops nothing on its own, because `capture(null as never, forgedMap)`
93
+ * type-checks. Withholding the class object is what actually closes it;
94
+ * - the entries live in `#entries`, a genuine ECMAScript private field, so they are unreachable at
95
+ * RUNTIME as well as at compile time — no `Object.keys`, no cast, no index signature;
96
+ * - the map is defensively copied ON CAPTURE, again on each NARROWING, and again ON RESTORE, so a
97
+ * caller who still holds the live context (or who keeps writing to it after capturing) cannot reach
98
+ * through the snapshot, narrowing never mutates the capture it came from, and a snapshot can be
99
+ * restored repeatedly without the first restore's mutations bleeding into the second.
100
+ *
101
+ * There is deliberately no reader: nothing hands the entries back out. That is why `getAll()` is gone
102
+ * rather than re-typed to return one of these — a `CapturedContext` you cannot read is useless as a
103
+ * `getAll`, and a readable one would be the raw enumeration of every trusted value all over again.
104
+ *
105
+ * The one residual: a consumer holding a NARROWED snapshot can still cast a token into
106
+ * {@link RestorableContext.toFreshStore} and read the entries back out as a plain Map. That is knowingly accepted, and it is the same asymmetry
107
+ * `RequestContext.getAny` states — FORGING a trusted value is the dangerous direction and is closed
108
+ * here; reading one you were already legitimately handed, without saying `getTrusted`, costs you
109
+ * nothing but the type. Closing it too would mean no method could take the token at all, which is to
110
+ * say no restore could exist.
111
+ */
112
+ export declare class CapturedContext {
113
+ #private;
114
+ /**
115
+ * PRIVATE — a CapturedContext can only come from {@link capture}, which in turn can only be called
116
+ * by code holding a {@link ContextCaptureAuthority}. Copies the map so the snapshot is never a
117
+ * window onto a live store.
118
+ */
119
+ private constructor();
120
+ /**
121
+ * The ONLY producer. `authority` is a compile-time capability, not a runtime check — so it is
122
+ * referenced below only to keep it from being an unused parameter. Note the token alone is not the
123
+ * guarantee (a cast can supply one); the guarantee is that the barrel exports this class as a TYPE
124
+ * ONLY, so no consumer ever holds the class object this static hangs off. See the class doc.
125
+ */
126
+ static capture(authority: ContextCaptureAuthority, live: Map<string, unknown>): CapturedContext;
127
+ /**
128
+ * Carry EVERY value onward, the proven identity included — the faithful re-root. The work runs AS
129
+ * that user: `getTrusted(USER_ID)` inside it answers exactly what it answered in the original scope,
130
+ * which is the entire point when a request's own continuation was re-rooted onto a queue or a timer.
131
+ *
132
+ * Said OUT LOUD, because it is the wide branch. A bare snapshot is deliberately not accepted by
133
+ * `runWithContext`/`restoreContext` (see the class doc), so the identity never crosses a scope
134
+ * boundary by default or by omission, and `grep -rn withTrusted` enumerates every place it does.
135
+ *
136
+ * NON-MUTATING, like its sibling — the receiver is unchanged, so ONE snapshot can be narrowed both
137
+ * ways at two different call sites.
138
+ */
139
+ withTrusted(): RestorableContext;
140
+ /**
141
+ * Carry only the UNTRUSTED values — a deliberate PRIVILEGE DROP.
142
+ *
143
+ * ```typescript
144
+ * const snapshot = RequestContext.copyContext();
145
+ * RequestContext.runWithContext(snapshot.withTrusted(), fn); // runs AS that user
146
+ * RequestContext.runWithContext(snapshot.withoutTrusted(), fn); // runs as the SYSTEM
147
+ * ```
148
+ *
149
+ * The case: a background job or fire-and-forget task spawned during a request should keep the
150
+ * untrusted trace fields — `requestId`, `actionId` — so its log lines are still greppable back to
151
+ * the click that caused them, but it must NOT keep `userId` / `orgId` / roles, because it executes
152
+ * as the system rather than as that user. Carrying the proven identity onward would make every
153
+ * downstream authorization decision think the user is still on the other end of the wire.
154
+ *
155
+ * A METHOD PAIR, never a `keepTrusted: boolean` on {@link RequestContext.runWithContext}: a
156
+ * parameter makes the two intents equally easy to type and impossible to grep, and a defaulted one
157
+ * makes the permissive branch the shortest thing to write — CLAUDE.md shim shape #5, "a widening
158
+ * that is an ABSENCE rather than a token", the same reason `@AuthJwt({allRolesAllowed: true})` says
159
+ * the wide grant out loud. As a transform on the SNAPSHOT rather than a second capture mechanism it
160
+ * composes with BOTH consumers — `runWithContext` and `restoreContext` — for free.
161
+ *
162
+ * NON-MUTATING: the receiver is untouched, so one snapshot can be used both ways.
163
+ *
164
+ * NO AUTHORITY TOKEN, deliberately, and it must not grow one. The token on {@link capture} exists
165
+ * because CONSTRUCTING a snapshot from arbitrary entries forges trust. This direction only ever
166
+ * REMOVES entries: whatever survives was already in a real capture of a real scope, so the result
167
+ * is strictly less privileged than the object the caller is already holding. Dropping cannot forge.
168
+ *
169
+ * WHAT SURVIVES is exactly "registered as an UNTRUSTED {@link ContextKey}". Trusted keys go, and so
170
+ * do names the {@link HeaderRegistry} does not know — the framework's reserved slots (the
171
+ * `HttpRequest`, the AuthFilter principal, the Cloud Tasks schedule frame), which carry no declared
172
+ * trust and are the caller's identity and connection rather than trace fields. A privilege drop
173
+ * that guessed in the permissive direction would not be one. For the same reason, with no registry
174
+ * configured NOTHING is knowably untrusted and the result is empty — always the safe answer, since
175
+ * this method's only job is to remove.
176
+ */
177
+ withoutTrusted(): RestorableContext;
178
+ /**
179
+ * How many entries the snapshot holds. The one thing it will tell you about itself — a count is
180
+ * not a value, so it leaks nothing, and it lets a caller (and a test) see that a capture taken
181
+ * outside an active scope is simply empty rather than an error.
182
+ */
183
+ size(): number;
184
+ }
185
+ /**
186
+ * A snapshot whose TRUST INTENT has been stated — the only thing `RequestContext.restoreContext` and
187
+ * `RequestContext.runWithContext` accept.
188
+ *
189
+ * It exists to make the wide choice unskippable. A single type would have meant
190
+ * `runWithContext(snapshot, fn)` compiling next to `runWithContext(snapshot.withTrusted(), fn)`: two
191
+ * spellings of one thing (shim shape #1), with the shorter one silently carrying a user identity into
192
+ * work that may have no business running as that user. Splitting the type deletes the default — a
193
+ * capture is inert until it says which it means — so there is exactly one spelling per intent, and
194
+ * `grep -rn withTrusted` / `grep -rn withoutTrusted` enumerate the two populations of call sites.
195
+ *
196
+ * Two CLASSES rather than a phantom type parameter on {@link CapturedContext}, even though this repo
197
+ * uses that trick on `ContextKey<V, T extends Trust>`. There the parameter rides along with a key that
198
+ * consumers name constantly and read values through, so it earns its complexity. Here the two states
199
+ * have DIFFERENT MEMBERS — a capture can only be narrowed, a narrowed one can only be restored — and a
200
+ * type that changes its members between states is a second class, not a second type argument. Naming
201
+ * it also gives the field/queue-entry type a consumer holds a name that says what it is.
202
+ *
203
+ * The #622 opacity guarantees are unchanged and are why this class is also barrel-exported as a TYPE
204
+ * ONLY: private constructor, a capability token on the producer, a real `#entries` private field, and
205
+ * defensive copies on the way in and on the way out.
206
+ */
207
+ export declare class RestorableContext {
208
+ #private;
209
+ /** PRIVATE — {@link of} is the only producer, and only this module can call it. */
210
+ private constructor();
211
+ /**
212
+ * The ONLY producer, called by `CapturedContext.withTrusted()` / `withoutTrusted()`. Guarded the
213
+ * same way `capture` is: a token no consumer can name, and a barrel that exports this class as a
214
+ * TYPE ONLY, so the class object — and with it this static — never reaches a consumer at all.
215
+ */
216
+ static of(authority: ContextCaptureAuthority, entries: Map<string, unknown>): RestorableContext;
217
+ /**
218
+ * Overwrite a LIVE store with this snapshot — the engine behind `RequestContext.restoreContext`.
219
+ * Write-only by design: it pushes entries in and hands nothing back, so it is not a side door onto
220
+ * the snapshot's contents.
221
+ */
222
+ restoreInto(authority: ContextCaptureAuthority, live: Map<string, unknown>): void;
223
+ /**
224
+ * A FRESH store holding this snapshot — the engine behind `RequestContext.runWithContext`, which
225
+ * opens a new AsyncLocalStorage scope around it. Fresh (a copy) rather than the internal map, so
226
+ * everything the restored scope writes stays in that scope and the snapshot remains reusable.
227
+ */
228
+ toFreshStore(authority: ContextCaptureAuthority): Map<string, unknown>;
229
+ /** How many entries survived the narrowing. A count is not a value, so it leaks nothing. */
230
+ size(): number;
231
+ }
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RestorableContext = exports.CapturedContext = exports.ContextCaptureAuthority = void 0;
4
+ const core_util_1 = require("@webpieces/core-util");
5
+ /**
6
+ * A capability TOKEN, not data — the thing you must be holding to build a {@link CapturedContext} or
7
+ * to unpack one.
8
+ *
9
+ * It exists because `CapturedContext` needs a producer that {@link RequestContext} (a different class,
10
+ * in a different file) can call, and TypeScript's `private` is class-scoped: a plain public
11
+ * `CapturedContext.capture(map)` would be exactly the hand-assembled-payload hole this class exists to
12
+ * close. So the producer takes a token whose constructor is private and whose only instance is
13
+ * {@link INTERNAL}, and this module is deliberately NOT re-exported from the package barrel — in any
14
+ * form, type or value.
15
+ *
16
+ * On its own that stops a consumer NAMING the token but not SUPPLYING one, since `null as never`
17
+ * type-checks. It is the barrel's `export type { CapturedContext }` that finishes the job: with no
18
+ * class object on the package surface there is no `capture(...)` to call, cast or not. The two halves
19
+ * are load-bearing together, which is why each names the other.
20
+ *
21
+ * Per CLAUDE.md this is a class rather than a `Symbol` or an object literal: it is nominal (the private
22
+ * `brand` field stops a structurally-identical `{}` from satisfying it) and it has exactly one
23
+ * instantiation point.
24
+ */
25
+ class ContextCaptureAuthority {
26
+ /**
27
+ * Nominal brand. Without a private member the class is structurally `{}`, and any object at all
28
+ * would typecheck as an authority.
29
+ */
30
+ brand = 'webpieces.context-capture';
31
+ /** The ONE token that exists. The constructor below is private, so no other can be made. */
32
+ static INTERNAL = new ContextCaptureAuthority();
33
+ constructor() { }
34
+ /** Kept honest — the brand is read here so it is a real field, not a type-only fiction. */
35
+ describe() {
36
+ return this.brand;
37
+ }
38
+ }
39
+ exports.ContextCaptureAuthority = ContextCaptureAuthority;
40
+ /**
41
+ * CapturedContext — an OPAQUE snapshot of a {@link RequestContext} scope.
42
+ *
43
+ * ## THE THREE CASES — pick by where the values came from and what may keep the identity
44
+ *
45
+ * | you have | you want | write |
46
+ * |---|---|---|
47
+ * | a genuine prior scope | ALL of it, trusted values included | `runWithContext(snapshot.withTrusted(), fn)` |
48
+ * | a genuine prior scope | the trace fields but NOT the identity | `runWithContext(snapshot.withoutTrusted(), fn)` |
49
+ * | values from OUTSIDE this process | exactly what you re-state, nothing inherited | `RequestContext.runDetachedScope(fn)` |
50
+ *
51
+ * Row 1 is a faithful re-root of a broken async chain — the work continues AS that user. Row 2 is a
52
+ * deliberate PRIVILEGE DROP: a background job keeps `requestId`/`actionId` so it stays greppable, and
53
+ * loses `userId`/`orgId`/roles because it runs as the system (see {@link withoutTrusted}). Row 3 is a
54
+ * different question entirely — the values were never in a scope here, so there is no snapshot to take;
55
+ * see `RequestContext.runDetachedScope`, where each value is written inside the closure with the trust
56
+ * verbs.
57
+ *
58
+ * There is NO bare form of rows 1 and 2. `copyContext()` hands back a `CapturedContext`, and
59
+ * `runWithContext`/`restoreContext` do not accept one — they take the {@link RestorableContext} that
60
+ * {@link withTrusted} and {@link withoutTrusted} produce. Every call site therefore STATES whether the
61
+ * proven identity travels, and neither intent is shorter to type than the other. That is CLAUDE.md shim
62
+ * shape #5 applied here: a bare snapshot silently carrying a user identity is a widening that is an
63
+ * absence rather than a token, and it is ungreppable. Now `grep -rn withTrusted` enumerates every place
64
+ * an identity crosses a scope boundary and `grep -rn withoutTrusted` every deliberate drop.
65
+ *
66
+ * ## What it is for
67
+ *
68
+ * `AsyncLocalStorage` follows `await`, `.then()` and ordinary callbacks on its own, so the vast
69
+ * majority of code never needs this. What it does NOT follow is work whose async chain was BROKEN and
70
+ * re-rooted somewhere else: an item pushed onto an in-memory queue during a request and drained later
71
+ * by a background loop, a batch flushed on a scheduler tick, an `EventEmitter` listener fired from a
72
+ * socket the request does not own, a retry re-armed from a top-level timer, a hand-off to a worker
73
+ * pool. In each of those the work executes under a DIFFERENT (or no) context, so the request id, the
74
+ * log fields and the proven identity would silently vanish from everything the work logs or calls.
75
+ *
76
+ * The answer is two halves: `RequestContext.copyContext()` where the work is ENQUEUED, and
77
+ * `RequestContext.runWithContext(captured.withTrusted(), fn)` — or `.withoutTrusted()`, or
78
+ * `restoreContext(...)` of either — where it RUNS. The narrowing is not optional; see the table above.
79
+ *
80
+ * ## Why it is opaque instead of a `Map<string, unknown>`
81
+ *
82
+ * A restored context legitimately contains TRUSTED values — reinstating what the original scope had
83
+ * proven is the entire point — so the restore side cannot type-check its payload the way
84
+ * `putTrusted`/`getTrusted` do. That left the `Map`-taking signature that this class DELETED (a
85
+ * now-removed `setContext(map)`, and `runWithContext(map, fn)`) as a complete bypass of the trust
86
+ * system: handing it `new Map([['userId', 'victim']])` forged a proven identity in one line, without
87
+ * ever typing a trust verb, and the only thing standing against it was a doc comment saying "the Map
88
+ * must come from copyContext()". An agent picks whatever compiles, so a doc comment is not
89
+ * enforcement.
90
+ *
91
+ * Making the PAYLOAD opaque solves it without type-checking the contents: the only way to obtain one is
92
+ * a real capture of a real scope, so whatever it holds was, by construction, already in a context that
93
+ * something legitimately wrote. Concretely:
94
+ *
95
+ * - the constructor is `private`, and there is no public factory — {@link capture} demands a
96
+ * {@link ContextCaptureAuthority} that cannot be named outside this package;
97
+ * - the package barrel exports this class as a TYPE ONLY, so a consumer never receives the class
98
+ * object at all and cannot reach `capture` even with a cast. That second half matters: a token whose
99
+ * TYPE is unexported still stops nothing on its own, because `capture(null as never, forgedMap)`
100
+ * type-checks. Withholding the class object is what actually closes it;
101
+ * - the entries live in `#entries`, a genuine ECMAScript private field, so they are unreachable at
102
+ * RUNTIME as well as at compile time — no `Object.keys`, no cast, no index signature;
103
+ * - the map is defensively copied ON CAPTURE, again on each NARROWING, and again ON RESTORE, so a
104
+ * caller who still holds the live context (or who keeps writing to it after capturing) cannot reach
105
+ * through the snapshot, narrowing never mutates the capture it came from, and a snapshot can be
106
+ * restored repeatedly without the first restore's mutations bleeding into the second.
107
+ *
108
+ * There is deliberately no reader: nothing hands the entries back out. That is why `getAll()` is gone
109
+ * rather than re-typed to return one of these — a `CapturedContext` you cannot read is useless as a
110
+ * `getAll`, and a readable one would be the raw enumeration of every trusted value all over again.
111
+ *
112
+ * The one residual: a consumer holding a NARROWED snapshot can still cast a token into
113
+ * {@link RestorableContext.toFreshStore} and read the entries back out as a plain Map. That is knowingly accepted, and it is the same asymmetry
114
+ * `RequestContext.getAny` states — FORGING a trusted value is the dangerous direction and is closed
115
+ * here; reading one you were already legitimately handed, without saying `getTrusted`, costs you
116
+ * nothing but the type. Closing it too would mean no method could take the token at all, which is to
117
+ * say no restore could exist.
118
+ */
119
+ class CapturedContext {
120
+ /**
121
+ * A real ECMAScript private field, not a TypeScript `private`. The distinction matters here: `#`
122
+ * is enforced by the runtime, so the snapshot's contents cannot be reached by a cast, by
123
+ * `Object.entries`, or by `as unknown as { entries: Map<string, unknown> }`.
124
+ */
125
+ // webpieces-disable no-any-unknown -- the context store is deliberately type-erased; each ContextKey carries its own value type and the typed verbs re-apply it on read
126
+ #entries;
127
+ /**
128
+ * PRIVATE — a CapturedContext can only come from {@link capture}, which in turn can only be called
129
+ * by code holding a {@link ContextCaptureAuthority}. Copies the map so the snapshot is never a
130
+ * window onto a live store.
131
+ */
132
+ // webpieces-disable no-any-unknown -- see #entries
133
+ constructor(entries) {
134
+ this.#entries = new Map(entries);
135
+ }
136
+ /**
137
+ * The ONLY producer. `authority` is a compile-time capability, not a runtime check — so it is
138
+ * referenced below only to keep it from being an unused parameter. Note the token alone is not the
139
+ * guarantee (a cast can supply one); the guarantee is that the barrel exports this class as a TYPE
140
+ * ONLY, so no consumer ever holds the class object this static hangs off. See the class doc.
141
+ */
142
+ // webpieces-disable no-any-unknown -- see #entries
143
+ // webpieces-disable no-function-outside-class -- static factory standing in for the (private) constructor; making it an instance method would mean an instance already existed, which is the thing being created
144
+ static capture(authority, live) {
145
+ void authority;
146
+ return new CapturedContext(live);
147
+ }
148
+ /**
149
+ * Carry EVERY value onward, the proven identity included — the faithful re-root. The work runs AS
150
+ * that user: `getTrusted(USER_ID)` inside it answers exactly what it answered in the original scope,
151
+ * which is the entire point when a request's own continuation was re-rooted onto a queue or a timer.
152
+ *
153
+ * Said OUT LOUD, because it is the wide branch. A bare snapshot is deliberately not accepted by
154
+ * `runWithContext`/`restoreContext` (see the class doc), so the identity never crosses a scope
155
+ * boundary by default or by omission, and `grep -rn withTrusted` enumerates every place it does.
156
+ *
157
+ * NON-MUTATING, like its sibling — the receiver is unchanged, so ONE snapshot can be narrowed both
158
+ * ways at two different call sites.
159
+ */
160
+ withTrusted() {
161
+ return RestorableContext.of(ContextCaptureAuthority.INTERNAL, this.#entries);
162
+ }
163
+ /**
164
+ * Carry only the UNTRUSTED values — a deliberate PRIVILEGE DROP.
165
+ *
166
+ * ```typescript
167
+ * const snapshot = RequestContext.copyContext();
168
+ * RequestContext.runWithContext(snapshot.withTrusted(), fn); // runs AS that user
169
+ * RequestContext.runWithContext(snapshot.withoutTrusted(), fn); // runs as the SYSTEM
170
+ * ```
171
+ *
172
+ * The case: a background job or fire-and-forget task spawned during a request should keep the
173
+ * untrusted trace fields — `requestId`, `actionId` — so its log lines are still greppable back to
174
+ * the click that caused them, but it must NOT keep `userId` / `orgId` / roles, because it executes
175
+ * as the system rather than as that user. Carrying the proven identity onward would make every
176
+ * downstream authorization decision think the user is still on the other end of the wire.
177
+ *
178
+ * A METHOD PAIR, never a `keepTrusted: boolean` on {@link RequestContext.runWithContext}: a
179
+ * parameter makes the two intents equally easy to type and impossible to grep, and a defaulted one
180
+ * makes the permissive branch the shortest thing to write — CLAUDE.md shim shape #5, "a widening
181
+ * that is an ABSENCE rather than a token", the same reason `@AuthJwt({allRolesAllowed: true})` says
182
+ * the wide grant out loud. As a transform on the SNAPSHOT rather than a second capture mechanism it
183
+ * composes with BOTH consumers — `runWithContext` and `restoreContext` — for free.
184
+ *
185
+ * NON-MUTATING: the receiver is untouched, so one snapshot can be used both ways.
186
+ *
187
+ * NO AUTHORITY TOKEN, deliberately, and it must not grow one. The token on {@link capture} exists
188
+ * because CONSTRUCTING a snapshot from arbitrary entries forges trust. This direction only ever
189
+ * REMOVES entries: whatever survives was already in a real capture of a real scope, so the result
190
+ * is strictly less privileged than the object the caller is already holding. Dropping cannot forge.
191
+ *
192
+ * WHAT SURVIVES is exactly "registered as an UNTRUSTED {@link ContextKey}". Trusted keys go, and so
193
+ * do names the {@link HeaderRegistry} does not know — the framework's reserved slots (the
194
+ * `HttpRequest`, the AuthFilter principal, the Cloud Tasks schedule frame), which carry no declared
195
+ * trust and are the caller's identity and connection rather than trace fields. A privilege drop
196
+ * that guessed in the permissive direction would not be one. For the same reason, with no registry
197
+ * configured NOTHING is knowably untrusted and the result is empty — always the safe answer, since
198
+ * this method's only job is to remove.
199
+ */
200
+ withoutTrusted() {
201
+ // webpieces-disable no-any-unknown -- the context store is deliberately type-erased; see #entries
202
+ const kept = new Map();
203
+ if (!core_util_1.HeaderRegistry.isConfigured()) {
204
+ return RestorableContext.of(ContextCaptureAuthority.INTERNAL, kept);
205
+ }
206
+ const registry = core_util_1.HeaderRegistry.get();
207
+ for (const name of this.#entries.keys()) {
208
+ const key = registry.findByName(name);
209
+ if (key && key.isUntrusted()) {
210
+ kept.set(name, this.#entries.get(name));
211
+ }
212
+ }
213
+ return RestorableContext.of(ContextCaptureAuthority.INTERNAL, kept);
214
+ }
215
+ /**
216
+ * How many entries the snapshot holds. The one thing it will tell you about itself — a count is
217
+ * not a value, so it leaks nothing, and it lets a caller (and a test) see that a capture taken
218
+ * outside an active scope is simply empty rather than an error.
219
+ */
220
+ size() {
221
+ return this.#entries.size;
222
+ }
223
+ }
224
+ exports.CapturedContext = CapturedContext;
225
+ /**
226
+ * A snapshot whose TRUST INTENT has been stated — the only thing `RequestContext.restoreContext` and
227
+ * `RequestContext.runWithContext` accept.
228
+ *
229
+ * It exists to make the wide choice unskippable. A single type would have meant
230
+ * `runWithContext(snapshot, fn)` compiling next to `runWithContext(snapshot.withTrusted(), fn)`: two
231
+ * spellings of one thing (shim shape #1), with the shorter one silently carrying a user identity into
232
+ * work that may have no business running as that user. Splitting the type deletes the default — a
233
+ * capture is inert until it says which it means — so there is exactly one spelling per intent, and
234
+ * `grep -rn withTrusted` / `grep -rn withoutTrusted` enumerate the two populations of call sites.
235
+ *
236
+ * Two CLASSES rather than a phantom type parameter on {@link CapturedContext}, even though this repo
237
+ * uses that trick on `ContextKey<V, T extends Trust>`. There the parameter rides along with a key that
238
+ * consumers name constantly and read values through, so it earns its complexity. Here the two states
239
+ * have DIFFERENT MEMBERS — a capture can only be narrowed, a narrowed one can only be restored — and a
240
+ * type that changes its members between states is a second class, not a second type argument. Naming
241
+ * it also gives the field/queue-entry type a consumer holds a name that says what it is.
242
+ *
243
+ * The #622 opacity guarantees are unchanged and are why this class is also barrel-exported as a TYPE
244
+ * ONLY: private constructor, a capability token on the producer, a real `#entries` private field, and
245
+ * defensive copies on the way in and on the way out.
246
+ */
247
+ class RestorableContext {
248
+ /** Same real ECMAScript private field, for the same reason — see {@link CapturedContext}. */
249
+ // webpieces-disable no-any-unknown -- the context store is deliberately type-erased; each ContextKey carries its own value type and the typed verbs re-apply it on read
250
+ #entries;
251
+ /** PRIVATE — {@link of} is the only producer, and only this module can call it. */
252
+ // webpieces-disable no-any-unknown -- see #entries
253
+ constructor(entries) {
254
+ this.#entries = new Map(entries);
255
+ }
256
+ /**
257
+ * The ONLY producer, called by `CapturedContext.withTrusted()` / `withoutTrusted()`. Guarded the
258
+ * same way `capture` is: a token no consumer can name, and a barrel that exports this class as a
259
+ * TYPE ONLY, so the class object — and with it this static — never reaches a consumer at all.
260
+ */
261
+ // webpieces-disable no-any-unknown -- see #entries
262
+ // webpieces-disable no-function-outside-class -- static factory standing in for the (private) constructor; an instance method would presuppose the instance being created
263
+ static of(authority, entries) {
264
+ void authority;
265
+ return new RestorableContext(entries);
266
+ }
267
+ /**
268
+ * Overwrite a LIVE store with this snapshot — the engine behind `RequestContext.restoreContext`.
269
+ * Write-only by design: it pushes entries in and hands nothing back, so it is not a side door onto
270
+ * the snapshot's contents.
271
+ */
272
+ // webpieces-disable no-any-unknown -- see #entries
273
+ restoreInto(authority, live) {
274
+ void authority;
275
+ live.clear();
276
+ for (const name of this.#entries.keys()) {
277
+ live.set(name, this.#entries.get(name));
278
+ }
279
+ }
280
+ /**
281
+ * A FRESH store holding this snapshot — the engine behind `RequestContext.runWithContext`, which
282
+ * opens a new AsyncLocalStorage scope around it. Fresh (a copy) rather than the internal map, so
283
+ * everything the restored scope writes stays in that scope and the snapshot remains reusable.
284
+ */
285
+ // webpieces-disable no-any-unknown -- see #entries
286
+ toFreshStore(authority) {
287
+ void authority;
288
+ return new Map(this.#entries);
289
+ }
290
+ /** How many entries survived the narrowing. A count is not a value, so it leaks nothing. */
291
+ size() {
292
+ return this.#entries.size;
293
+ }
294
+ }
295
+ exports.RestorableContext = RestorableContext;
296
+ //# sourceMappingURL=CapturedContext.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CapturedContext.js","sourceRoot":"","sources":["../../../../../packages/core/core-context/src/CapturedContext.ts"],"names":[],"mappings":";;;AAAA,oDAAsD;AAEtD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAa,uBAAuB;IAChC;;;OAGG;IACc,KAAK,GAAW,2BAA2B,CAAC;IAE7D,4FAA4F;IAC5F,MAAM,CAAU,QAAQ,GAAG,IAAI,uBAAuB,EAAE,CAAC;IAEzD,gBAAuB,CAAC;IAExB,2FAA2F;IAC3F,QAAQ;QACJ,OAAO,IAAI,CAAC,KAAK,CAAC;IACtB,CAAC;;AAfL,0DAgBC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8EG;AACH,MAAa,eAAe;IACxB;;;;OAIG;IACH,wKAAwK;IAC/J,QAAQ,CAAuB;IAExC;;;;OAIG;IACH,mDAAmD;IACnD,YAAoB,OAA6B;QAC7C,IAAI,CAAC,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;IACrC,CAAC;IAED;;;;;OAKG;IACH,mDAAmD;IACnD,iNAAiN;IACjN,MAAM,CAAC,OAAO,CAAC,SAAkC,EAAE,IAA0B;QACzE,KAAK,SAAS,CAAC;QACf,OAAO,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC;IACrC,CAAC;IAED;;;;;;;;;;;OAWG;IACH,WAAW;QACP,OAAO,iBAAiB,CAAC,EAAE,CAAC,uBAAuB,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;IACjF,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,cAAc;QACV,kGAAkG;QAClG,MAAM,IAAI,GAAG,IAAI,GAAG,EAAmB,CAAC;QACxC,IAAI,CAAC,0BAAc,CAAC,YAAY,EAAE,EAAE,CAAC;YACjC,OAAO,iBAAiB,CAAC,EAAE,CAAC,uBAAuB,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QACxE,CAAC;QACD,MAAM,QAAQ,GAAG,0BAAc,CAAC,GAAG,EAAE,CAAC;QACtC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC;YACtC,MAAM,GAAG,GAAG,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;YACtC,IAAI,GAAG,IAAI,GAAG,CAAC,WAAW,EAAE,EAAE,CAAC;gBAC3B,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;YAC5C,CAAC;QACL,CAAC;QACD,OAAO,iBAAiB,CAAC,EAAE,CAAC,uBAAuB,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACxE,CAAC;IAED;;;;OAIG;IACH,IAAI;QACA,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC9B,CAAC;CACJ;AA7GD,0CA6GC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAa,iBAAiB;IAC1B,6FAA6F;IAC7F,wKAAwK;IAC/J,QAAQ,CAAuB;IAExC,mFAAmF;IACnF,mDAAmD;IACnD,YAAoB,OAA6B;QAC7C,IAAI,CAAC,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;IACrC,CAAC;IAED;;;;OAIG;IACH,mDAAmD;IACnD,0KAA0K;IAC1K,MAAM,CAAC,EAAE,CAAC,SAAkC,EAAE,OAA6B;QACvE,KAAK,SAAS,CAAC;QACf,OAAO,IAAI,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAC1C,CAAC;IAED;;;;OAIG;IACH,mDAAmD;IACnD,WAAW,CAAC,SAAkC,EAAE,IAA0B;QACtE,KAAK,SAAS,CAAC;QACf,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC;YACtC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;QAC5C,CAAC;IACL,CAAC;IAED;;;;OAIG;IACH,mDAAmD;IACnD,YAAY,CAAC,SAAkC;QAC3C,KAAK,SAAS,CAAC;QACf,OAAO,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAClC,CAAC;IAED,4FAA4F;IAC5F,IAAI;QACA,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC9B,CAAC;CACJ;AApDD,8CAoDC","sourcesContent":["import { HeaderRegistry } from '@webpieces/core-util';\n\n/**\n * A capability TOKEN, not data — the thing you must be holding to build a {@link CapturedContext} or\n * to unpack one.\n *\n * It exists because `CapturedContext` needs a producer that {@link RequestContext} (a different class,\n * in a different file) can call, and TypeScript's `private` is class-scoped: a plain public\n * `CapturedContext.capture(map)` would be exactly the hand-assembled-payload hole this class exists to\n * close. So the producer takes a token whose constructor is private and whose only instance is\n * {@link INTERNAL}, and this module is deliberately NOT re-exported from the package barrel — in any\n * form, type or value.\n *\n * On its own that stops a consumer NAMING the token but not SUPPLYING one, since `null as never`\n * type-checks. It is the barrel's `export type { CapturedContext }` that finishes the job: with no\n * class object on the package surface there is no `capture(...)` to call, cast or not. The two halves\n * are load-bearing together, which is why each names the other.\n *\n * Per CLAUDE.md this is a class rather than a `Symbol` or an object literal: it is nominal (the private\n * `brand` field stops a structurally-identical `{}` from satisfying it) and it has exactly one\n * instantiation point.\n */\nexport class ContextCaptureAuthority {\n /**\n * Nominal brand. Without a private member the class is structurally `{}`, and any object at all\n * would typecheck as an authority.\n */\n private readonly brand: string = 'webpieces.context-capture';\n\n /** The ONE token that exists. The constructor below is private, so no other can be made. */\n static readonly INTERNAL = new ContextCaptureAuthority();\n\n private constructor() {}\n\n /** Kept honest — the brand is read here so it is a real field, not a type-only fiction. */\n describe(): string {\n return this.brand;\n }\n}\n\n/**\n * CapturedContext — an OPAQUE snapshot of a {@link RequestContext} scope.\n *\n * ## THE THREE CASES — pick by where the values came from and what may keep the identity\n *\n * | you have | you want | write |\n * |---|---|---|\n * | a genuine prior scope | ALL of it, trusted values included | `runWithContext(snapshot.withTrusted(), fn)` |\n * | a genuine prior scope | the trace fields but NOT the identity | `runWithContext(snapshot.withoutTrusted(), fn)` |\n * | values from OUTSIDE this process | exactly what you re-state, nothing inherited | `RequestContext.runDetachedScope(fn)` |\n *\n * Row 1 is a faithful re-root of a broken async chain — the work continues AS that user. Row 2 is a\n * deliberate PRIVILEGE DROP: a background job keeps `requestId`/`actionId` so it stays greppable, and\n * loses `userId`/`orgId`/roles because it runs as the system (see {@link withoutTrusted}). Row 3 is a\n * different question entirely — the values were never in a scope here, so there is no snapshot to take;\n * see `RequestContext.runDetachedScope`, where each value is written inside the closure with the trust\n * verbs.\n *\n * There is NO bare form of rows 1 and 2. `copyContext()` hands back a `CapturedContext`, and\n * `runWithContext`/`restoreContext` do not accept one — they take the {@link RestorableContext} that\n * {@link withTrusted} and {@link withoutTrusted} produce. Every call site therefore STATES whether the\n * proven identity travels, and neither intent is shorter to type than the other. That is CLAUDE.md shim\n * shape #5 applied here: a bare snapshot silently carrying a user identity is a widening that is an\n * absence rather than a token, and it is ungreppable. Now `grep -rn withTrusted` enumerates every place\n * an identity crosses a scope boundary and `grep -rn withoutTrusted` every deliberate drop.\n *\n * ## What it is for\n *\n * `AsyncLocalStorage` follows `await`, `.then()` and ordinary callbacks on its own, so the vast\n * majority of code never needs this. What it does NOT follow is work whose async chain was BROKEN and\n * re-rooted somewhere else: an item pushed onto an in-memory queue during a request and drained later\n * by a background loop, a batch flushed on a scheduler tick, an `EventEmitter` listener fired from a\n * socket the request does not own, a retry re-armed from a top-level timer, a hand-off to a worker\n * pool. In each of those the work executes under a DIFFERENT (or no) context, so the request id, the\n * log fields and the proven identity would silently vanish from everything the work logs or calls.\n *\n * The answer is two halves: `RequestContext.copyContext()` where the work is ENQUEUED, and\n * `RequestContext.runWithContext(captured.withTrusted(), fn)` — or `.withoutTrusted()`, or\n * `restoreContext(...)` of either — where it RUNS. The narrowing is not optional; see the table above.\n *\n * ## Why it is opaque instead of a `Map<string, unknown>`\n *\n * A restored context legitimately contains TRUSTED values — reinstating what the original scope had\n * proven is the entire point — so the restore side cannot type-check its payload the way\n * `putTrusted`/`getTrusted` do. That left the `Map`-taking signature that this class DELETED (a\n * now-removed `setContext(map)`, and `runWithContext(map, fn)`) as a complete bypass of the trust\n * system: handing it `new Map([['userId', 'victim']])` forged a proven identity in one line, without\n * ever typing a trust verb, and the only thing standing against it was a doc comment saying \"the Map\n * must come from copyContext()\". An agent picks whatever compiles, so a doc comment is not\n * enforcement.\n *\n * Making the PAYLOAD opaque solves it without type-checking the contents: the only way to obtain one is\n * a real capture of a real scope, so whatever it holds was, by construction, already in a context that\n * something legitimately wrote. Concretely:\n *\n * - the constructor is `private`, and there is no public factory — {@link capture} demands a\n * {@link ContextCaptureAuthority} that cannot be named outside this package;\n * - the package barrel exports this class as a TYPE ONLY, so a consumer never receives the class\n * object at all and cannot reach `capture` even with a cast. That second half matters: a token whose\n * TYPE is unexported still stops nothing on its own, because `capture(null as never, forgedMap)`\n * type-checks. Withholding the class object is what actually closes it;\n * - the entries live in `#entries`, a genuine ECMAScript private field, so they are unreachable at\n * RUNTIME as well as at compile time — no `Object.keys`, no cast, no index signature;\n * - the map is defensively copied ON CAPTURE, again on each NARROWING, and again ON RESTORE, so a\n * caller who still holds the live context (or who keeps writing to it after capturing) cannot reach\n * through the snapshot, narrowing never mutates the capture it came from, and a snapshot can be\n * restored repeatedly without the first restore's mutations bleeding into the second.\n *\n * There is deliberately no reader: nothing hands the entries back out. That is why `getAll()` is gone\n * rather than re-typed to return one of these — a `CapturedContext` you cannot read is useless as a\n * `getAll`, and a readable one would be the raw enumeration of every trusted value all over again.\n *\n * The one residual: a consumer holding a NARROWED snapshot can still cast a token into\n * {@link RestorableContext.toFreshStore} and read the entries back out as a plain Map. That is knowingly accepted, and it is the same asymmetry\n * `RequestContext.getAny` states — FORGING a trusted value is the dangerous direction and is closed\n * here; reading one you were already legitimately handed, without saying `getTrusted`, costs you\n * nothing but the type. Closing it too would mean no method could take the token at all, which is to\n * say no restore could exist.\n */\nexport class CapturedContext {\n /**\n * A real ECMAScript private field, not a TypeScript `private`. The distinction matters here: `#`\n * is enforced by the runtime, so the snapshot's contents cannot be reached by a cast, by\n * `Object.entries`, or by `as unknown as { entries: Map<string, unknown> }`.\n */\n // webpieces-disable no-any-unknown -- the context store is deliberately type-erased; each ContextKey carries its own value type and the typed verbs re-apply it on read\n readonly #entries: Map<string, unknown>;\n\n /**\n * PRIVATE — a CapturedContext can only come from {@link capture}, which in turn can only be called\n * by code holding a {@link ContextCaptureAuthority}. Copies the map so the snapshot is never a\n * window onto a live store.\n */\n // webpieces-disable no-any-unknown -- see #entries\n private constructor(entries: Map<string, unknown>) {\n this.#entries = new Map(entries);\n }\n\n /**\n * The ONLY producer. `authority` is a compile-time capability, not a runtime check — so it is\n * referenced below only to keep it from being an unused parameter. Note the token alone is not the\n * guarantee (a cast can supply one); the guarantee is that the barrel exports this class as a TYPE\n * ONLY, so no consumer ever holds the class object this static hangs off. See the class doc.\n */\n // webpieces-disable no-any-unknown -- see #entries\n // webpieces-disable no-function-outside-class -- static factory standing in for the (private) constructor; making it an instance method would mean an instance already existed, which is the thing being created\n static capture(authority: ContextCaptureAuthority, live: Map<string, unknown>): CapturedContext {\n void authority;\n return new CapturedContext(live);\n }\n\n /**\n * Carry EVERY value onward, the proven identity included — the faithful re-root. The work runs AS\n * that user: `getTrusted(USER_ID)` inside it answers exactly what it answered in the original scope,\n * which is the entire point when a request's own continuation was re-rooted onto a queue or a timer.\n *\n * Said OUT LOUD, because it is the wide branch. A bare snapshot is deliberately not accepted by\n * `runWithContext`/`restoreContext` (see the class doc), so the identity never crosses a scope\n * boundary by default or by omission, and `grep -rn withTrusted` enumerates every place it does.\n *\n * NON-MUTATING, like its sibling — the receiver is unchanged, so ONE snapshot can be narrowed both\n * ways at two different call sites.\n */\n withTrusted(): RestorableContext {\n return RestorableContext.of(ContextCaptureAuthority.INTERNAL, this.#entries);\n }\n\n /**\n * Carry only the UNTRUSTED values — a deliberate PRIVILEGE DROP.\n *\n * ```typescript\n * const snapshot = RequestContext.copyContext();\n * RequestContext.runWithContext(snapshot.withTrusted(), fn); // runs AS that user\n * RequestContext.runWithContext(snapshot.withoutTrusted(), fn); // runs as the SYSTEM\n * ```\n *\n * The case: a background job or fire-and-forget task spawned during a request should keep the\n * untrusted trace fields — `requestId`, `actionId` — so its log lines are still greppable back to\n * the click that caused them, but it must NOT keep `userId` / `orgId` / roles, because it executes\n * as the system rather than as that user. Carrying the proven identity onward would make every\n * downstream authorization decision think the user is still on the other end of the wire.\n *\n * A METHOD PAIR, never a `keepTrusted: boolean` on {@link RequestContext.runWithContext}: a\n * parameter makes the two intents equally easy to type and impossible to grep, and a defaulted one\n * makes the permissive branch the shortest thing to write — CLAUDE.md shim shape #5, \"a widening\n * that is an ABSENCE rather than a token\", the same reason `@AuthJwt({allRolesAllowed: true})` says\n * the wide grant out loud. As a transform on the SNAPSHOT rather than a second capture mechanism it\n * composes with BOTH consumers — `runWithContext` and `restoreContext` — for free.\n *\n * NON-MUTATING: the receiver is untouched, so one snapshot can be used both ways.\n *\n * NO AUTHORITY TOKEN, deliberately, and it must not grow one. The token on {@link capture} exists\n * because CONSTRUCTING a snapshot from arbitrary entries forges trust. This direction only ever\n * REMOVES entries: whatever survives was already in a real capture of a real scope, so the result\n * is strictly less privileged than the object the caller is already holding. Dropping cannot forge.\n *\n * WHAT SURVIVES is exactly \"registered as an UNTRUSTED {@link ContextKey}\". Trusted keys go, and so\n * do names the {@link HeaderRegistry} does not know — the framework's reserved slots (the\n * `HttpRequest`, the AuthFilter principal, the Cloud Tasks schedule frame), which carry no declared\n * trust and are the caller's identity and connection rather than trace fields. A privilege drop\n * that guessed in the permissive direction would not be one. For the same reason, with no registry\n * configured NOTHING is knowably untrusted and the result is empty — always the safe answer, since\n * this method's only job is to remove.\n */\n withoutTrusted(): RestorableContext {\n // webpieces-disable no-any-unknown -- the context store is deliberately type-erased; see #entries\n const kept = new Map<string, unknown>();\n if (!HeaderRegistry.isConfigured()) {\n return RestorableContext.of(ContextCaptureAuthority.INTERNAL, kept);\n }\n const registry = HeaderRegistry.get();\n for (const name of this.#entries.keys()) {\n const key = registry.findByName(name);\n if (key && key.isUntrusted()) {\n kept.set(name, this.#entries.get(name));\n }\n }\n return RestorableContext.of(ContextCaptureAuthority.INTERNAL, kept);\n }\n\n /**\n * How many entries the snapshot holds. The one thing it will tell you about itself — a count is\n * not a value, so it leaks nothing, and it lets a caller (and a test) see that a capture taken\n * outside an active scope is simply empty rather than an error.\n */\n size(): number {\n return this.#entries.size;\n }\n}\n\n/**\n * A snapshot whose TRUST INTENT has been stated — the only thing `RequestContext.restoreContext` and\n * `RequestContext.runWithContext` accept.\n *\n * It exists to make the wide choice unskippable. A single type would have meant\n * `runWithContext(snapshot, fn)` compiling next to `runWithContext(snapshot.withTrusted(), fn)`: two\n * spellings of one thing (shim shape #1), with the shorter one silently carrying a user identity into\n * work that may have no business running as that user. Splitting the type deletes the default — a\n * capture is inert until it says which it means — so there is exactly one spelling per intent, and\n * `grep -rn withTrusted` / `grep -rn withoutTrusted` enumerate the two populations of call sites.\n *\n * Two CLASSES rather than a phantom type parameter on {@link CapturedContext}, even though this repo\n * uses that trick on `ContextKey<V, T extends Trust>`. There the parameter rides along with a key that\n * consumers name constantly and read values through, so it earns its complexity. Here the two states\n * have DIFFERENT MEMBERS — a capture can only be narrowed, a narrowed one can only be restored — and a\n * type that changes its members between states is a second class, not a second type argument. Naming\n * it also gives the field/queue-entry type a consumer holds a name that says what it is.\n *\n * The #622 opacity guarantees are unchanged and are why this class is also barrel-exported as a TYPE\n * ONLY: private constructor, a capability token on the producer, a real `#entries` private field, and\n * defensive copies on the way in and on the way out.\n */\nexport class RestorableContext {\n /** Same real ECMAScript private field, for the same reason — see {@link CapturedContext}. */\n // webpieces-disable no-any-unknown -- the context store is deliberately type-erased; each ContextKey carries its own value type and the typed verbs re-apply it on read\n readonly #entries: Map<string, unknown>;\n\n /** PRIVATE — {@link of} is the only producer, and only this module can call it. */\n // webpieces-disable no-any-unknown -- see #entries\n private constructor(entries: Map<string, unknown>) {\n this.#entries = new Map(entries);\n }\n\n /**\n * The ONLY producer, called by `CapturedContext.withTrusted()` / `withoutTrusted()`. Guarded the\n * same way `capture` is: a token no consumer can name, and a barrel that exports this class as a\n * TYPE ONLY, so the class object — and with it this static — never reaches a consumer at all.\n */\n // webpieces-disable no-any-unknown -- see #entries\n // webpieces-disable no-function-outside-class -- static factory standing in for the (private) constructor; an instance method would presuppose the instance being created\n static of(authority: ContextCaptureAuthority, entries: Map<string, unknown>): RestorableContext {\n void authority;\n return new RestorableContext(entries);\n }\n\n /**\n * Overwrite a LIVE store with this snapshot — the engine behind `RequestContext.restoreContext`.\n * Write-only by design: it pushes entries in and hands nothing back, so it is not a side door onto\n * the snapshot's contents.\n */\n // webpieces-disable no-any-unknown -- see #entries\n restoreInto(authority: ContextCaptureAuthority, live: Map<string, unknown>): void {\n void authority;\n live.clear();\n for (const name of this.#entries.keys()) {\n live.set(name, this.#entries.get(name));\n }\n }\n\n /**\n * A FRESH store holding this snapshot — the engine behind `RequestContext.runWithContext`, which\n * opens a new AsyncLocalStorage scope around it. Fresh (a copy) rather than the internal map, so\n * everything the restored scope writes stays in that scope and the snapshot remains reusable.\n */\n // webpieces-disable no-any-unknown -- see #entries\n toFreshStore(authority: ContextCaptureAuthority): Map<string, unknown> {\n void authority;\n return new Map(this.#entries);\n }\n\n /** How many entries survived the narrowing. A count is not a value, so it leaks nothing. */\n size(): number {\n return this.#entries.size;\n }\n}\n"]}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * COMPILE-TIME assertions that a context can only be restored from a REAL capture.
3
+ *
4
+ * The runtime half — that a snapshot round-trips, and that mutating the source afterwards does not
5
+ * reach it — is in `CapturedContext.spec.ts`. What a spec CANNOT express is the half that matters
6
+ * most: that `RequestContext.restoreContext(new Map([['userId', 'victim']]))` does not compile. Each
7
+ * `@ts-expect-error` below fails the build with TS2578 the day its line starts compiling again.
8
+ *
9
+ * In COMPILED source deliberately — `tsconfig.lib.json` excludes specs and vitest strips types with
10
+ * esbuild, so a `@ts-expect-error` in a `.spec.ts` is inert and the suite would pass either way. See
11
+ * `RequestContextTrustCompileAssertions` for the trust-verb half.
12
+ */
13
+ export declare class CapturedContextCompileAssertions {
14
+ /** THE hole this change closes: a hand-assembled payload forging a trusted value. */
15
+ cannotRestoreAHandBuiltMap(): void;
16
+ /** Same hole through the other door. */
17
+ cannotRunWithAHandBuiltMap(): void;
18
+ /** Nor by asserting an object literal into the shape — the private `#entries` makes it nominal. */
19
+ cannotFakeTheShape(): void;
20
+ /** Nor by constructing one directly — the constructor is private. */
21
+ cannotConstructOneDirectly(): void;
22
+ /**
23
+ * Nor by calling the factory: it demands a capability token whose own constructor is private, so
24
+ * even code that can NAME the token (this package) cannot mint a second one.
25
+ */
26
+ cannotMintAnAuthority(): void;
27
+ /** And the factory is not callable without one at all. */
28
+ cannotCaptureWithoutAnAuthority(): void;
29
+ /**
30
+ * THE new hole this pair closes: a BARE capture must not run. If it did, `runWithContext(snapshot,
31
+ * fn)` would sit next to `runWithContext(snapshot.withTrusted(), fn)` as a second spelling whose
32
+ * shorter form silently carries a user identity — a widening that is an absence rather than a
33
+ * token, and ungreppable. The capture is inert until it states its intent.
34
+ */
35
+ cannotRunABareCapture(): void;
36
+ /** Same, through the in-place door. */
37
+ cannotRestoreABareCapture(): void;
38
+ /**
39
+ * And the intent is stated by NARROWING, never by a flag on the run call. A `keepTrusted: boolean`
40
+ * would make the wide intent as easy to type as the narrow one and impossible to grep; this line
41
+ * fails the build the day such a parameter appears.
42
+ */
43
+ cannotStateTheIntentViaAFlagOnTheRunCall(): void;
44
+ /** Nor can a narrowed snapshot be minted directly — same private constructor, same token. */
45
+ cannotConstructARestorableContextDirectly(): void;
46
+ /** Nor through its factory, which demands the same unobtainable authority. */
47
+ cannotMintARestorableContextWithoutAnAuthority(): void;
48
+ /** POSITIVE: both narrowings produce the ONE type both consumers take. */
49
+ bothNarrowingsFeedBothConsumers(): void;
50
+ /** POSITIVE: the real round trip must keep compiling — restoring a proven value IS the point. */
51
+ theRealRoundTripCompiles(): void;
52
+ }