stitchkit 0.75.0 → 0.76.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.
- package/dist/application/watch-hub.d.ts +23 -2
- package/dist/application/watch-hub.d.ts.map +1 -1
- package/dist/application.js +1 -1
- package/dist/live/watch-client.d.ts +26 -0
- package/dist/live/watch-client.d.ts.map +1 -1
- package/dist/live.js +59 -26
- package/dist/server/index.js +11 -1
- package/dist/server/middleware/trust-fence.d.ts +21 -0
- package/dist/server/middleware/trust-fence.d.ts.map +1 -1
- package/llms-full.txt +91 -2
- package/package.json +1 -1
|
@@ -64,12 +64,33 @@ export interface WatchHubConfig {
|
|
|
64
64
|
/**
|
|
65
65
|
* Perform one read. Supplied by the application, so a watched read goes
|
|
66
66
|
* through the same authorization as the request it mirrors.
|
|
67
|
+
*
|
|
68
|
+
* **It is given no subscriber, and that is the guard, not an omission.** A key
|
|
69
|
+
* shares one read across everyone asking it, so a key that did not separate
|
|
70
|
+
* callers would hand one caller's answer to another. Here it cannot: an answer
|
|
71
|
+
* that depends on who is asking has to carry the asker in `args`, and `args`
|
|
72
|
+
* are what the key's digest is taken over — so two callers who differ get two
|
|
73
|
+
* keys and two reads, by construction rather than by discipline.
|
|
74
|
+
*
|
|
75
|
+
* The one way to defeat that is to resolve an identity from ambient state
|
|
76
|
+
* *inside* this function — a request-scoped context, a module-level "current
|
|
77
|
+
* user". Then every subscriber to that key receives whatever the first read
|
|
78
|
+
* happened to resolve. Do not; put the identity in the arguments.
|
|
67
79
|
*/
|
|
68
80
|
read(operation: WatchOperation, args: unknown): Promise<unknown>;
|
|
69
81
|
/** Whether an operation may be watched at all. Refusal is answered in words. */
|
|
70
82
|
watchable(operation: WatchOperation): boolean;
|
|
71
|
-
/**
|
|
72
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The topics whose announcement means this answer may have changed.
|
|
85
|
+
*
|
|
86
|
+
* Given the arguments as well as the operation, so a topic can name what the
|
|
87
|
+
* answer actually depends on: `chat.transcript:<address>` rather than
|
|
88
|
+
* `chat.transcript`. Without that narrowing, one address changing wakes every
|
|
89
|
+
* watcher of the operation — twenty conversations open means twenty reads for
|
|
90
|
+
* one change, and nineteen of them publish nothing because nothing changed.
|
|
91
|
+
* The read is still paid.
|
|
92
|
+
*/
|
|
93
|
+
invalidatedBy(operation: WatchOperation, args: unknown): readonly string[];
|
|
73
94
|
/** Subscribe to a topic; returns the unsubscribe. Normally an event bus's `on`. */
|
|
74
95
|
subscribe(topic: string, listener: () => void): () => void;
|
|
75
96
|
/** The most keys one subscriber may watch at once. Default 64. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch-hub.d.ts","sourceRoot":"","sources":["../../src/application/watch-hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,KAAK,aAAa,EAAiB,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IACpC,oDAAoD;IACpD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,GAAG;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B
|
|
1
|
+
{"version":3,"file":"watch-hub.d.ts","sourceRoot":"","sources":["../../src/application/watch-hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,KAAK,aAAa,EAAiB,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IACpC,oDAAoD;IACpD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,GAAG;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;;;;;;;;;OAeG;IACH,IAAI,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjE,gFAAgF;IAChF,SAAS,CAAC,SAAS,EAAE,cAAc,GAAG,OAAO,CAAC;IAC9C;;;;;;;;;OASG;IACH,aAAa,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,CAAC;IAC3E,mFAAmF;IACnF,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC3D,kEAAkE;IAClE,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,yFAAyF;IACzF,IAAI,CAAC,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IACjD,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,UAAU,EAAE,eAAe,GAAG,eAAe,CAAC;IACrD;;;;;;OAMG;IACH,SAAS,IAAI,MAAM,CAAC;IACpB,qEAAqE;IACrE,IAAI,IAAI,MAAM,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;CACf;AAqBD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAU1F;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CA4M/D"}
|
package/dist/application.js
CHANGED
|
@@ -1610,7 +1610,7 @@ function createWatchHub(config) {
|
|
|
1610
1610
|
state: { key, phase: "opening" },
|
|
1611
1611
|
backoff: createBackoff(config.backoff ?? DEFAULT_BACKOFF)
|
|
1612
1612
|
};
|
|
1613
|
-
for (const topic of config.invalidatedBy(operation)) {
|
|
1613
|
+
for (const topic of config.invalidatedBy(operation, args)) {
|
|
1614
1614
|
source.unsubscribes.push(config.subscribe(topic, () => {
|
|
1615
1615
|
source.dirty = true;
|
|
1616
1616
|
pump(source);
|
|
@@ -17,6 +17,23 @@
|
|
|
17
17
|
* `createUrlBuilder`, and the identity is the contract's own
|
|
18
18
|
* `(prefix, endpoint key)` — the same pair the server labels every request with.
|
|
19
19
|
*
|
|
20
|
+
* ## A connection is not forever, and recovering is this client's job
|
|
21
|
+
*
|
|
22
|
+
* A socket drops and comes back, and the hub forgets everything the old
|
|
23
|
+
* connection held — it releases a subscriber's keys the moment that subscriber
|
|
24
|
+
* detaches. So a client that opened each question once and never again is a
|
|
25
|
+
* client whose face freezes the first time a server restarts, *silently*,
|
|
26
|
+
* because nothing tells the subscriber that what it is looking at stopped being
|
|
27
|
+
* live.
|
|
28
|
+
*
|
|
29
|
+
* This client holds every key and every listener, so nobody else can do it: on a
|
|
30
|
+
* drop it tells subscribers the source is gone and forgets that anything was
|
|
31
|
+
* opened; on a fresh connection it re-opens every key that still has a listener.
|
|
32
|
+
* That is why {@link WatchTransport} **requires** `onConnectionChange` rather
|
|
33
|
+
* than using it when offered — a transport that cannot say when it reconnected
|
|
34
|
+
* cannot host a recovering client, and an optional hook would turn recovery into
|
|
35
|
+
* something that silently did not happen.
|
|
36
|
+
*
|
|
20
37
|
* ## Retention
|
|
21
38
|
*
|
|
22
39
|
* The last value of a key is kept while anyone holds it, and for `holdMs` after
|
|
@@ -49,6 +66,15 @@ export interface WatchTransport {
|
|
|
49
66
|
accepted: boolean;
|
|
50
67
|
reason?: string;
|
|
51
68
|
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Observe connection changes. Required, because recovery depends on it.
|
|
71
|
+
*
|
|
72
|
+
* The hub releases a subscriber's keys when its connection detaches, so every
|
|
73
|
+
* question opened over the old socket is gone the moment it drops. Without
|
|
74
|
+
* this the client would go on believing it was subscribed and show a face that
|
|
75
|
+
* had quietly stopped updating.
|
|
76
|
+
*/
|
|
77
|
+
onConnectionChange(listener: (connected: boolean, reason?: string) => void): () => void;
|
|
52
78
|
}
|
|
53
79
|
export interface WatchClientConfig {
|
|
54
80
|
readonly transport: WatchTransport;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch-client.d.ts","sourceRoot":"","sources":["../../src/live/watch-client.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"watch-client.d.ts","sourceRoot":"","sources":["../../src/live/watch-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEnE,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,eAAe,EAGrB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,kEAAkE;IAClE,KAAK,CAAC,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,WAAW,CAAC,MAAM;IACjC,sFAAsF;IACtF,SAAS,CAAC,SAAS,EAAE,cAAc,CAAC,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC;IACzD,wEAAwE;IACxE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,6FAA6F;AAC7F,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACjE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC5C,OAAO,CACL,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAC7B,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD;;;;;;;OAOG;IACH,kBAAkB,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACzF;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9D;AAuBD,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI;KACnE,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC;CACzE,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACrE,QAAQ,EAAE,WAAW,CAAC,CAAC,EAAE,MAAM,CAAC,EAChC,MAAM,EAAE,iBAAiB,GACxB,gBAAgB,CAAC,CAAC,CAAC,CAwNrB"}
|
package/dist/live.js
CHANGED
|
@@ -122,6 +122,11 @@ function createWatchClient(contract, config) {
|
|
|
122
122
|
const holdMs = config.holdMs ?? 0;
|
|
123
123
|
const openTimeoutMs = config.openTimeoutMs ?? 1e4;
|
|
124
124
|
const service = contract.meta.prefix;
|
|
125
|
+
function publishState(entry, state) {
|
|
126
|
+
entry.state = state;
|
|
127
|
+
for (const listener of [...entry.listeners])
|
|
128
|
+
listener.state?.(state);
|
|
129
|
+
}
|
|
125
130
|
config.transport.on(WATCH_VALUE, (frame) => {
|
|
126
131
|
const entry = entries.get(watchKeyString(frame.key));
|
|
127
132
|
if (!entry)
|
|
@@ -138,9 +143,27 @@ function createWatchClient(contract, config) {
|
|
|
138
143
|
const entry = entries.get(watchKeyString(frame.key));
|
|
139
144
|
if (!entry)
|
|
140
145
|
return;
|
|
141
|
-
entry
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
publishState(entry, frame);
|
|
147
|
+
});
|
|
148
|
+
config.transport.onConnectionChange((connected, reason) => {
|
|
149
|
+
if (!connected) {
|
|
150
|
+
for (const entry of entries.values()) {
|
|
151
|
+
entry.opened = false;
|
|
152
|
+
publishState(entry, {
|
|
153
|
+
key: entry.key,
|
|
154
|
+
phase: "unavailable",
|
|
155
|
+
reason: "source-unavailable",
|
|
156
|
+
...reason !== undefined && { message: reason }
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const entry of entries.values()) {
|
|
162
|
+
if (entry.listeners.size === 0)
|
|
163
|
+
continue;
|
|
164
|
+
publishState(entry, { key: entry.key, phase: "opening" });
|
|
165
|
+
open(entry);
|
|
166
|
+
}
|
|
144
167
|
});
|
|
145
168
|
function cachedDigest(action, args) {
|
|
146
169
|
return digests.get(`${service}/${action}/${JSON.stringify(stableValue(args))}`);
|
|
@@ -154,7 +177,7 @@ function createWatchClient(contract, config) {
|
|
|
154
177
|
digests.set(cacheKey, digest);
|
|
155
178
|
return digest;
|
|
156
179
|
}
|
|
157
|
-
function entryFor(key) {
|
|
180
|
+
function entryFor(key, args) {
|
|
158
181
|
const id = watchKeyString(key);
|
|
159
182
|
const existing = entries.get(id);
|
|
160
183
|
if (existing) {
|
|
@@ -164,14 +187,44 @@ function createWatchClient(contract, config) {
|
|
|
164
187
|
}
|
|
165
188
|
const entry = {
|
|
166
189
|
key,
|
|
190
|
+
args,
|
|
167
191
|
listeners: new Set,
|
|
168
192
|
revision: 0,
|
|
169
193
|
hasValue: false,
|
|
194
|
+
opened: false,
|
|
170
195
|
state: { key, phase: "opening" }
|
|
171
196
|
};
|
|
172
197
|
entries.set(id, entry);
|
|
173
198
|
return entry;
|
|
174
199
|
}
|
|
200
|
+
async function open(entry) {
|
|
201
|
+
if (entry.opened)
|
|
202
|
+
return;
|
|
203
|
+
entry.opened = true;
|
|
204
|
+
try {
|
|
205
|
+
const acknowledgement = await config.transport.request(WATCH_OPEN, { key: entry.key, args: entry.args }, { timeoutMs: openTimeoutMs });
|
|
206
|
+
if (!acknowledgement.accepted) {
|
|
207
|
+
const reason = acknowledgement.reason ?? "the server refused this watch";
|
|
208
|
+
config.onRefused?.(entry.key, reason);
|
|
209
|
+
publishState(entry, {
|
|
210
|
+
key: entry.key,
|
|
211
|
+
phase: "unavailable",
|
|
212
|
+
reason: "source-unavailable",
|
|
213
|
+
message: reason
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
entry.opened = false;
|
|
218
|
+
const code = typeof error === "object" && error !== null && "code" in error ? String(Reflect.get(error, "code")) : undefined;
|
|
219
|
+
publishState(entry, {
|
|
220
|
+
key: entry.key,
|
|
221
|
+
phase: "unavailable",
|
|
222
|
+
reason: "source-unavailable",
|
|
223
|
+
...code !== undefined && { code },
|
|
224
|
+
message: error instanceof Error ? error.message : String(error)
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
175
228
|
function releaseEntry(entry) {
|
|
176
229
|
if (entry.listeners.size > 0)
|
|
177
230
|
return;
|
|
@@ -191,36 +244,16 @@ function createWatchClient(contract, config) {
|
|
|
191
244
|
function handleFor(action, args) {
|
|
192
245
|
const mine = new Set;
|
|
193
246
|
let entry;
|
|
194
|
-
let opened = false;
|
|
195
247
|
const known = cachedDigest(action, args);
|
|
196
248
|
if (known !== undefined)
|
|
197
|
-
entry = entryFor({ service, action, digest: known });
|
|
249
|
+
entry = entryFor({ service, action, digest: known }, args);
|
|
198
250
|
const ready = (async () => {
|
|
199
251
|
if (entry)
|
|
200
252
|
return entry;
|
|
201
253
|
const digest = await resolveDigest(action, args);
|
|
202
|
-
entry = entryFor({ service, action, digest });
|
|
254
|
+
entry = entryFor({ service, action, digest }, args);
|
|
203
255
|
return entry;
|
|
204
256
|
})();
|
|
205
|
-
async function open(target) {
|
|
206
|
-
if (opened)
|
|
207
|
-
return;
|
|
208
|
-
opened = true;
|
|
209
|
-
const acknowledgement = await config.transport.request(WATCH_OPEN, { key: target.key, args }, { timeoutMs: openTimeoutMs });
|
|
210
|
-
if (!acknowledgement.accepted) {
|
|
211
|
-
const reason = acknowledgement.reason ?? "the server refused this watch";
|
|
212
|
-
config.onRefused?.(target.key, reason);
|
|
213
|
-
const refusal = {
|
|
214
|
-
key: target.key,
|
|
215
|
-
phase: "unavailable",
|
|
216
|
-
reason: "source-unavailable",
|
|
217
|
-
message: reason
|
|
218
|
-
};
|
|
219
|
-
target.state = refusal;
|
|
220
|
-
for (const listener of [...target.listeners])
|
|
221
|
-
listener.state?.(refusal);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
257
|
return {
|
|
225
258
|
subscribe(listeners) {
|
|
226
259
|
const registered = listeners;
|
package/dist/server/index.js
CHANGED
|
@@ -875,6 +875,14 @@ function createTrustFence(config) {
|
|
|
875
875
|
}
|
|
876
876
|
trusted.push(authority);
|
|
877
877
|
}
|
|
878
|
+
const trustedOrigins = [];
|
|
879
|
+
for (const entry of config.trustedOrigins ?? []) {
|
|
880
|
+
const authority = readAuthority(entry);
|
|
881
|
+
if (!authority) {
|
|
882
|
+
throw new Error(`[stitchkit] trust fence: trustedOrigins entry "${entry}" is not an authority. An entry is \`host\` or \`host:port\` — no scheme, no path, no wildcard, the same form as trustedHosts.`);
|
|
883
|
+
}
|
|
884
|
+
trustedOrigins.push(authority);
|
|
885
|
+
}
|
|
878
886
|
function refuse(refusal) {
|
|
879
887
|
config.onRefused?.(refusal);
|
|
880
888
|
config.logger?.warn?.("[stitchkit] trust fence refused a request", {
|
|
@@ -911,7 +919,9 @@ function createTrustFence(config) {
|
|
|
911
919
|
} catch {
|
|
912
920
|
originAuthority = undefined;
|
|
913
921
|
}
|
|
914
|
-
const
|
|
922
|
+
const sameAuthority = originAuthority !== undefined && originAuthority.hostname === requested.hostname && comparablePort(originAuthority.port) === comparablePort(requested.port);
|
|
923
|
+
const declared = originAuthority !== undefined && trustedOrigins.some((entry) => entry.hostname === originAuthority.hostname && (entry.port === null || entry.port === originAuthority.port));
|
|
924
|
+
const agrees = sameAuthority || declared;
|
|
915
925
|
if (!agrees) {
|
|
916
926
|
return refuse({ reason: "origin-mismatch", lane, host, origin, site });
|
|
917
927
|
}
|
|
@@ -84,6 +84,27 @@ export interface TrustFenceConfig {
|
|
|
84
84
|
* list it when you want it.
|
|
85
85
|
*/
|
|
86
86
|
readonly trustedHosts: readonly string[];
|
|
87
|
+
/**
|
|
88
|
+
* Origins allowed to address this server from a different authority.
|
|
89
|
+
*
|
|
90
|
+
* Same entry format as {@link trustedHosts} — `host` or `host:port`, compared
|
|
91
|
+
* against the `Origin`'s authority. Absent, only an `Origin` that matches the
|
|
92
|
+
* `Host` is accepted, which is the safe default and also refuses the most
|
|
93
|
+
* ordinary arrangement there is: a UI dev server on one port talking to an API
|
|
94
|
+
* on the next. That is the case this exists for.
|
|
95
|
+
*
|
|
96
|
+
* Worth knowing what the `Origin` check is and is not for, because the
|
|
97
|
+
* question comes up the moment it refuses something. It is **not** what stops
|
|
98
|
+
* DNS rebinding: that attack is same-origin by construction — the page believes
|
|
99
|
+
* it *is* the target authority — so it sends a matching `Origin` or none at
|
|
100
|
+
* all, and {@link trustedHosts} is what refuses it. What the `Origin` check
|
|
101
|
+
* stops is a plain cross-origin request from a page that never needs to read
|
|
102
|
+
* the reply: CORS governs whether a response can be *read*, never whether the
|
|
103
|
+
* request is *sent*, so a state-changing endpoint is reachable without it.
|
|
104
|
+
* That is why the answer here is a declared list rather than dropping the
|
|
105
|
+
* check.
|
|
106
|
+
*/
|
|
107
|
+
readonly trustedOrigins?: readonly string[];
|
|
87
108
|
/** Called on every refusal, before the 403 is written. */
|
|
88
109
|
readonly onRefused?: (refusal: TrustRefusal) => void;
|
|
89
110
|
/** Refusals are logged at `warn` when a logger is supplied. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trust-fence.d.ts","sourceRoot":"","sources":["../../../src/server/middleware/trust-fence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,qCAAqC;AACrC,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE1C,MAAM,MAAM,kBAAkB;AAC5B,yEAAyE;AACvE,cAAc;AAChB,wEAAwE;GACtE,gBAAgB;AAClB,iEAAiE;GAC/D,iBAAiB;AACnB,4DAA4D;GAC1D,YAAY,CAAC;AAEjB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CAAC;IACrD,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;IAClD,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,qBAAqB,CAAC;IAC7C,oFAAoF;IACpF,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;CACpE;AAyDD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAM1D;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,
|
|
1
|
+
{"version":3,"file":"trust-fence.d.ts","sourceRoot":"","sources":["../../../src/server/middleware/trust-fence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,qCAAqC;AACrC,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE1C,MAAM,MAAM,kBAAkB;AAC5B,yEAAyE;AACvE,cAAc;AAChB,wEAAwE;GACtE,gBAAgB;AAClB,iEAAiE;GAC/D,iBAAiB;AACnB,4DAA4D;GAC1D,YAAY,CAAC;AAEjB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CAAC;IACrD,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;IAClD,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,qBAAqB,CAAC;IAC7C,oFAAoF;IACpF,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;CACpE;AAyDD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAM1D;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CA6GrE"}
|
package/llms-full.txt
CHANGED
|
@@ -62,7 +62,7 @@ own, recorded as an ADR.
|
|
|
62
62
|
| `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
|
|
63
63
|
| `stitchkit/declaration` | build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
|
|
64
64
|
| `stitchkit/react` | browser | stable | `createCursorQuery`, `createCacheBridge` |
|
|
65
|
-
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the
|
|
65
|
+
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the 21 minors since 0.56.2, most recently 0.75.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
|
|
66
66
|
| `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
|
|
67
67
|
| `stitchkit/agent-runtime/coding-tools` | server (Bun or Node) | evolving | bounded host-authorized direct file and shell tools; a root boundary, not an OS sandbox |
|
|
68
68
|
| `stitchkit/agent-runtime/openrouter` | server | evolving | isolated OpenRouter language-model adapter |
|
|
@@ -70,7 +70,7 @@ own, recorded as an ADR.
|
|
|
70
70
|
| `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
|
|
71
71
|
| `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
|
|
72
72
|
| `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
|
|
73
|
-
| `stitchkit/application` | server | evolving<br>_redefined in 4 of the
|
|
73
|
+
| `stitchkit/application` | server | evolving<br>_redefined in 4 of the 21 minors since 0.56.2, most recently 0.72.0_ | managed resource graph, readiness, admission, schedules and bounded shutdown |
|
|
74
74
|
| `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
|
|
75
75
|
| `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
|
|
76
76
|
|
|
@@ -7636,6 +7636,33 @@ order-independent — `{a, b}` and `{b, a}` are the same question, which a plain
|
|
|
7636
7636
|
`JSON.stringify` key would get wrong exactly when two components happen to build
|
|
7637
7637
|
their argument object in a different order.
|
|
7638
7638
|
|
|
7639
|
+
### Narrow the topic to the arguments
|
|
7640
|
+
|
|
7641
|
+
`invalidatedBy` is given the arguments as well as the operation, so a topic can
|
|
7642
|
+
name what the answer actually depends on:
|
|
7643
|
+
|
|
7644
|
+
```ts
|
|
7645
|
+
invalidatedBy: (operation, args) => [`chat.transcript:${args.address}`],
|
|
7646
|
+
```
|
|
7647
|
+
|
|
7648
|
+
Without that, one address changing wakes every watcher of the operation. Twenty
|
|
7649
|
+
conversations open means twenty reads for one change — nineteen of them publish
|
|
7650
|
+
nothing, because nothing changed, and the read is paid anyway.
|
|
7651
|
+
|
|
7652
|
+
### Who shares with whom
|
|
7653
|
+
|
|
7654
|
+
A key is `(service, action, digest of arguments)`, and everyone on that key gets
|
|
7655
|
+
one read. So the question a sharing primitive has to answer is what keeps one
|
|
7656
|
+
caller's answer from reaching another — and here the answer is structural:
|
|
7657
|
+
`read` is **given no subscriber**. An answer that depends on who is asking has to
|
|
7658
|
+
carry the asker in its arguments, and the arguments are what the digest is taken
|
|
7659
|
+
over, so two callers who differ get two keys and two reads.
|
|
7660
|
+
|
|
7661
|
+
The one way to defeat that is to resolve an identity from ambient state *inside*
|
|
7662
|
+
`read` — a request-scoped context, a module-level "current user". Then every
|
|
7663
|
+
subscriber on that key receives whatever the first read happened to resolve.
|
|
7664
|
+
Put the identity in the arguments instead.
|
|
7665
|
+
|
|
7639
7666
|
### Three states, not two
|
|
7640
7667
|
|
|
7641
7668
|
`state.phase` comes from `LiveStatePhase`, the vocabulary the live-state
|
|
@@ -7647,6 +7674,20 @@ A failed read arrives as `unavailable` with the read's own `code` and `message`.
|
|
|
7647
7674
|
The hub retries on its own backoff, and a success clears the state without anyone
|
|
7648
7675
|
asking.
|
|
7649
7676
|
|
|
7677
|
+
### Across a reconnect
|
|
7678
|
+
|
|
7679
|
+
The hub releases a connection's keys when it detaches, so everything opened over
|
|
7680
|
+
the old socket is gone the moment it drops. The client recovers on its own: on a
|
|
7681
|
+
drop your `state` listener gets `unavailable` / `source-unavailable`, and on the
|
|
7682
|
+
next connection every key that still has a listener is re-opened and the values
|
|
7683
|
+
resume. That is what `onConnectionChange` on the transport is for, and why it is
|
|
7684
|
+
required rather than optional — a client that cannot be told has no way to
|
|
7685
|
+
notice, and the face stops updating without a word.
|
|
7686
|
+
|
|
7687
|
+
Nothing an open can fail with escapes as a rejected promise: a disconnected
|
|
7688
|
+
socket, a timeout, a refusal all arrive as `unavailable` carrying the error's own
|
|
7689
|
+
code and message, and the next connection retries.
|
|
7690
|
+
|
|
7650
7691
|
### `watch` or `createLiveStateController`?
|
|
7651
7692
|
|
|
7652
7693
|
> **If you would have written `applyEvent` as `(_, next) => next`, you want
|
|
@@ -7731,6 +7772,32 @@ createServer({
|
|
|
7731
7772
|
});
|
|
7732
7773
|
```
|
|
7733
7774
|
|
|
7775
|
+
### A UI on one port, an API on the next
|
|
7776
|
+
|
|
7777
|
+
The most ordinary arrangement in development is also the one the fence refuses by
|
|
7778
|
+
default: a UI dev server on `:5180` calling an API on `:5181`. The browser sends
|
|
7779
|
+
`Origin: http://localhost:5180` to `Host: localhost:5181`, they differ, and both
|
|
7780
|
+
lanes answer 403. Declare the second origin:
|
|
7781
|
+
|
|
7782
|
+
```ts
|
|
7783
|
+
createTrustFence({
|
|
7784
|
+
trustedHosts: ['localhost:5181'],
|
|
7785
|
+
trustedOrigins: ['localhost:5180'],
|
|
7786
|
+
});
|
|
7787
|
+
```
|
|
7788
|
+
|
|
7789
|
+
Same entry format as `trustedHosts`, compared against the `Origin`'s authority.
|
|
7790
|
+
Declaring one does not widen anything else: an undeclared origin is still
|
|
7791
|
+
refused, and `trustedHosts` still decides which authority the server answers on.
|
|
7792
|
+
|
|
7793
|
+
It is worth knowing what the `Origin` check is for, because it is not what most
|
|
7794
|
+
people assume. It is **not** the DNS-rebinding defence — that attack is
|
|
7795
|
+
same-origin by construction, so it sends a matching `Origin` or none at all, and
|
|
7796
|
+
`trustedHosts` is what refuses it. What the `Origin` check stops is a plain
|
|
7797
|
+
cross-origin request from a page that never needs to read the reply: CORS governs
|
|
7798
|
+
whether a response can be *read*, never whether the request is *sent*, so a
|
|
7799
|
+
state-changing endpoint is reachable without it.
|
|
7800
|
+
|
|
7734
7801
|
### Install **both** halves
|
|
7735
7802
|
|
|
7736
7803
|
`fence.hooks` fences HTTP. `fence.allowRequest` fences the Socket.IO lane — and
|
|
@@ -9904,6 +9971,28 @@ makes one thing your job rather than the resolver's:
|
|
|
9904
9971
|
The mechanical part is identical either way. Only the *noticing* differs, and an
|
|
9905
9972
|
exact pin moves it onto you.
|
|
9906
9973
|
|
|
9974
|
+
## Released migration: 0.76.0
|
|
9975
|
+
|
|
9976
|
+
One change, and only if you hand `createWatchClient` a transport you wrote yourself.
|
|
9977
|
+
|
|
9978
|
+
```bash
|
|
9979
|
+
rg -n "createWatchClient" --glob '*.ts'
|
|
9980
|
+
```
|
|
9981
|
+
|
|
9982
|
+
If the `transport:` you pass is a bound realtime client — `bindRealtimeClient(...)` or
|
|
9983
|
+
`createRealtimeClient(...)` — there is nothing to do: it already carries
|
|
9984
|
+
`onConnectionChange`. If it is an object you assembled, add the fourth member:
|
|
9985
|
+
|
|
9986
|
+
```ts
|
|
9987
|
+
onConnectionChange(listener: (connected: boolean, reason?: string) => void): () => void
|
|
9988
|
+
```
|
|
9989
|
+
|
|
9990
|
+
It is required because the client recovers through it: on a drop it publishes
|
|
9991
|
+
`unavailable` to subscribers and forgets what was opened, and on a fresh
|
|
9992
|
+
connection it re-opens every key that still has a listener. Without it a watch
|
|
9993
|
+
stays "open" on the client after a server restart while the hub remembers
|
|
9994
|
+
nothing, and the face stops updating without saying so.
|
|
9995
|
+
|
|
9907
9996
|
## Released migration: 0.75.0
|
|
9908
9997
|
|
|
9909
9998
|
Two mechanical renames and one option that has to move. Nothing about a passing
|
package/package.json
CHANGED