agenthooksprotocol 0.0.0-bootstrap.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/LICENSE +202 -0
- package/README.md +29 -0
- package/dist/src/client/auth.d.ts +147 -0
- package/dist/src/client/auth.d.ts.map +1 -0
- package/dist/src/client/auth.js +629 -0
- package/dist/src/client/auth.js.map +1 -0
- package/dist/src/client/composition.d.ts +42 -0
- package/dist/src/client/composition.d.ts.map +1 -0
- package/dist/src/client/composition.js +523 -0
- package/dist/src/client/composition.js.map +1 -0
- package/dist/src/client/content.d.ts +73 -0
- package/dist/src/client/content.d.ts.map +1 -0
- package/dist/src/client/content.js +404 -0
- package/dist/src/client/content.js.map +1 -0
- package/dist/src/client/hooks.d.ts +62 -0
- package/dist/src/client/hooks.d.ts.map +1 -0
- package/dist/src/client/hooks.js +997 -0
- package/dist/src/client/hooks.js.map +1 -0
- package/dist/src/client/index.d.ts +11 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +9 -0
- package/dist/src/client/index.js.map +1 -0
- package/dist/src/client/transport.d.ts +12 -0
- package/dist/src/client/transport.d.ts.map +1 -0
- package/dist/src/client/transport.js +347 -0
- package/dist/src/client/transport.js.map +1 -0
- package/dist/src/client/types.d.ts +117 -0
- package/dist/src/client/types.d.ts.map +1 -0
- package/dist/src/client/types.js +10 -0
- package/dist/src/client/types.js.map +1 -0
- package/dist/src/client/validation.d.ts +3 -0
- package/dist/src/client/validation.d.ts.map +1 -0
- package/dist/src/client/validation.js +25 -0
- package/dist/src/client/validation.js.map +1 -0
- package/dist/src/compaction.d.ts +27 -0
- package/dist/src/compaction.d.ts.map +1 -0
- package/dist/src/compaction.js +155 -0
- package/dist/src/compaction.js.map +1 -0
- package/dist/src/content-upload.d.ts +60 -0
- package/dist/src/content-upload.d.ts.map +1 -0
- package/dist/src/content-upload.js +129 -0
- package/dist/src/content-upload.js.map +1 -0
- package/dist/src/draft/generated.d.ts +3085 -0
- package/dist/src/draft/generated.d.ts.map +1 -0
- package/dist/src/draft/generated.js +15372 -0
- package/dist/src/draft/generated.js.map +1 -0
- package/dist/src/draft/index.d.ts +31 -0
- package/dist/src/draft/index.d.ts.map +1 -0
- package/dist/src/draft/index.js +129 -0
- package/dist/src/draft/index.js.map +1 -0
- package/dist/src/draft/schemas.d.ts +8848 -0
- package/dist/src/draft/schemas.d.ts.map +1 -0
- package/dist/src/draft/schemas.js +9219 -0
- package/dist/src/draft/schemas.js.map +1 -0
- package/dist/src/draft-atomic.d.ts +18 -0
- package/dist/src/draft-atomic.d.ts.map +1 -0
- package/dist/src/draft-atomic.js +102 -0
- package/dist/src/draft-atomic.js.map +1 -0
- package/dist/src/draft-runtime.d.ts +89 -0
- package/dist/src/draft-runtime.d.ts.map +1 -0
- package/dist/src/draft-runtime.js +242 -0
- package/dist/src/draft-runtime.js.map +1 -0
- package/dist/src/elicitation.d.ts +18 -0
- package/dist/src/elicitation.d.ts.map +1 -0
- package/dist/src/elicitation.js +191 -0
- package/dist/src/elicitation.js.map +1 -0
- package/dist/src/errors.d.ts +7 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +15 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/framing.d.ts +7 -0
- package/dist/src/framing.d.ts.map +1 -0
- package/dist/src/framing.js +39 -0
- package/dist/src/framing.js.map +1 -0
- package/dist/src/generated.d.ts +304 -0
- package/dist/src/generated.d.ts.map +1 -0
- package/dist/src/generated.js +1885 -0
- package/dist/src/generated.js.map +1 -0
- package/dist/src/hook-runner.d.ts +11 -0
- package/dist/src/hook-runner.d.ts.map +1 -0
- package/dist/src/hook-runner.js +223 -0
- package/dist/src/hook-runner.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/observation.d.ts +26 -0
- package/dist/src/observation.d.ts.map +1 -0
- package/dist/src/observation.js +28 -0
- package/dist/src/observation.js.map +1 -0
- package/dist/src/server/attachments.d.ts +29 -0
- package/dist/src/server/attachments.d.ts.map +1 -0
- package/dist/src/server/attachments.js +112 -0
- package/dist/src/server/attachments.js.map +1 -0
- package/dist/src/server/hooks.d.ts +20 -0
- package/dist/src/server/hooks.d.ts.map +1 -0
- package/dist/src/server/hooks.js +129 -0
- package/dist/src/server/hooks.js.map +1 -0
- package/dist/src/server/index.d.ts +7 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +4 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/stdio.d.ts +15 -0
- package/dist/src/server/stdio.d.ts.map +1 -0
- package/dist/src/server/stdio.js +69 -0
- package/dist/src/server/stdio.js.map +1 -0
- package/dist/src/transport.d.ts +7 -0
- package/dist/src/transport.d.ts.map +1 -0
- package/dist/src/transport.js +195 -0
- package/dist/src/transport.js.map +1 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +14 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/validation.d.ts +7 -0
- package/dist/src/validation.d.ts.map +1 -0
- package/dist/src/validation.js +209 -0
- package/dist/src/validation.js.map +1 -0
- package/package.json +64 -0
- package/src/client/auth.ts +1076 -0
- package/src/client/composition.ts +708 -0
- package/src/client/content.ts +510 -0
- package/src/client/hooks.ts +1368 -0
- package/src/client/index.ts +28 -0
- package/src/client/transport.ts +408 -0
- package/src/client/types.ts +173 -0
- package/src/client/validation.ts +24 -0
- package/src/compaction.ts +196 -0
- package/src/content-upload.ts +205 -0
- package/src/draft/README.md +16 -0
- package/src/draft/ahp-codegen.lock.json +123 -0
- package/src/draft/generated.ts +19143 -0
- package/src/draft/index.ts +246 -0
- package/src/draft/schemas.ts +9413 -0
- package/src/draft-atomic.ts +119 -0
- package/src/draft-runtime.ts +365 -0
- package/src/elicitation.ts +254 -0
- package/src/errors.ts +30 -0
- package/src/framing.ts +56 -0
- package/src/generated.ts +2408 -0
- package/src/hook-runner.ts +310 -0
- package/src/index.ts +8 -0
- package/src/observation.ts +65 -0
- package/src/server/attachments.ts +128 -0
- package/src/server/hooks.ts +205 -0
- package/src/server/index.ts +18 -0
- package/src/server/node-crypto.d.ts +7 -0
- package/src/server/stdio.ts +81 -0
- package/src/transport.ts +273 -0
- package/src/types.ts +124 -0
- package/src/validation.ts +372 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { HookOperationalError, asOperationalError } from "./errors.js";
|
|
3
|
+
import { createTransport, type LineTransport } from "./transport.js";
|
|
4
|
+
import {
|
|
5
|
+
INTERCEPT_METHOD,
|
|
6
|
+
PROTOCOL_VERSION,
|
|
7
|
+
type HookBackend,
|
|
8
|
+
type HookFailure,
|
|
9
|
+
type IdGenerator,
|
|
10
|
+
type InterceptRequest,
|
|
11
|
+
type ToolBeforeEvent,
|
|
12
|
+
type ToolBeforeInput,
|
|
13
|
+
type ToolBeforeOutcome,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import {
|
|
16
|
+
assertJsonObject,
|
|
17
|
+
parseInterceptRequest,
|
|
18
|
+
parseInterceptResponse,
|
|
19
|
+
} from "./validation.js";
|
|
20
|
+
|
|
21
|
+
interface Registration {
|
|
22
|
+
config: Required<
|
|
23
|
+
Pick<
|
|
24
|
+
HookBackend,
|
|
25
|
+
| "command"
|
|
26
|
+
| "args"
|
|
27
|
+
| "timeoutMs"
|
|
28
|
+
| "failurePolicy"
|
|
29
|
+
| "retries"
|
|
30
|
+
| "lifecycle"
|
|
31
|
+
>
|
|
32
|
+
> &
|
|
33
|
+
Pick<HookBackend, "name">;
|
|
34
|
+
transport: LineTransport;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateBackend(backend: HookBackend): Registration["config"] {
|
|
38
|
+
if (backend === null || typeof backend !== "object")
|
|
39
|
+
throw new HookOperationalError(
|
|
40
|
+
"INVALID_CONFIG",
|
|
41
|
+
"Backend configuration must be an object",
|
|
42
|
+
);
|
|
43
|
+
if (typeof backend.command !== "string" || backend.command.length === 0)
|
|
44
|
+
throw new HookOperationalError(
|
|
45
|
+
"INVALID_CONFIG",
|
|
46
|
+
"Backend command must be a non-empty string",
|
|
47
|
+
);
|
|
48
|
+
const args = backend.args === undefined ? [] : [...backend.args];
|
|
49
|
+
if (!args.every((arg) => typeof arg === "string"))
|
|
50
|
+
throw new HookOperationalError(
|
|
51
|
+
"INVALID_CONFIG",
|
|
52
|
+
"Backend args must be strings",
|
|
53
|
+
);
|
|
54
|
+
if (!Number.isInteger(backend.timeoutMs) || backend.timeoutMs <= 0)
|
|
55
|
+
throw new HookOperationalError(
|
|
56
|
+
"INVALID_CONFIG",
|
|
57
|
+
"Backend timeoutMs must be a positive integer",
|
|
58
|
+
);
|
|
59
|
+
if (
|
|
60
|
+
backend.failurePolicy !== "fail-open" &&
|
|
61
|
+
backend.failurePolicy !== "fail-closed"
|
|
62
|
+
)
|
|
63
|
+
throw new HookOperationalError(
|
|
64
|
+
"INVALID_CONFIG",
|
|
65
|
+
"Backend failurePolicy must be explicit",
|
|
66
|
+
);
|
|
67
|
+
const retries = backend.retries ?? 0;
|
|
68
|
+
if (!Number.isInteger(retries) || retries < 0 || retries > 10)
|
|
69
|
+
throw new HookOperationalError(
|
|
70
|
+
"INVALID_CONFIG",
|
|
71
|
+
"Backend retries must be an integer from 0 through 10",
|
|
72
|
+
);
|
|
73
|
+
const lifecycle = backend.lifecycle ?? "per_event";
|
|
74
|
+
if (lifecycle !== "per_event" && lifecycle !== "persistent")
|
|
75
|
+
throw new HookOperationalError("INVALID_CONFIG", "Unsupported lifecycle");
|
|
76
|
+
return backend.name === undefined
|
|
77
|
+
? {
|
|
78
|
+
command: backend.command,
|
|
79
|
+
args,
|
|
80
|
+
timeoutMs: backend.timeoutMs,
|
|
81
|
+
failurePolicy: backend.failurePolicy,
|
|
82
|
+
retries,
|
|
83
|
+
lifecycle,
|
|
84
|
+
}
|
|
85
|
+
: {
|
|
86
|
+
command: backend.command,
|
|
87
|
+
args,
|
|
88
|
+
timeoutMs: backend.timeoutMs,
|
|
89
|
+
failurePolicy: backend.failurePolicy,
|
|
90
|
+
retries,
|
|
91
|
+
lifecycle,
|
|
92
|
+
name: backend.name,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function snapshot<T>(value: T, label: string): T {
|
|
97
|
+
assertJsonObject(value, label);
|
|
98
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class ToolBeforeRunner {
|
|
102
|
+
readonly #ids: IdGenerator;
|
|
103
|
+
readonly #registrations: Registration[] = [];
|
|
104
|
+
#tail: Promise<void> = Promise.resolve();
|
|
105
|
+
|
|
106
|
+
constructor(options: { idGenerator?: IdGenerator } = {}) {
|
|
107
|
+
this.#ids = options.idGenerator ?? ((kind) => `${kind}_${randomUUID()}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
register(backend: HookBackend): () => void {
|
|
111
|
+
const config = validateBackend(backend);
|
|
112
|
+
const registration: Registration = {
|
|
113
|
+
config,
|
|
114
|
+
transport: createTransport(config.command, config.args, config.lifecycle),
|
|
115
|
+
};
|
|
116
|
+
this.#registrations.push(registration);
|
|
117
|
+
return () => {
|
|
118
|
+
const index = this.#registrations.indexOf(registration);
|
|
119
|
+
if (index !== -1) this.#registrations.splice(index, 1);
|
|
120
|
+
registration.transport.close();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
intercept(input: ToolBeforeInput): Promise<ToolBeforeOutcome> {
|
|
125
|
+
const result = this.#tail.then(() => this.#interceptNow(input));
|
|
126
|
+
this.#tail = result.then(
|
|
127
|
+
() => undefined,
|
|
128
|
+
() => undefined,
|
|
129
|
+
);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async #interceptNow(input: ToolBeforeInput): Promise<ToolBeforeOutcome> {
|
|
134
|
+
if (
|
|
135
|
+
input === null ||
|
|
136
|
+
typeof input !== "object" ||
|
|
137
|
+
input.session === null ||
|
|
138
|
+
typeof input.session !== "object" ||
|
|
139
|
+
input.tool === null ||
|
|
140
|
+
typeof input.tool !== "object"
|
|
141
|
+
) {
|
|
142
|
+
throw new HookOperationalError(
|
|
143
|
+
"INVALID_CONFIG",
|
|
144
|
+
"Tool input, session, and tool must be objects",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const session = {
|
|
148
|
+
id: this.#providedId(input.session.id, "session"),
|
|
149
|
+
...(input.session.cwd === undefined ? {} : { cwd: input.session.cwd }),
|
|
150
|
+
...(input.session.workspaceRoots === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { workspaceRoots: input.session.workspaceRoots }),
|
|
153
|
+
...(input.session.model === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { model: input.session.model }),
|
|
156
|
+
...(input.session.agent === undefined
|
|
157
|
+
? {}
|
|
158
|
+
: { agent: input.session.agent }),
|
|
159
|
+
};
|
|
160
|
+
const tool = {
|
|
161
|
+
callId: this.#providedId(input.tool.callId, "call"),
|
|
162
|
+
name: input.tool.name,
|
|
163
|
+
kind: input.tool.kind,
|
|
164
|
+
input: snapshot(input.tool.input, "tool.input"),
|
|
165
|
+
...(input.tool.mcp === undefined
|
|
166
|
+
? {}
|
|
167
|
+
: { mcp: snapshot(input.tool.mcp, "tool.mcp") }),
|
|
168
|
+
};
|
|
169
|
+
const event: ToolBeforeEvent = {
|
|
170
|
+
id: this.#providedId(input.eventId, "event"),
|
|
171
|
+
source: input.source,
|
|
172
|
+
type: "tool.before",
|
|
173
|
+
time: input.time ?? new Date().toISOString(),
|
|
174
|
+
session,
|
|
175
|
+
tool,
|
|
176
|
+
...(input.native === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { native: snapshot(input.native, "native") }),
|
|
179
|
+
...(input.extensions === undefined
|
|
180
|
+
? {}
|
|
181
|
+
: { extensions: snapshot(input.extensions, "extensions") }),
|
|
182
|
+
};
|
|
183
|
+
parseInterceptRequest(
|
|
184
|
+
JSON.stringify({
|
|
185
|
+
jsonrpc: "2.0",
|
|
186
|
+
id: event.id,
|
|
187
|
+
method: INTERCEPT_METHOD,
|
|
188
|
+
params: {
|
|
189
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
190
|
+
event,
|
|
191
|
+
capabilities: { effects: ["deny"] },
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
const failures: HookFailure[] = [];
|
|
196
|
+
const registrations = [...this.#registrations];
|
|
197
|
+
for (const registration of registrations) {
|
|
198
|
+
const request: InterceptRequest = {
|
|
199
|
+
jsonrpc: "2.0",
|
|
200
|
+
id: event.id,
|
|
201
|
+
method: INTERCEPT_METHOD,
|
|
202
|
+
params: {
|
|
203
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
204
|
+
event,
|
|
205
|
+
capabilities: { effects: ["deny"] },
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
const line = JSON.stringify(request);
|
|
209
|
+
parseInterceptRequest(line);
|
|
210
|
+
let lastError: HookOperationalError | undefined;
|
|
211
|
+
const deadline = Date.now() + registration.config.timeoutMs;
|
|
212
|
+
for (
|
|
213
|
+
let attempt = 0;
|
|
214
|
+
attempt <= registration.config.retries;
|
|
215
|
+
attempt += 1
|
|
216
|
+
) {
|
|
217
|
+
const remainingMs = deadline - Date.now();
|
|
218
|
+
if (remainingMs <= 0) {
|
|
219
|
+
lastError = new HookOperationalError(
|
|
220
|
+
"TIMEOUT",
|
|
221
|
+
`Hook exchange exceeded ${registration.config.timeoutMs} ms`,
|
|
222
|
+
);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const responseLine = await registration.transport.exchange(
|
|
227
|
+
line,
|
|
228
|
+
remainingMs,
|
|
229
|
+
);
|
|
230
|
+
const response = parseInterceptResponse(responseLine, event.id);
|
|
231
|
+
const effect = response.effects[0];
|
|
232
|
+
if (effect !== undefined) {
|
|
233
|
+
const denial = {
|
|
234
|
+
backend: this.#backendName(registration),
|
|
235
|
+
reason: effect.reason,
|
|
236
|
+
operational: false,
|
|
237
|
+
...(effect.code === undefined ? {} : { code: effect.code }),
|
|
238
|
+
...(effect.extensions === undefined
|
|
239
|
+
? {}
|
|
240
|
+
: { extensions: effect.extensions }),
|
|
241
|
+
};
|
|
242
|
+
return { decision: "deny", event, failures, denial };
|
|
243
|
+
}
|
|
244
|
+
lastError = undefined;
|
|
245
|
+
break;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
lastError = asOperationalError(error);
|
|
248
|
+
registration.transport.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (lastError !== undefined) {
|
|
252
|
+
const failure: HookFailure = {
|
|
253
|
+
backend: this.#backendName(registration),
|
|
254
|
+
policy: registration.config.failurePolicy,
|
|
255
|
+
code: lastError.code,
|
|
256
|
+
message: lastError.message,
|
|
257
|
+
};
|
|
258
|
+
failures.push(failure);
|
|
259
|
+
if (registration.config.failurePolicy === "fail-closed") {
|
|
260
|
+
return {
|
|
261
|
+
decision: "deny",
|
|
262
|
+
event,
|
|
263
|
+
failures,
|
|
264
|
+
denial: {
|
|
265
|
+
backend: failure.backend,
|
|
266
|
+
reason: `Operational failure: ${failure.code}`,
|
|
267
|
+
operational: true,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { decision: "continue", event, failures };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#providedId(
|
|
277
|
+
value: string | undefined,
|
|
278
|
+
kind: "event" | "session" | "call",
|
|
279
|
+
): string {
|
|
280
|
+
if (value !== undefined) {
|
|
281
|
+
if (typeof value !== "string" || value.length === 0)
|
|
282
|
+
throw new HookOperationalError(
|
|
283
|
+
"INVALID_CONFIG",
|
|
284
|
+
`${kind} ID must be a non-empty string`,
|
|
285
|
+
);
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
return this.#generatedId(kind);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#generatedId(kind: "event" | "session" | "call"): string {
|
|
292
|
+
const value = this.#ids(kind);
|
|
293
|
+
if (typeof value !== "string" || value.length === 0)
|
|
294
|
+
throw new HookOperationalError(
|
|
295
|
+
"INVALID_CONFIG",
|
|
296
|
+
`Generated ${kind} ID must be a non-empty string`,
|
|
297
|
+
);
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#backendName(registration: Registration): string {
|
|
302
|
+
return registration.config.name ?? registration.config.command;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
close(): void {
|
|
306
|
+
for (const registration of this.#registrations)
|
|
307
|
+
registration.transport.close();
|
|
308
|
+
this.#registrations.length = 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** One-way delivery of an already-settled boundary. */
|
|
2
|
+
export interface ObservationSubscription {
|
|
3
|
+
id: string;
|
|
4
|
+
mode: "intercept" | "observe";
|
|
5
|
+
}
|
|
6
|
+
export interface ObservationEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
source: string;
|
|
9
|
+
type: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface ObservationNotification {
|
|
13
|
+
jsonrpc: "2.0";
|
|
14
|
+
method: "hooks/observe";
|
|
15
|
+
params: { protocolVersion: "draft"; event: ObservationEvent };
|
|
16
|
+
}
|
|
17
|
+
/** The host supplies matching subscriptions and invoked intercept subscription IDs.
|
|
18
|
+
* prepare MUST apply existing content permissions/selections and confirm uploads.
|
|
19
|
+
* Its failure drops only this best-effort delivery, never reopens the decision.
|
|
20
|
+
* schedule MUST enqueue without awaiting work; it must not keep interrupted
|
|
21
|
+
* execution alive. This injection supports browser and server host schedulers. */
|
|
22
|
+
export function dispatchObservations(
|
|
23
|
+
event: ObservationEvent,
|
|
24
|
+
subscriptions: readonly ObservationSubscription[],
|
|
25
|
+
called: ReadonlySet<string>,
|
|
26
|
+
prepare: (
|
|
27
|
+
event: ObservationEvent,
|
|
28
|
+
subscription: ObservationSubscription,
|
|
29
|
+
) => Promise<ObservationEvent>,
|
|
30
|
+
notify: (
|
|
31
|
+
notification: ObservationNotification,
|
|
32
|
+
subscription: ObservationSubscription,
|
|
33
|
+
) => unknown,
|
|
34
|
+
schedule: (work: () => void) => void,
|
|
35
|
+
): void {
|
|
36
|
+
const snapshot = structuredClone(event);
|
|
37
|
+
for (const subscription of structuredClone(subscriptions)) {
|
|
38
|
+
if (
|
|
39
|
+
subscription.mode !== "observe" &&
|
|
40
|
+
!(subscription.mode === "intercept" && !called.has(subscription.id))
|
|
41
|
+
)
|
|
42
|
+
continue;
|
|
43
|
+
schedule(() => {
|
|
44
|
+
void Promise.resolve()
|
|
45
|
+
.then(() => prepare(structuredClone(snapshot), subscription))
|
|
46
|
+
.then((projected) => {
|
|
47
|
+
if (
|
|
48
|
+
["id", "source", "type"].some(
|
|
49
|
+
(key) => projected[key] !== snapshot[key],
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
throw Error("Observation changed boundary identity");
|
|
53
|
+
return notify(
|
|
54
|
+
{
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
method: "hooks/observe",
|
|
57
|
+
params: { protocolVersion: "draft", event: projected },
|
|
58
|
+
},
|
|
59
|
+
subscription,
|
|
60
|
+
);
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
parseContentReference,
|
|
4
|
+
type ContentReference,
|
|
5
|
+
} from "../draft/generated.js";
|
|
6
|
+
import { validateWire } from "../client/validation.js";
|
|
7
|
+
|
|
8
|
+
/** Safe protocol upload failure; applications can map status to an HTTP response. */
|
|
9
|
+
export class UploadError extends Error {
|
|
10
|
+
readonly status = 400;
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "UploadError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export interface Upload {
|
|
17
|
+
/** Consume to successful EOF before committing storage or confirming availability. */
|
|
18
|
+
body: ReadableStream<Uint8Array>;
|
|
19
|
+
/** Declared framing, not independently verified until body reaches successful EOF. */
|
|
20
|
+
size: number;
|
|
21
|
+
sha256: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Synchronous framing checks; bytes are read and verified only on consumer demand.
|
|
25
|
+
* The application owns authorization, storage, reference allocation and commit.
|
|
26
|
+
* A storage writer must propagate stream errors and await successful EOF.
|
|
27
|
+
*/
|
|
28
|
+
function parse(request: Request): Upload {
|
|
29
|
+
const length = request.headers.get("content-length");
|
|
30
|
+
const sha256 = request.headers.get("ahp-content-sha256");
|
|
31
|
+
if (
|
|
32
|
+
request.method !== "POST" ||
|
|
33
|
+
request.headers.get("content-type")?.toLowerCase() !==
|
|
34
|
+
"application/octet-stream" ||
|
|
35
|
+
request.headers.has("content-encoding") ||
|
|
36
|
+
request.headers.has("transfer-encoding") ||
|
|
37
|
+
length === null ||
|
|
38
|
+
!/^[0-9]+$/.test(length) ||
|
|
39
|
+
!Number.isSafeInteger(Number(length)) ||
|
|
40
|
+
sha256 === null ||
|
|
41
|
+
!/^[0-9a-f]{64}$/.test(sha256) ||
|
|
42
|
+
request.bodyUsed ||
|
|
43
|
+
request.body?.locked
|
|
44
|
+
) {
|
|
45
|
+
throw new UploadError("Invalid upload framing");
|
|
46
|
+
}
|
|
47
|
+
const size = Number(length);
|
|
48
|
+
const hash = createHash("sha256");
|
|
49
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
50
|
+
let count = 0;
|
|
51
|
+
const body = new ReadableStream<Uint8Array>(
|
|
52
|
+
{
|
|
53
|
+
async pull(controller) {
|
|
54
|
+
try {
|
|
55
|
+
reader ??= request.body?.getReader();
|
|
56
|
+
const chunk = reader
|
|
57
|
+
? await reader.read()
|
|
58
|
+
: { done: true as const, value: undefined };
|
|
59
|
+
if (chunk.done) {
|
|
60
|
+
reader?.releaseLock();
|
|
61
|
+
if (count !== size) throw new UploadError("Upload length mismatch");
|
|
62
|
+
if (hash.digest("hex") !== sha256)
|
|
63
|
+
throw new UploadError("Upload hash mismatch");
|
|
64
|
+
controller.close();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
!(chunk.value instanceof Uint8Array) ||
|
|
69
|
+
chunk.value.byteLength > size - count
|
|
70
|
+
)
|
|
71
|
+
throw new UploadError("Upload length mismatch");
|
|
72
|
+
// Snapshot mutable producer buffers before hashing and handing them to storage.
|
|
73
|
+
const bytes = new Uint8Array(chunk.value);
|
|
74
|
+
count += bytes.byteLength;
|
|
75
|
+
hash.update(bytes);
|
|
76
|
+
controller.enqueue(bytes);
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
controller.error(
|
|
79
|
+
cause instanceof UploadError
|
|
80
|
+
? cause
|
|
81
|
+
: new UploadError("Upload body read failed"),
|
|
82
|
+
);
|
|
83
|
+
try {
|
|
84
|
+
await reader?.cancel();
|
|
85
|
+
} catch {
|
|
86
|
+
/* Do not mask the validation failure. */
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
reader?.releaseLock();
|
|
90
|
+
} catch {
|
|
91
|
+
/* Reader may already have released its lock. */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async cancel(reason) {
|
|
96
|
+
if (reader) {
|
|
97
|
+
try {
|
|
98
|
+
await reader.cancel(reason);
|
|
99
|
+
} finally {
|
|
100
|
+
reader.releaseLock();
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
await request.body?.cancel(reason);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{ highWaterMark: 0 },
|
|
108
|
+
);
|
|
109
|
+
return { body, size, sha256 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Validate and format a descriptor. This does not verify bytes or commit storage.
|
|
113
|
+
* Call only after an authorized storage write consumed body to successful EOF
|
|
114
|
+
* and the immutable reference is synchronously available to the receiver.
|
|
115
|
+
*/
|
|
116
|
+
function response(descriptor: ContentReference): Response {
|
|
117
|
+
if (validateWire("content-reference", descriptor).length !== 0)
|
|
118
|
+
throw new UploadError("Invalid content descriptor");
|
|
119
|
+
const decoded = parseContentReference(descriptor);
|
|
120
|
+
if (!decoded.ok || !Number.isSafeInteger(decoded.value.size))
|
|
121
|
+
throw new UploadError("Invalid content descriptor");
|
|
122
|
+
return new Response(JSON.stringify(decoded.value), {
|
|
123
|
+
status: 201,
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const attachments = { parse, response };
|