@keri-ts/hosting 0.0.0-stage → 2.0.1
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 +22 -2
- package/esm/hosting/npm/src/_dnt.shims.js +61 -0
- package/esm/hosting/npm/src/mod.js +7 -0
- package/esm/hosting/npm/src/src/host/http-server.js +310 -0
- package/esm/hosting/npm/src/src/host/kernel.js +83 -0
- package/esm/hosting/npm/src/src/http/app.js +33 -0
- package/esm/hosting/npm/src/src/http/error-mapping.js +19 -0
- package/esm/hosting/npm/src/src/http/policy.js +63 -0
- package/esm/hosting/npm/src/src/http/protocol/artifact-files.js +164 -0
- package/esm/hosting/npm/src/src/http/protocol/context.js +48 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/did-resolution.js +90 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/dws-artifacts.js +112 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/generic-cesr-ingress.js +272 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/health.js +9 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/mailbox-admin.js +136 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/oobi.js +171 -0
- package/esm/hosting/npm/src/src/http/protocol/endpoints/witness-http.js +98 -0
- package/esm/hosting/npm/src/src/http/protocol/responses.js +21 -0
- package/esm/hosting/npm/src/src/http/protocol/runtime-bridge.js +16 -0
- package/esm/hosting/npm/src/src/http/protocol/types.js +1 -0
- package/esm/hosting/npm/src/src/http/protocol-handler.js +68 -0
- package/esm/hosting/npm/src/src/http/source.js +118 -0
- package/esm/package.json +3 -0
- package/package.json +43 -4
- package/types/_dnt.shims.d.ts +6 -0
- package/types/_dnt.shims.d.ts.map +1 -0
- package/types/mod.d.ts +9 -0
- package/types/mod.d.ts.map +1 -0
- package/types/src/host/http-server.d.ts +46 -0
- package/types/src/host/http-server.d.ts.map +1 -0
- package/types/src/host/kernel.d.ts +37 -0
- package/types/src/host/kernel.d.ts.map +1 -0
- package/types/src/http/app.d.ts +22 -0
- package/types/src/http/app.d.ts.map +1 -0
- package/types/src/http/error-mapping.d.ts +7 -0
- package/types/src/http/error-mapping.d.ts.map +1 -0
- package/types/src/http/policy.d.ts +27 -0
- package/types/src/http/policy.d.ts.map +1 -0
- package/types/src/http/protocol/artifact-files.d.ts +11 -0
- package/types/src/http/protocol/artifact-files.d.ts.map +1 -0
- package/types/src/http/protocol/context.d.ts +14 -0
- package/types/src/http/protocol/context.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/did-resolution.d.ts +12 -0
- package/types/src/http/protocol/endpoints/did-resolution.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/dws-artifacts.d.ts +9 -0
- package/types/src/http/protocol/endpoints/dws-artifacts.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/generic-cesr-ingress.d.ts +21 -0
- package/types/src/http/protocol/endpoints/generic-cesr-ingress.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/health.d.ts +6 -0
- package/types/src/http/protocol/endpoints/health.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/mailbox-admin.d.ts +26 -0
- package/types/src/http/protocol/endpoints/mailbox-admin.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/oobi.d.ts +31 -0
- package/types/src/http/protocol/endpoints/oobi.d.ts.map +1 -0
- package/types/src/http/protocol/endpoints/witness-http.d.ts +13 -0
- package/types/src/http/protocol/endpoints/witness-http.d.ts.map +1 -0
- package/types/src/http/protocol/responses.d.ts +7 -0
- package/types/src/http/protocol/responses.d.ts.map +1 -0
- package/types/src/http/protocol/runtime-bridge.d.ts +13 -0
- package/types/src/http/protocol/runtime-bridge.d.ts.map +1 -0
- package/types/src/http/protocol/types.d.ts +78 -0
- package/types/src/http/protocol/types.d.ts.map +1 -0
- package/types/src/http/protocol-handler.d.ts +19 -0
- package/types/src/http/protocol-handler.d.ts.map +1 -0
- package/types/src/http/source.d.ts +25 -0
- package/types/src/http/source.d.ts.map +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic CESR POST/PUT ingress for the Tufa HTTP edge.
|
|
3
|
+
*
|
|
4
|
+
* Explicit routes are classified before this module runs. This handler owns
|
|
5
|
+
* the remaining raw CESR traffic and decides whether it belongs to local
|
|
6
|
+
* witness ingestion, mailbox SSE query streaming, or normal runtime ingress.
|
|
7
|
+
*/
|
|
8
|
+
import { Ilks, parse, ParserError } from "cesr-ts";
|
|
9
|
+
import { call, ensure, run } from "effection";
|
|
10
|
+
import { drainWitnessCues, isCesrContentType, requestBytes, rethrowCleanupFailure, ValidationError, } from "keri-ts/runtime";
|
|
11
|
+
import { jsonNoContentResponse, textResponse } from "../responses.js";
|
|
12
|
+
import { drainRuntimeCues } from "../runtime-bridge.js";
|
|
13
|
+
/** No hosted endpoint matched this generic ingress request. */
|
|
14
|
+
const NONE_HOSTED_ROUTE = {
|
|
15
|
+
kind: "none",
|
|
16
|
+
endpoint: null,
|
|
17
|
+
relativePath: null,
|
|
18
|
+
};
|
|
19
|
+
/** Classify generic POST/PUT CESR ingress after all explicit HTTP routes. */
|
|
20
|
+
export function classifyGenericCesrIngressRoute(context) {
|
|
21
|
+
if (!context.runtime || (context.method !== "POST" && context.method !== "PUT")) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (context.genericIngress?.kind === "ambiguous") {
|
|
25
|
+
return {
|
|
26
|
+
kind: "ambiguousHostedPath",
|
|
27
|
+
message: "Ambiguous hosted endpoint path",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
kind: "genericCesrIngress",
|
|
32
|
+
hosted: context.genericIngress ?? NONE_HOSTED_ROUTE,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide how one inspected CESR request should be ingested.
|
|
37
|
+
*
|
|
38
|
+
* This is the explicit replacement for the old inline witness-root boolean.
|
|
39
|
+
*/
|
|
40
|
+
export function classifyCesrIngressRoute(context, hosted, serder) {
|
|
41
|
+
const mailboxAid = hosted.kind === "one"
|
|
42
|
+
? hosted.endpoint?.eid ?? null
|
|
43
|
+
: null;
|
|
44
|
+
const witnessHab = context.policy.witnessHab;
|
|
45
|
+
if (witnessHab
|
|
46
|
+
&& hosted.kind === "one"
|
|
47
|
+
&& hosted.endpoint?.eid === witnessHab.pre
|
|
48
|
+
&& serder.ilk !== Ilks.qry
|
|
49
|
+
&& serder.ilk !== Ilks.exn) {
|
|
50
|
+
// Witness-hosted events are accepted locally by the witness processor, but
|
|
51
|
+
// witness `qry` and peer `exn` traffic still belongs to runtime routing.
|
|
52
|
+
return { kind: "witnessLocalIngress", witnessHab };
|
|
53
|
+
}
|
|
54
|
+
if (serder.ilk === Ilks.qry && serder.route === "mbx") {
|
|
55
|
+
const query = serder.ked?.q;
|
|
56
|
+
const pre = typeof query?.i === "string" ? query.i : null;
|
|
57
|
+
return {
|
|
58
|
+
kind: "mailboxQueryStream",
|
|
59
|
+
mailboxAid,
|
|
60
|
+
pre,
|
|
61
|
+
topics: query?.topics,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { kind: "runtimeIngress", mailboxAid };
|
|
65
|
+
}
|
|
66
|
+
/** Return the mailbox stream correlated with this already-authorized query. */
|
|
67
|
+
function querySseResponse(context, said) {
|
|
68
|
+
if (!said) {
|
|
69
|
+
return textResponse("Query is missing said", 400);
|
|
70
|
+
}
|
|
71
|
+
return new Response(context.runtime.mailboxDirector.streamQueryResponse(said), {
|
|
72
|
+
status: 200,
|
|
73
|
+
headers: {
|
|
74
|
+
"Content-Type": "text/event-stream",
|
|
75
|
+
"Cache-Control": "no-cache",
|
|
76
|
+
"Connection": "close",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Run one request in a single owned Effection task and join it on disconnect. */
|
|
81
|
+
export async function handleGenericCesrIngress(context, hosted) {
|
|
82
|
+
if (!isCesrContentType(context.req.headers.get("content-type"))) {
|
|
83
|
+
await context.req.body?.cancel();
|
|
84
|
+
return textResponse("Unacceptable content type.", 406);
|
|
85
|
+
}
|
|
86
|
+
let response, operationFailure;
|
|
87
|
+
const task = run(function* () {
|
|
88
|
+
try {
|
|
89
|
+
response = yield* call(() => ingest(context, hosted, (value) => response = value));
|
|
90
|
+
return response;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
operationFailure = { error };
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
let stopping;
|
|
98
|
+
/** Own one halt and join its cleanup; notification observes but never consumes its failure. */
|
|
99
|
+
const stop = () => {
|
|
100
|
+
stopping ??= Promise.resolve(task.halt());
|
|
101
|
+
void stopping.catch(() => { });
|
|
102
|
+
};
|
|
103
|
+
context.req.signal.addEventListener("abort", stop, { once: true });
|
|
104
|
+
if (context.req.signal.aborted)
|
|
105
|
+
stop();
|
|
106
|
+
let primary;
|
|
107
|
+
const cleanup = [];
|
|
108
|
+
const note = (error) => {
|
|
109
|
+
if (!cleanup.includes(error))
|
|
110
|
+
cleanup.push(error);
|
|
111
|
+
};
|
|
112
|
+
let haltedCleanly = false;
|
|
113
|
+
try {
|
|
114
|
+
await task;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
primary = { error };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
context.req.signal.removeEventListener("abort", stop);
|
|
121
|
+
try {
|
|
122
|
+
await (stopping ?? Promise.resolve(task.halt()));
|
|
123
|
+
haltedCleanly = true;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
note(error);
|
|
127
|
+
}
|
|
128
|
+
// Failure may precede reader acquisition; cancellation failure is still this owner's responsibility.
|
|
129
|
+
if (context.req.body && !context.req.body.locked) {
|
|
130
|
+
try {
|
|
131
|
+
await context.req.body.cancel();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error !== context.req.signal.reason) {
|
|
135
|
+
note(error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const ownCancellation = context.req.signal.aborted && stopping && haltedCleanly
|
|
141
|
+
&& (!operationFailure || operationFailure.error === context.req.signal.reason);
|
|
142
|
+
if (primary && !ownCancellation) {
|
|
143
|
+
const error = primary.error;
|
|
144
|
+
if (!context.req.signal.aborted && !cleanup.length) {
|
|
145
|
+
if (error instanceof ParserError || error instanceof ValidationError)
|
|
146
|
+
return textResponse(error.message, 400);
|
|
147
|
+
}
|
|
148
|
+
if (!cleanup.includes(error))
|
|
149
|
+
cleanup.unshift(error);
|
|
150
|
+
}
|
|
151
|
+
// Published output is independent of ingress. A late abort must close it outside status/error mapping.
|
|
152
|
+
if (response && (context.req.signal.aborted || cleanup.length)) {
|
|
153
|
+
try {
|
|
154
|
+
await response.body?.cancel();
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
note(error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (cleanup.length === 1)
|
|
161
|
+
throw cleanup[0];
|
|
162
|
+
if (cleanup.length)
|
|
163
|
+
throw new AggregateError(cleanup, "HTTP ingress cleanup failed");
|
|
164
|
+
return context.req.signal.aborted ? textResponse("Request cancelled", 408) : response;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Parse on demand, dispatch each completed child, and retain only the final response query.
|
|
168
|
+
*
|
|
169
|
+
* @param context Request context owning body parsing and response lifetime.
|
|
170
|
+
* @param hosted Selected hosted endpoint/provenance for this request.
|
|
171
|
+
* @param publish Synchronous response-publication callback for the request owner; invoked while response resources remain live.
|
|
172
|
+
* @returns Request-owned response; ordinary queries acknowledge intake and the final authorized mailbox query may stream.
|
|
173
|
+
*/
|
|
174
|
+
function* ingest(context, hosted, publish) {
|
|
175
|
+
const runtime = context.runtime;
|
|
176
|
+
let seen = false;
|
|
177
|
+
let query;
|
|
178
|
+
// Close and join the request parser before publishing a mailbox response.
|
|
179
|
+
yield* call(function* () {
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const signal = AbortSignal.any([controller.signal, context.req.signal]);
|
|
182
|
+
try {
|
|
183
|
+
signal.throwIfAborted();
|
|
184
|
+
const frames = parse(requestBytes(context.req, { signal }));
|
|
185
|
+
let pending;
|
|
186
|
+
// The earlier resource joins the read after processing children stop.
|
|
187
|
+
yield* ensure(function* () {
|
|
188
|
+
try {
|
|
189
|
+
if (pending) {
|
|
190
|
+
try {
|
|
191
|
+
yield* call(() => pending);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
rethrowCleanupFailure(error, signal);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
yield* call(() => frames.return());
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
while (true) {
|
|
203
|
+
signal.throwIfAborted();
|
|
204
|
+
pending = frames.next();
|
|
205
|
+
let result;
|
|
206
|
+
try {
|
|
207
|
+
result = yield* call(() => pending);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
pending = undefined;
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
pending = undefined;
|
|
214
|
+
signal.throwIfAborted();
|
|
215
|
+
if (result.done)
|
|
216
|
+
break;
|
|
217
|
+
const frame = result.value;
|
|
218
|
+
yield* call(function* () {
|
|
219
|
+
query = undefined;
|
|
220
|
+
seen = true;
|
|
221
|
+
const serder = frame.body;
|
|
222
|
+
const route = classifyCesrIngressRoute(context, hosted, serder);
|
|
223
|
+
if (serder.ilk === Ilks.qry) {
|
|
224
|
+
query = {
|
|
225
|
+
serder,
|
|
226
|
+
mailbox: route.kind === "mailboxQueryStream",
|
|
227
|
+
authorized: false,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const { decision, mailboxQueryAuthorized } = yield* runtime.protocol.dispatch(frame, {
|
|
231
|
+
local: route.kind === "witnessLocalIngress",
|
|
232
|
+
mailboxAid: route.kind === "witnessLocalIngress" ? hosted.endpoint?.eid : route.mailboxAid,
|
|
233
|
+
escrows: true,
|
|
234
|
+
});
|
|
235
|
+
if (query) {
|
|
236
|
+
// SAIDs exclude attachments: authorize this exact envelope, not an older query cue.
|
|
237
|
+
query.transportOnly = decision?.kind === "drop"
|
|
238
|
+
&& (decision.reason === "missingProcessor" || decision.reason === "replay")
|
|
239
|
+
&& !["logs", "ksn", "mbx", "tels", "tsn"].includes(query.serder.route ?? "");
|
|
240
|
+
query.authorized = decision?.kind !== "drop" && decision?.kind !== "pending"
|
|
241
|
+
&& decision?.kind !== "missing"
|
|
242
|
+
&& (!query.mailbox || mailboxQueryAuthorized);
|
|
243
|
+
}
|
|
244
|
+
if (route.kind === "witnessLocalIngress") {
|
|
245
|
+
yield* drainWitnessCues(runtime, route.witnessHab, runtime.cues);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
yield* drainRuntimeCues(runtime, context.policy.serviceHab, runtime.cues);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
controller.abort();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
if (!seen)
|
|
258
|
+
return textResponse("Invalid CESR request", 400);
|
|
259
|
+
if (!query)
|
|
260
|
+
return jsonNoContentResponse();
|
|
261
|
+
// Unhandled or cached-replayed custom queries acknowledge transport only; no protocol work is authorized.
|
|
262
|
+
if (query.transportOnly)
|
|
263
|
+
return jsonNoContentResponse();
|
|
264
|
+
if (!query.authorized)
|
|
265
|
+
return textResponse("Query requester is not authorized", 403);
|
|
266
|
+
if (query.mailbox) {
|
|
267
|
+
const response = querySseResponse(context, query.serder.said ?? null);
|
|
268
|
+
publish(response);
|
|
269
|
+
return response;
|
|
270
|
+
}
|
|
271
|
+
return jsonNoContentResponse();
|
|
272
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { textResponse } from "../responses.js";
|
|
2
|
+
/** Classify `/health` before any runtime-aware routing. */
|
|
3
|
+
export function classifyHealthRoute(context) {
|
|
4
|
+
return context.pathname === "/health" ? { kind: "health" } : null;
|
|
5
|
+
}
|
|
6
|
+
/** Return the fixed health response. */
|
|
7
|
+
export function handleHealthRoute() {
|
|
8
|
+
return textResponse("ok", 200);
|
|
9
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Ilks } from "cesr-ts";
|
|
2
|
+
import { run } from "effection";
|
|
3
|
+
import { chunkBytes, readMailboxAdminRequest, Roles, ValidationError, } from "keri-ts/runtime";
|
|
4
|
+
import { textResponse } from "../responses.js";
|
|
5
|
+
/** Classify mailbox-admin requests before witness or generic ingress routing. */
|
|
6
|
+
export function classifyMailboxAdminRoute(context) {
|
|
7
|
+
if (!context.runtime || context.method !== "POST") {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
if (context.mailboxAdmin?.kind === "ambiguous") {
|
|
11
|
+
return {
|
|
12
|
+
kind: "ambiguousHostedPath",
|
|
13
|
+
message: "Ambiguous mailbox endpoint path",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (context.mailboxAdmin?.kind === "one") {
|
|
17
|
+
return {
|
|
18
|
+
kind: "mailboxAdmin",
|
|
19
|
+
mailboxAid: context.mailboxAdmin.endpoint.eid,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Handle mailbox add/remove authorization requests for one hosted mailbox AID.
|
|
26
|
+
*
|
|
27
|
+
* Contract:
|
|
28
|
+
* - request body is either one `application/cesr` stream or one
|
|
29
|
+
* `multipart/form-data` request carrying `kel`, optional `delkel`, and `rpy`
|
|
30
|
+
* - both request shapes normalize to one mailbox authorization CESR stream
|
|
31
|
+
* - that stream ends in the mailbox authorization `rpy`
|
|
32
|
+
*/
|
|
33
|
+
export async function handleMailboxAdmin(runtime, req, mailboxAid, serviceHab) {
|
|
34
|
+
let mailboxRequest;
|
|
35
|
+
try {
|
|
36
|
+
mailboxRequest = await readMailboxAdminRequest(req);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (req.signal.aborted)
|
|
40
|
+
return textResponse("Request cancelled", 408);
|
|
41
|
+
if (error instanceof ValidationError) {
|
|
42
|
+
return textResponse(error.message, 400);
|
|
43
|
+
}
|
|
44
|
+
return textResponse(String(error), 400);
|
|
45
|
+
}
|
|
46
|
+
if (!mailboxRequest) {
|
|
47
|
+
await req.body?.cancel().catch(() => { });
|
|
48
|
+
return textResponse("Unacceptable content type.", 406);
|
|
49
|
+
}
|
|
50
|
+
const { bytes, inspection } = mailboxRequest;
|
|
51
|
+
const validation = validateMailboxAuthorizationReply(inspection, mailboxAid);
|
|
52
|
+
if (validation instanceof Response) {
|
|
53
|
+
return validation;
|
|
54
|
+
}
|
|
55
|
+
const { cid, role, expected } = validation;
|
|
56
|
+
const task = run(() => settleMailboxAdminIngress(runtime, bytes, mailboxAid, req.signal));
|
|
57
|
+
/** Request disconnect cancels its owned settlement, including any pending escrow storage. */
|
|
58
|
+
const stop = () => {
|
|
59
|
+
void task.halt();
|
|
60
|
+
};
|
|
61
|
+
req.signal.addEventListener("abort", stop, { once: true });
|
|
62
|
+
if (req.signal.aborted)
|
|
63
|
+
stop();
|
|
64
|
+
try {
|
|
65
|
+
await task;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (req.signal.aborted)
|
|
69
|
+
return textResponse("Request cancelled", 408);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
req.signal.removeEventListener("abort", stop);
|
|
74
|
+
await task.halt();
|
|
75
|
+
}
|
|
76
|
+
if (req.signal.aborted)
|
|
77
|
+
return textResponse("Request cancelled", 408);
|
|
78
|
+
const acceptance = confirmMailboxAuthorization(runtime, cid, mailboxAid, expected);
|
|
79
|
+
if (acceptance instanceof Response) {
|
|
80
|
+
return acceptance;
|
|
81
|
+
}
|
|
82
|
+
return new Response(JSON.stringify({
|
|
83
|
+
cid,
|
|
84
|
+
role,
|
|
85
|
+
eid: mailboxAid,
|
|
86
|
+
allowed: expected,
|
|
87
|
+
}), {
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Validate the terminal mailbox authorization reply before local acceptance checks. */
|
|
93
|
+
export function validateMailboxAuthorizationReply(inspection, mailboxAid) {
|
|
94
|
+
const serder = inspection.terminal;
|
|
95
|
+
if (!serder) {
|
|
96
|
+
return textResponse("Mailbox authorization stream is required", 400);
|
|
97
|
+
}
|
|
98
|
+
if (serder.ilk !== Ilks.rpy) {
|
|
99
|
+
return textResponse("Mailbox authorization stream must end in rpy", 400);
|
|
100
|
+
}
|
|
101
|
+
const route = serder.route ?? "";
|
|
102
|
+
if (route !== "/end/role/add" && route !== "/end/role/cut") {
|
|
103
|
+
return textResponse("Unsupported mailbox authorization route", 400);
|
|
104
|
+
}
|
|
105
|
+
const data = serder.ked?.a;
|
|
106
|
+
const cid = typeof data?.cid === "string" ? data.cid : null;
|
|
107
|
+
const role = typeof data?.role === "string" ? data.role : null;
|
|
108
|
+
const eid = typeof data?.eid === "string" ? data.eid : null;
|
|
109
|
+
if (!cid || !role || !eid) {
|
|
110
|
+
return textResponse("Mailbox authorization reply is missing cid/role/eid", 400);
|
|
111
|
+
}
|
|
112
|
+
if (role !== Roles.mailbox) {
|
|
113
|
+
return textResponse("Mailbox authorization reply must use role=mailbox", 400);
|
|
114
|
+
}
|
|
115
|
+
if (eid !== mailboxAid) {
|
|
116
|
+
return textResponse("Mailbox authorization target does not match hosted mailbox", 403);
|
|
117
|
+
}
|
|
118
|
+
return { cid, role, expected: route === "/end/role/add" };
|
|
119
|
+
}
|
|
120
|
+
/** Confirm that mailbox authorization state was accepted locally. */
|
|
121
|
+
export function confirmMailboxAuthorization(runtime, cid, mailboxAid, expected) {
|
|
122
|
+
const end = runtime.hby.db.ends.get([cid, Roles.mailbox, mailboxAid]);
|
|
123
|
+
const accepted = expected ? !!end?.allowed : !!end && !end.allowed;
|
|
124
|
+
if (!accepted) {
|
|
125
|
+
return textResponse("Mailbox authorization reply was not accepted", 403);
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/** Settle mailbox-admin CESR ingress without responder-side cue forwarding. */
|
|
130
|
+
export function* settleMailboxAdminIngress(runtime, bytes, mailboxAid, signal) {
|
|
131
|
+
yield* runtime.reactor.process((owned) => chunkBytes(bytes, { signal: owned }), {
|
|
132
|
+
mailboxAid,
|
|
133
|
+
signal,
|
|
134
|
+
escrows: true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { endpointBasePath, fetchEndpointUrls, preferredUrl, Roles } from "keri-ts/runtime";
|
|
2
|
+
import { textResponse } from "../responses.js";
|
|
3
|
+
/** Parse one OOBI-style request path into its route semantics. */
|
|
4
|
+
export function parseOobiRouteRequest(pathname) {
|
|
5
|
+
const parts = pathname.split("/").filter((part) => part.length > 0);
|
|
6
|
+
if (parts.length >= 4
|
|
7
|
+
&& parts[0] === ".well-known"
|
|
8
|
+
&& parts[1] === "keri"
|
|
9
|
+
&& parts[2] === "oobi") {
|
|
10
|
+
return {
|
|
11
|
+
kind: "wellKnown",
|
|
12
|
+
aid: parts[3] ?? null,
|
|
13
|
+
role: Roles.controller,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (parts[0] === "oobi") {
|
|
17
|
+
const value = parts[1] ?? null;
|
|
18
|
+
if (parts.length === 2) {
|
|
19
|
+
return {
|
|
20
|
+
kind: "oobi",
|
|
21
|
+
aid: value,
|
|
22
|
+
said: value ?? undefined,
|
|
23
|
+
role: undefined,
|
|
24
|
+
eid: undefined,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
kind: "oobi",
|
|
29
|
+
aid: value,
|
|
30
|
+
role: parts[2],
|
|
31
|
+
eid: parts[3],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** Classify OOBI requests after mailbox-admin and witness-specific routes. */
|
|
37
|
+
export function classifyOobiRoute(context) {
|
|
38
|
+
if (!context.runtime || !context.oobi) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (context.hosted?.kind === "ambiguous" && context.policy.hostedPrefixes) {
|
|
42
|
+
return {
|
|
43
|
+
kind: "ambiguousHostedPath",
|
|
44
|
+
message: "Ambiguous hosted endpoint path",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { kind: "oobi", request: context.oobi };
|
|
48
|
+
}
|
|
49
|
+
/** Serve one OOBI request from local accepted habitat state. */
|
|
50
|
+
export function handleOobiRoute(context, request) {
|
|
51
|
+
const runtime = context.runtime;
|
|
52
|
+
if (request.said) {
|
|
53
|
+
const schemer = runtime.hby.db.schema.get(request.said);
|
|
54
|
+
if (schemer) {
|
|
55
|
+
return new Response(new Blob([schemer.raw.slice().buffer]), {
|
|
56
|
+
status: 200,
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/schema+json",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const aid = request.aid ?? defaultOobiAid(runtime, context.policy.serviceHab, context.policy.hostedPrefixes);
|
|
64
|
+
if (!aid) {
|
|
65
|
+
return textResponse("no blind oobi for this node", 404);
|
|
66
|
+
}
|
|
67
|
+
const hosted = context.hosted ?? {
|
|
68
|
+
kind: "none",
|
|
69
|
+
endpoint: null,
|
|
70
|
+
relativePath: null,
|
|
71
|
+
};
|
|
72
|
+
const respondingHabAid = selectResponderHab(runtime, hosted, aid, request.eid, context.policy.hostedPrefixes);
|
|
73
|
+
const hab = respondingHabAid ? runtime.hby.habs.get(respondingHabAid) : undefined;
|
|
74
|
+
if (!hab) {
|
|
75
|
+
if (hosted.kind === "ambiguous" && context.policy.hostedPrefixes) {
|
|
76
|
+
return textResponse("Ambiguous hosted endpoint path", 409);
|
|
77
|
+
}
|
|
78
|
+
return textResponse("Not Found", 404);
|
|
79
|
+
}
|
|
80
|
+
const body = runtime.replyStreams.openNonEmpty(hab.replyToOobiMessages(aid, request.role, request.eid ? [request.eid] : []));
|
|
81
|
+
if (!body)
|
|
82
|
+
return textResponse("Not Found", 404);
|
|
83
|
+
return new Response(body, {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: {
|
|
86
|
+
// Both pinned v1 Oobiery implementations require this label; main also
|
|
87
|
+
// accepts it. OOBI media is distinct from attachment-header POST media.
|
|
88
|
+
"Content-Type": "application/json+cesr",
|
|
89
|
+
"KERI-AID": aid,
|
|
90
|
+
"Oobi-Aid": aid,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pick the default local AID whose Hab should answer a blind OOBI request when
|
|
96
|
+
* the request omits an AID.
|
|
97
|
+
*/
|
|
98
|
+
export function defaultOobiAid(runtime, serviceHab, hostedPrefixes) {
|
|
99
|
+
if (serviceHab?.pre) {
|
|
100
|
+
return serviceHab.pre;
|
|
101
|
+
}
|
|
102
|
+
if (hostedPrefixes?.length === 1) {
|
|
103
|
+
const candidate = hostedPrefixes[0];
|
|
104
|
+
if (candidate && runtime.hby.habs.has(candidate)) {
|
|
105
|
+
return candidate;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (runtime.hby.habs.size === 1) {
|
|
109
|
+
return runtime.hby.habs.keys().next().value;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Choose which local Hab should answer an OOBI request and return that Hab's
|
|
115
|
+
* AID.
|
|
116
|
+
*
|
|
117
|
+
* Preference order:
|
|
118
|
+
* - the requested AID itself when locally controlled
|
|
119
|
+
* - the explicit endpoint AID when it is locally controlled
|
|
120
|
+
* - the hosted endpoint matched from the request path
|
|
121
|
+
*/
|
|
122
|
+
export function selectResponderHab(runtime, hosted, aid, eid, hostedPrefixes) {
|
|
123
|
+
const hostedSet = hostedPrefixes ? new Set(hostedPrefixes) : null;
|
|
124
|
+
const hostedCandidate = (candidate) => {
|
|
125
|
+
if (!candidate || !runtime.hby.habs.has(candidate)) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (hostedSet && !hostedSet.has(candidate)) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return candidate;
|
|
132
|
+
};
|
|
133
|
+
const rootHostedCandidate = (candidate) => {
|
|
134
|
+
if (!candidate || !runtime.hby.habs.has(candidate)) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
if (hostedSet && !hostedSet.has(candidate)) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const preferred = preferredUrl(fetchEndpointUrls(runtime.hby, candidate));
|
|
141
|
+
if (!preferred || endpointBasePath(preferred) !== "/") {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
return candidate;
|
|
145
|
+
};
|
|
146
|
+
if (hostedSet) {
|
|
147
|
+
if (hosted.kind === "ambiguous") {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (hosted.kind === "one") {
|
|
151
|
+
const hostedAid = hosted.endpoint?.eid;
|
|
152
|
+
if (hostedAid
|
|
153
|
+
&& runtime.hby.habs.has(hostedAid)
|
|
154
|
+
&& (aid === hostedAid || eid === hostedAid)) {
|
|
155
|
+
return hostedAid;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return hostedCandidate(aid)
|
|
160
|
+
?? hostedCandidate(eid)
|
|
161
|
+
?? rootHostedCandidate(aid)
|
|
162
|
+
?? rootHostedCandidate(eid);
|
|
163
|
+
}
|
|
164
|
+
if (runtime.hby.habs.has(aid)) {
|
|
165
|
+
return aid;
|
|
166
|
+
}
|
|
167
|
+
if (eid && runtime.hby.habs.has(eid)) {
|
|
168
|
+
return eid;
|
|
169
|
+
}
|
|
170
|
+
return hosted.endpoint?.eid ?? undefined;
|
|
171
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { run } from "effection";
|
|
2
|
+
import { CESR_DESTINATION_HEADER, readRequiredCesrRequestBytes, witnessKeyStateGet, witnessQueryGet, witnessReceiptGet, witnessReceiptPost, } from "keri-ts/runtime";
|
|
3
|
+
import { cesrResponse, textResponse } from "../responses.js";
|
|
4
|
+
/** Classify witness-only `/receipts` and `/query` routes ahead of generic ingress. */
|
|
5
|
+
export function classifyWitnessHttpRoute(context) {
|
|
6
|
+
if (!context.runtime || !context.policy.witnessHab) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
if (context.hosted?.kind === "ambiguous") {
|
|
10
|
+
return {
|
|
11
|
+
kind: "ambiguousHostedPath",
|
|
12
|
+
message: "Ambiguous hosted endpoint path",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const relativePath = context.hosted?.relativePath ?? context.pathname;
|
|
16
|
+
if ((context.method === "POST" || context.method === "PUT")
|
|
17
|
+
&& relativePath === "/receipts") {
|
|
18
|
+
return {
|
|
19
|
+
kind: "witnessReceiptsPost",
|
|
20
|
+
witnessHab: context.policy.witnessHab,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (context.method === "GET" && relativePath === "/receipts") {
|
|
24
|
+
return {
|
|
25
|
+
kind: "witnessReceiptsGet",
|
|
26
|
+
witnessHab: context.policy.witnessHab,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (context.method === "GET" && relativePath === "/query") {
|
|
30
|
+
return { kind: "witnessQueryGet", witnessHab: context.policy.witnessHab };
|
|
31
|
+
}
|
|
32
|
+
if (context.method === "GET" && relativePath === "/ksn") {
|
|
33
|
+
return { kind: "witnessKeyStateGet", witnessHab: context.policy.witnessHab };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** Handle one witness `/receipts` POST request; `options.local` follows the core receipt policy. */
|
|
38
|
+
export async function handleWitnessReceiptPost(runtime, req, witnessHab, options = {}) {
|
|
39
|
+
const bytes = await readRequiredCesrRequestBytes(req);
|
|
40
|
+
if (!bytes) {
|
|
41
|
+
return textResponse("Unacceptable content type.", 406);
|
|
42
|
+
}
|
|
43
|
+
const result = await run(function* () {
|
|
44
|
+
return yield* witnessReceiptPost(runtime, witnessHab, bytes, options);
|
|
45
|
+
});
|
|
46
|
+
if (result.kind === "accepted") {
|
|
47
|
+
return cesrResponse(result.body, result.status);
|
|
48
|
+
}
|
|
49
|
+
if (result.kind === "escrow") {
|
|
50
|
+
return new Response(null, {
|
|
51
|
+
status: result.status,
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return textResponse(result.message, result.status);
|
|
56
|
+
}
|
|
57
|
+
/** Handle one witness `/receipts` GET request. */
|
|
58
|
+
export function handleWitnessReceiptGet(context, witnessHab) {
|
|
59
|
+
const snText = context.url.searchParams.get("sn");
|
|
60
|
+
const sn = snText === null ? null : Number.parseInt(snText, 10);
|
|
61
|
+
const result = witnessReceiptGet(witnessHab, {
|
|
62
|
+
pre: context.url.searchParams.get("pre"),
|
|
63
|
+
sn: Number.isNaN(sn ?? Number.NaN) ? null : sn,
|
|
64
|
+
said: context.url.searchParams.get("said"),
|
|
65
|
+
});
|
|
66
|
+
if (result.kind === "accepted") {
|
|
67
|
+
return cesrResponse(result.body, result.status);
|
|
68
|
+
}
|
|
69
|
+
return textResponse(result.message, result.status);
|
|
70
|
+
}
|
|
71
|
+
/** Handle one witness `/query` GET request. */
|
|
72
|
+
export function handleWitnessQueryGet(context, witnessHab) {
|
|
73
|
+
const snText = context.url.searchParams.get("sn");
|
|
74
|
+
const sn = snText === null ? null : Number.parseInt(snText, 10);
|
|
75
|
+
const runtime = context.runtime;
|
|
76
|
+
const result = witnessQueryGet(witnessHab, {
|
|
77
|
+
typ: context.url.searchParams.get("typ"),
|
|
78
|
+
pre: context.url.searchParams.get("pre"),
|
|
79
|
+
sn: Number.isNaN(sn ?? Number.NaN) ? null : sn,
|
|
80
|
+
});
|
|
81
|
+
if (result.kind === "accepted") {
|
|
82
|
+
return new Response(runtime.replyStreams.open(result.messages), {
|
|
83
|
+
status: result.status,
|
|
84
|
+
headers: { "Content-Type": "application/cesr" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return textResponse(result.message, result.status);
|
|
88
|
+
}
|
|
89
|
+
/** Serve the signed witness state-notice contract with explicit destination selection. */
|
|
90
|
+
export function handleWitnessKeyStateGet(context, witnessHab) {
|
|
91
|
+
const result = witnessKeyStateGet(witnessHab, {
|
|
92
|
+
pre: context.url.searchParams.get("pre"),
|
|
93
|
+
destination: context.req.headers.get(CESR_DESTINATION_HEADER),
|
|
94
|
+
});
|
|
95
|
+
return result.kind === "accepted"
|
|
96
|
+
? cesrResponse(result.body, result.status)
|
|
97
|
+
: textResponse(result.message, result.status);
|
|
98
|
+
}
|