@loopbacktools/server 0.0.0-stage → 0.4.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 +21 -0
- package/README.md +26 -2
- package/dist/package.json +57 -0
- package/dist/src/domain/ask.d.ts +78 -0
- package/dist/src/domain/ask.js +161 -0
- package/dist/src/domain/errors.d.ts +70 -0
- package/dist/src/domain/errors.js +57 -0
- package/dist/src/generated/app.gen.d.ts +2311 -0
- package/dist/src/generated/app.gen.js +400 -0
- package/dist/src/generated/runtime.gen.d.ts +357 -0
- package/dist/src/generated/runtime.gen.js +298 -0
- package/dist/src/generated/schemas.gen.d.ts +6416 -0
- package/dist/src/generated/schemas.gen.js +306 -0
- package/dist/src/generated/wire-contract.gen.d.ts +77 -0
- package/dist/src/generated/wire-contract.gen.js +3 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +8 -0
- package/dist/src/loopback.d.ts +27 -0
- package/dist/src/loopback.js +16 -0
- package/dist/src/space/arguments.d.ts +8 -0
- package/dist/src/space/arguments.js +52 -0
- package/dist/src/space/migrations.d.ts +10 -0
- package/dist/src/space/migrations.js +106 -0
- package/dist/src/space/object.d.ts +30 -0
- package/dist/src/space/object.js +169 -0
- package/dist/src/space/operations.d.ts +149 -0
- package/dist/src/space/operations.js +568 -0
- package/dist/src/space/read.d.ts +8 -0
- package/dist/src/space/read.js +33 -0
- package/dist/src/space/rpc.d.ts +61 -0
- package/dist/src/space/rpc.js +96 -0
- package/dist/src/space/services.d.ts +41 -0
- package/dist/src/space/services.js +58 -0
- package/dist/src/space/store.d.ts +87 -0
- package/dist/src/space/store.js +276 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +3 -0
- package/dist/src/worker/env.d.ts +19 -0
- package/dist/src/worker/env.js +3 -0
- package/dist/src/worker/files.d.ts +21 -0
- package/dist/src/worker/files.js +42 -0
- package/dist/src/worker/gate.d.ts +55 -0
- package/dist/src/worker/gate.js +83 -0
- package/dist/src/worker/http.d.ts +2092 -0
- package/dist/src/worker/http.js +291 -0
- package/dist/src/worker/jwt.d.ts +49 -0
- package/dist/src/worker/jwt.js +142 -0
- package/dist/src/worker/mcp.d.ts +15 -0
- package/dist/src/worker/mcp.js +401 -0
- package/dist/src/worker/run.d.ts +45 -0
- package/dist/src/worker/run.js +71 -0
- package/dist/src/worker/spaces.d.ts +24 -0
- package/dist/src/worker/spaces.js +64 -0
- package/package.json +51 -3
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { BUILD_HEADER, RELOAD_CODE, REVISION_HEADER } from "@loopbacktools/protocol";
|
|
2
|
+
import { REVISION } from "@loopbacktools/protocol/schema";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { RegExpRouter } from "hono/router/reg-exp-router";
|
|
6
|
+
import { registerRoutes } from "../generated/app.gen.js";
|
|
7
|
+
import { openApiDocument } from "@loopbacktools/protocol/document";
|
|
8
|
+
import { fileKey, Files } from "./files.js";
|
|
9
|
+
import { Gate, vouchedAccess } from "./gate.js";
|
|
10
|
+
import { answerUnhandled, problem, run, } from "./run.js";
|
|
11
|
+
import { UPLOAD_MAX_BYTES } from "../space/operations.js";
|
|
12
|
+
import { buildOf } from "../space/rpc.js";
|
|
13
|
+
import { VERSION } from "../version.js";
|
|
14
|
+
import { makeMcp } from "./mcp.js";
|
|
15
|
+
import { Spaces } from "./spaces.js";
|
|
16
|
+
/** The contract, naming the release that serves it, so a deployment can be checked from outside. */
|
|
17
|
+
const document = { ...openApiDocument, info: { ...openApiDocument.info, version: VERSION } };
|
|
18
|
+
/** How many closed asks a history page holds when the caller does not say. */
|
|
19
|
+
const DEFAULT_PAGE = 20;
|
|
20
|
+
/**
|
|
21
|
+
* A refused request's 401. When the gate takes credentials from an authorization server, the
|
|
22
|
+
* challenge says where its protected resource metadata is, so an MCP client can go and sign in.
|
|
23
|
+
*/
|
|
24
|
+
const unauthorized = (message, resourceMetadata) => Response.json(problem("auth.unauthorized", message), {
|
|
25
|
+
status: 401,
|
|
26
|
+
headers: {
|
|
27
|
+
"WWW-Authenticate": resourceMetadata === undefined
|
|
28
|
+
? 'Bearer realm="loopback"'
|
|
29
|
+
: `Bearer realm="loopback", resource_metadata="${resourceMetadata}"`,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
/** Where the MCP endpoint's protected resource metadata is (RFC 9728): the well-known path, then its path. */
|
|
33
|
+
const RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
34
|
+
/** The prefix a browser puts its event ticket behind in `Sec-WebSocket-Protocol`. */
|
|
35
|
+
const TICKET_PROTOCOL_PREFIX = "loopback.ticket.";
|
|
36
|
+
/** The event ticket a socket upgrade offers, split into the space it names and its secret. */
|
|
37
|
+
const ticketOf = (request) => {
|
|
38
|
+
const ticket = (request.headers.get("Sec-WebSocket-Protocol") ?? "")
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((protocol) => protocol.trim())
|
|
41
|
+
.find((protocol) => protocol.startsWith(TICKET_PROTOCOL_PREFIX))
|
|
42
|
+
?.slice(TICKET_PROTOCOL_PREFIX.length);
|
|
43
|
+
if (ticket === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
const dot = ticket.indexOf(".");
|
|
46
|
+
return { spaceId: ticket.slice(0, dot), secret: ticket.slice(dot + 1) };
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Who a request is from. The Worker's invoker vouches first, when it does; otherwise the
|
|
50
|
+
* deployment's gate decides from the request itself. A refusal is the 401 to answer with.
|
|
51
|
+
*/
|
|
52
|
+
const authenticate = (runtime, request, props, requestId) => run(runtime, request.signal, requestId, Effect.gen(function* () {
|
|
53
|
+
const vouched = yield* vouchedAccess(props);
|
|
54
|
+
if (vouched !== undefined)
|
|
55
|
+
return vouched;
|
|
56
|
+
const gate = yield* Gate;
|
|
57
|
+
return yield* gate
|
|
58
|
+
.authenticate(request)
|
|
59
|
+
.pipe(Effect.catchTag("Unauthorized", (refused) => Effect.flatMap(gate.authorizationServers, (servers) => Effect.fail(unauthorized(refused.reason, servers.length === 0
|
|
60
|
+
? undefined
|
|
61
|
+
: `${new URL(request.url).origin}${RESOURCE_METADATA_PATH}/mcp`)))));
|
|
62
|
+
}).pipe(Effect.result));
|
|
63
|
+
/** The props the Worker's invoker passed, if any. */
|
|
64
|
+
const propsOf = (c) => c.executionCtx.props;
|
|
65
|
+
/** The access a handler's caller holds. Every route that reaches a handler requires one but uploads. */
|
|
66
|
+
const accessOf = (caller) => caller.access === null
|
|
67
|
+
? Effect.die(new Error("A gated operation was reached without access."))
|
|
68
|
+
: Effect.succeed(caller.access);
|
|
69
|
+
const spaceOf = (caller) => Effect.gen(function* () {
|
|
70
|
+
const spaces = yield* Spaces;
|
|
71
|
+
return spaces.of(yield* accessOf(caller));
|
|
72
|
+
});
|
|
73
|
+
const tooLarge = (maxBytes) => ({
|
|
74
|
+
status: 413,
|
|
75
|
+
body: problem("upload.too_large", `Images may be up to ${maxBytes} bytes.`),
|
|
76
|
+
});
|
|
77
|
+
const notFound = (message) => Effect.succeed({ status: 404, body: problem("not_found", message) });
|
|
78
|
+
/** The handler for each operation the contract declares. */
|
|
79
|
+
const handlers = (runtime, request, requestId) => {
|
|
80
|
+
const serve = (effect) => run(runtime, request.signal, requestId, effect);
|
|
81
|
+
return {
|
|
82
|
+
EventTickets_issue: (caller) => serve(Effect.gen(function* () {
|
|
83
|
+
const access = yield* accessOf(caller);
|
|
84
|
+
const spaces = yield* Spaces;
|
|
85
|
+
const issued = yield* spaces.of(access).issueTicket(access.actor.id);
|
|
86
|
+
return {
|
|
87
|
+
status: 201,
|
|
88
|
+
body: { ticket: `${issued.spaceId}.${issued.secret}`, expiresAt: issued.expiresAt },
|
|
89
|
+
};
|
|
90
|
+
})),
|
|
91
|
+
Session_whoami: (caller) => serve(Effect.map(accessOf(caller), (access) => ({
|
|
92
|
+
status: 200,
|
|
93
|
+
body: { space: access.space, actor: access.actor },
|
|
94
|
+
}))),
|
|
95
|
+
Snapshots_read: (caller) => serve(Effect.gen(function* () {
|
|
96
|
+
const space = yield* spaceOf(caller);
|
|
97
|
+
return { status: 200, body: yield* space.snapshot() };
|
|
98
|
+
})),
|
|
99
|
+
Asks_list: (caller, input) => serve(Effect.gen(function* () {
|
|
100
|
+
const space = yield* spaceOf(caller);
|
|
101
|
+
return {
|
|
102
|
+
status: 200,
|
|
103
|
+
body: yield* space.history(input.before, input.limit ?? DEFAULT_PAGE),
|
|
104
|
+
};
|
|
105
|
+
})),
|
|
106
|
+
Asks_read: (caller, input) => serve(Effect.gen(function* () {
|
|
107
|
+
const space = yield* spaceOf(caller);
|
|
108
|
+
return { status: 200, body: yield* space.get(input.id) };
|
|
109
|
+
}).pipe(Effect.catchTag("AskNotFound", () => notFound(`No ask ${input.id}.`)))),
|
|
110
|
+
Asks_reply: (caller, input) => serve(Effect.gen(function* () {
|
|
111
|
+
const access = yield* accessOf(caller);
|
|
112
|
+
const space = yield* spaceOf(caller);
|
|
113
|
+
return {
|
|
114
|
+
status: 200,
|
|
115
|
+
body: yield* space.reply(input.id, input.reply, access.actor),
|
|
116
|
+
};
|
|
117
|
+
}).pipe(Effect.catchTags({
|
|
118
|
+
AskNotFound: () => notFound(`No ask ${input.id}.`),
|
|
119
|
+
AskClosed: () => Effect.succeed({
|
|
120
|
+
status: 409,
|
|
121
|
+
body: problem("ask.closed", "This ask has already been answered."),
|
|
122
|
+
}),
|
|
123
|
+
NotYourTurn: () => Effect.succeed({
|
|
124
|
+
status: 409,
|
|
125
|
+
body: problem("ask.agent_turn", "The agent owes an answer before you can reply."),
|
|
126
|
+
}),
|
|
127
|
+
ReplyInvalid: (error) => Effect.succeed({
|
|
128
|
+
status: 422,
|
|
129
|
+
body: problem("reply.invalid", error.reason),
|
|
130
|
+
}),
|
|
131
|
+
}))),
|
|
132
|
+
Files_read: (caller, input) => serve(Effect.gen(function* () {
|
|
133
|
+
const spaces = yield* Spaces;
|
|
134
|
+
const files = yield* Files;
|
|
135
|
+
const access = yield* accessOf(caller);
|
|
136
|
+
const upload = yield* spaces.of(access).findUpload(input.uploadId);
|
|
137
|
+
const body = yield* files.get(fileKey(spaces.idOf(access), input.uploadId));
|
|
138
|
+
if (Option.isNone(body))
|
|
139
|
+
return yield* notFound(`No file ${input.uploadId}.`);
|
|
140
|
+
return { status: 200, contentType: upload.contentType, body: body.value };
|
|
141
|
+
}).pipe(Effect.catchTag("UploadNotFound", () => notFound(`No file ${input.uploadId}.`)))),
|
|
142
|
+
Uploads_store: (_caller, input) => serve(Effect.gen(function* () {
|
|
143
|
+
const spaces = yield* Spaces;
|
|
144
|
+
const files = yield* Files;
|
|
145
|
+
const space = spaces.byId(input.spaceId);
|
|
146
|
+
if (Option.isNone(space))
|
|
147
|
+
return yield* notFound("No such upload.");
|
|
148
|
+
const length = Number(request.headers.get("Content-Length") ?? Number.NaN);
|
|
149
|
+
if (!Number.isSafeInteger(length) || length <= 0 || request.body === null) {
|
|
150
|
+
return {
|
|
151
|
+
status: 422,
|
|
152
|
+
body: problem("upload.length_required", "Send the image with a Content-Length."),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Refused before the ticket is claimed, so it stays good for an image that fits.
|
|
156
|
+
if (length > UPLOAD_MAX_BYTES)
|
|
157
|
+
return tooLarge(UPLOAD_MAX_BYTES);
|
|
158
|
+
const claimed = yield* space.value.claimUpload(input.token, input["Content-Type"]);
|
|
159
|
+
if (length > claimed.maxBytes)
|
|
160
|
+
return tooLarge(claimed.maxBytes);
|
|
161
|
+
yield* files.put(fileKey(input.spaceId, claimed.uploadId), request.body, length, claimed.contentType);
|
|
162
|
+
return {
|
|
163
|
+
status: 201,
|
|
164
|
+
body: yield* space.value.completeUpload(claimed.uploadId, length),
|
|
165
|
+
};
|
|
166
|
+
}).pipe(Effect.catchTags({
|
|
167
|
+
UploadNotFound: () => notFound("No such upload."),
|
|
168
|
+
UploadExpired: () => Effect.succeed({
|
|
169
|
+
status: 410,
|
|
170
|
+
body: problem("upload.expired", "This upload URL has expired or was already used."),
|
|
171
|
+
}),
|
|
172
|
+
UploadRejected: (error) => Effect.succeed({
|
|
173
|
+
status: 422,
|
|
174
|
+
body: problem("upload.rejected", error.reason),
|
|
175
|
+
}),
|
|
176
|
+
UploadIncomplete: () => Effect.succeed({
|
|
177
|
+
status: 422,
|
|
178
|
+
body: problem("upload.incomplete", "The image ended before, or ran past, its Content-Length."),
|
|
179
|
+
}),
|
|
180
|
+
}))),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const deps = (runtimeOf) => ({
|
|
184
|
+
authorize: (requirements) => async (c, next) => {
|
|
185
|
+
// Every gated operation asks for a bearer token and nothing else; the gate decides what one is.
|
|
186
|
+
if (!requirements.some((requirement) => "BearerAuth" in requirement)) {
|
|
187
|
+
return unauthorized("This operation accepts no credentials Loopback can check.");
|
|
188
|
+
}
|
|
189
|
+
const access = await authenticate(runtimeOf(c.env), c.req.raw, propsOf(c), c.get("requestId"));
|
|
190
|
+
if (access._tag === "Failure")
|
|
191
|
+
return access.failure;
|
|
192
|
+
c.set("access", access.success);
|
|
193
|
+
await next();
|
|
194
|
+
return undefined;
|
|
195
|
+
},
|
|
196
|
+
context: (c, authentication) => {
|
|
197
|
+
const access = authentication === "none" ? null : (c.get("access") ?? null);
|
|
198
|
+
if (authentication === "required" && access === null)
|
|
199
|
+
return null;
|
|
200
|
+
return { access, requestId: c.get("requestId"), signal: c.req.raw.signal };
|
|
201
|
+
},
|
|
202
|
+
noContext: () => unauthorized("A bearer token is required."),
|
|
203
|
+
notAcceptable: (c, offered) => c.json(problem("request.not_acceptable", `This responds with ${offered.join(", ")}.`), 406),
|
|
204
|
+
invalid: (result, c) => result.success
|
|
205
|
+
? undefined
|
|
206
|
+
: c.json(problem("request.invalid", "The request does not match the API contract."), 400),
|
|
207
|
+
});
|
|
208
|
+
/**
|
|
209
|
+
* The Worker's surface: the contract's routes, the event socket, the document that describes them,
|
|
210
|
+
* and the MCP endpoint agents connect to.
|
|
211
|
+
*/
|
|
212
|
+
export const makeApp = (runtimeOf, inboxUrl) => {
|
|
213
|
+
const app = new Hono({ router: new RegExpRouter() });
|
|
214
|
+
// Middleware before `registerRoutes`: Hono applies it only to routes registered after it.
|
|
215
|
+
app.use(async (c, next) => {
|
|
216
|
+
c.set("requestId", c.req.header("cf-ray") ?? crypto.randomUUID());
|
|
217
|
+
await next();
|
|
218
|
+
});
|
|
219
|
+
// Nothing the Worker answers is to be sniffed as another type. Everything names the code that
|
|
220
|
+
// answered, so a client can tell when Loopback has been updated under it. A WebSocket's 101 is the
|
|
221
|
+
// space's own response, and is left as it is.
|
|
222
|
+
app.use(async (c, next) => {
|
|
223
|
+
await next();
|
|
224
|
+
if (c.res.status === 101)
|
|
225
|
+
return;
|
|
226
|
+
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
|
227
|
+
c.res.headers.set("Referrer-Policy", "no-referrer");
|
|
228
|
+
c.res.headers.set(REVISION_HEADER, String(REVISION));
|
|
229
|
+
const build = buildOf(c.env);
|
|
230
|
+
if (build !== undefined)
|
|
231
|
+
c.res.headers.set(BUILD_HEADER, build);
|
|
232
|
+
});
|
|
233
|
+
// A client writing a later revision of the contract than this code reads is from newer code, as a
|
|
234
|
+
// page loaded before a rollback is: it is refused before anything runs, and reloads onto this code.
|
|
235
|
+
app.use(async (c, next) => {
|
|
236
|
+
if (Number(c.req.header(REVISION_HEADER)) > REVISION) {
|
|
237
|
+
return c.json(problem(RELOAD_CODE, "This is from a newer version of Loopback than the server. Reload it."), 409);
|
|
238
|
+
}
|
|
239
|
+
await next();
|
|
240
|
+
return undefined;
|
|
241
|
+
});
|
|
242
|
+
app.onError((error, c) => answerUnhandled(error, c.get("requestId")));
|
|
243
|
+
app.notFound((c) => c.json(problem("not_found", "No such route."), 404));
|
|
244
|
+
app.get("/api/v1/openapi.json", (c) => c.json(document));
|
|
245
|
+
// Protected resource metadata for the MCP endpoint, when the gate takes credentials from an
|
|
246
|
+
// authorization server: clients find it at the well-known path or under it, by the resource's path.
|
|
247
|
+
const protectedResource = async (c) => {
|
|
248
|
+
const servers = await run(runtimeOf(c.env), c.req.raw.signal, c.get("requestId"), Gate.use((gate) => gate.authorizationServers));
|
|
249
|
+
if (servers.length === 0)
|
|
250
|
+
return c.json(problem("not_found", "No such route."), 404);
|
|
251
|
+
return c.json({
|
|
252
|
+
resource: `${new URL(c.req.url).origin}/mcp`,
|
|
253
|
+
authorization_servers: servers,
|
|
254
|
+
bearer_methods_supported: ["header"],
|
|
255
|
+
resource_name: "Loopback",
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
app.get(RESOURCE_METADATA_PATH, protectedResource);
|
|
259
|
+
app.get(`${RESOURCE_METADATA_PATH}/mcp`, protectedResource);
|
|
260
|
+
app.get("/api/v1/events", async (c) => {
|
|
261
|
+
if (c.req.header("Upgrade")?.toLowerCase() !== "websocket") {
|
|
262
|
+
return c.json(problem("events.upgrade_required", "Connect with a WebSocket."), 426);
|
|
263
|
+
}
|
|
264
|
+
const runtime = runtimeOf(c.env);
|
|
265
|
+
const ticket = ticketOf(c.req.raw);
|
|
266
|
+
if (ticket !== undefined) {
|
|
267
|
+
// A browser's one-time pass, spent against the space it names.
|
|
268
|
+
return run(runtime, c.req.raw.signal, c.get("requestId"), Effect.gen(function* () {
|
|
269
|
+
const spaces = yield* Spaces;
|
|
270
|
+
const space = spaces.byId(ticket.spaceId);
|
|
271
|
+
if (Option.isNone(space))
|
|
272
|
+
return unauthorized("The event ticket is not valid.");
|
|
273
|
+
yield* space.value.redeemTicket(ticket.secret);
|
|
274
|
+
return yield* spaces.connect(ticket.spaceId, c.req.raw);
|
|
275
|
+
}).pipe(Effect.catchTag("TicketInvalid", () => Effect.succeed(unauthorized("The event ticket is not valid, or was already used.")))));
|
|
276
|
+
}
|
|
277
|
+
const access = await authenticate(runtime, c.req.raw, propsOf(c), c.get("requestId"));
|
|
278
|
+
if (access._tag === "Failure")
|
|
279
|
+
return access.failure;
|
|
280
|
+
return run(runtime, c.req.raw.signal, c.get("requestId"), Spaces.use((spaces) => spaces.connect(access.success, c.req.raw)));
|
|
281
|
+
});
|
|
282
|
+
const mcp = makeMcp(inboxUrl);
|
|
283
|
+
app.all("/mcp", async (c) => {
|
|
284
|
+
const runtime = runtimeOf(c.env);
|
|
285
|
+
const access = await authenticate(runtime, c.req.raw, propsOf(c), c.get("requestId"));
|
|
286
|
+
if (access._tag === "Failure")
|
|
287
|
+
return access.failure;
|
|
288
|
+
return mcp(c.req.raw, runtime, access.success, c.get("requestId"));
|
|
289
|
+
});
|
|
290
|
+
return registerRoutes(app, (c) => handlers(runtimeOf(c.env), c.req.raw, c.get("requestId")), deps(runtimeOf));
|
|
291
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { type JWTPayload } from "jose";
|
|
3
|
+
import type { LoopbackBindings, WorkerBindings } from "./env.ts";
|
|
4
|
+
import { WorkerEnv } from "./env.ts";
|
|
5
|
+
import { type Access, Gate } from "./gate.ts";
|
|
6
|
+
/** How a JWT gate checks a token, and how it reads the caller from the token's claims. */
|
|
7
|
+
export interface JwtGateOptions {
|
|
8
|
+
/** Where the login publishes the keys it signs tokens with, as a JSON Web Key Set. */
|
|
9
|
+
readonly jwksUrl: string;
|
|
10
|
+
/** Fetches the key set in place of the global `fetch`: a service binding to the login, say. */
|
|
11
|
+
readonly fetcher?: {
|
|
12
|
+
readonly fetch: (url: string, init: RequestInit) => Promise<Response>;
|
|
13
|
+
};
|
|
14
|
+
/** The `iss` every token must carry; MCP clients are sent here to sign in. */
|
|
15
|
+
readonly issuer: string;
|
|
16
|
+
/** The audiences a token may be for; it must carry at least one of them in `aud`. */
|
|
17
|
+
readonly audience: ReadonlyArray<string>;
|
|
18
|
+
/** The claim that names the space a token opens. A token without it opens nothing. */
|
|
19
|
+
readonly spaceClaim: string;
|
|
20
|
+
/** The claim that holds the caller's display name. `name` unless said otherwise. */
|
|
21
|
+
readonly nameClaim?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Asked once a token has verified, with its claims and the caller they name: whether that caller
|
|
24
|
+
* may still come in. A login whose tokens outlive a membership or a grant answers here, returning
|
|
25
|
+
* the reason to refuse, which the caller is told, or nothing to let them in. A check that throws
|
|
26
|
+
* fails the request as a defect, like a key set out of reach.
|
|
27
|
+
*/
|
|
28
|
+
readonly check?: (claims: JWTPayload, access: Access) => Promise<string | undefined>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A gate for a deployment that serves a space per tenant behind any login that issues JWTs: a token
|
|
32
|
+
* signed by one of the login's keys, from its issuer, for one of the audiences, opens the space its
|
|
33
|
+
* claim names, as the person its `sub` names. Keys are fetched from the key set and cached by the
|
|
34
|
+
* isolate. A key set out of reach is not the caller's fault, so it fails the request as a defect
|
|
35
|
+
* rather than refusing it.
|
|
36
|
+
*/
|
|
37
|
+
export declare const makeJwtGate: (options: JwtGateOptions) => Gate["Service"];
|
|
38
|
+
/**
|
|
39
|
+
* A JWT gate configured in code, from the Worker's environment: how a host composing Loopback uses
|
|
40
|
+
* it. `options` may take the host's own environment type, such as the one `wrangler types` writes.
|
|
41
|
+
*/
|
|
42
|
+
export declare const jwtGate: <Env extends LoopbackBindings = WorkerBindings>(options: (env: Env) => JwtGateOptions) => Layer.Layer<Gate, never, WorkerEnv>;
|
|
43
|
+
/**
|
|
44
|
+
* The gate a deployment's settings choose: the token gate when `LOOPBACK_TOKEN` is set, the JWT gate
|
|
45
|
+
* when `LOOPBACK_JWKS_URL` is. Exactly one of them: both, or neither, is a deployment that cannot say
|
|
46
|
+
* who anyone is, answered as a 500 that says so. Settings are read on first use, so a deployment whose
|
|
47
|
+
* invoker vouches for every caller needs neither.
|
|
48
|
+
*/
|
|
49
|
+
export declare const ConfiguredGate: Layer.Layer<Gate, never, never>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Config, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { createRemoteJWKSet, customFetch, errors, jwtVerify } from "jose";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { Unauthorized } from "../domain/errors.js";
|
|
5
|
+
import { WorkerEnv } from "./env.js";
|
|
6
|
+
import { credentialOf, Gate, makeTokenGate, NotConfigured } from "./gate.js";
|
|
7
|
+
/** The caller a token's claims name, read from the configured claims. */
|
|
8
|
+
const caller = z.object({
|
|
9
|
+
sub: z.string().min(1),
|
|
10
|
+
space: z.string().min(1),
|
|
11
|
+
});
|
|
12
|
+
/** The caller's display name, when the token carries a usable one; it never decides access. */
|
|
13
|
+
const nameOf = (claim) => typeof claim === "string" && claim.trim() !== "" ? claim : undefined;
|
|
14
|
+
/** The signature algorithms a token may use. `none` and the HMAC family are never among them. */
|
|
15
|
+
const ALGORITHMS = ["EdDSA", "ES256", "RS256"];
|
|
16
|
+
/** Failures that say the token is wrong, as opposed to the login's keys being out of reach. */
|
|
17
|
+
const refusals = [
|
|
18
|
+
errors.JWTClaimValidationFailed,
|
|
19
|
+
errors.JWTInvalid,
|
|
20
|
+
errors.JWSInvalid,
|
|
21
|
+
errors.JWSSignatureVerificationFailed,
|
|
22
|
+
errors.JOSEAlgNotAllowed,
|
|
23
|
+
errors.JOSENotSupported,
|
|
24
|
+
errors.JWKSNoMatchingKey,
|
|
25
|
+
errors.JWKSMultipleMatchingKeys,
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* A gate for a deployment that serves a space per tenant behind any login that issues JWTs: a token
|
|
29
|
+
* signed by one of the login's keys, from its issuer, for one of the audiences, opens the space its
|
|
30
|
+
* claim names, as the person its `sub` names. Keys are fetched from the key set and cached by the
|
|
31
|
+
* isolate. A key set out of reach is not the caller's fault, so it fails the request as a defect
|
|
32
|
+
* rather than refusing it.
|
|
33
|
+
*/
|
|
34
|
+
export const makeJwtGate = (options) => {
|
|
35
|
+
const keys = createRemoteJWKSet(new URL(options.jwksUrl), options.fetcher === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { [customFetch]: (url, init) => options.fetcher.fetch(url, init) });
|
|
38
|
+
const nameClaim = options.nameClaim ?? "name";
|
|
39
|
+
return Gate.of({
|
|
40
|
+
authenticate: Effect.fnUntraced(function* (request) {
|
|
41
|
+
const presented = credentialOf(request);
|
|
42
|
+
if (presented === undefined) {
|
|
43
|
+
return yield* new Unauthorized({ reason: "A bearer token is required." });
|
|
44
|
+
}
|
|
45
|
+
const verified = yield* Effect.tryPromise({
|
|
46
|
+
try: () => jwtVerify(presented, keys, {
|
|
47
|
+
issuer: options.issuer,
|
|
48
|
+
audience: [...options.audience],
|
|
49
|
+
algorithms: ALGORITHMS,
|
|
50
|
+
}),
|
|
51
|
+
catch: (error) => error,
|
|
52
|
+
}).pipe(Effect.catch((error) => {
|
|
53
|
+
if (error instanceof errors.JWTExpired) {
|
|
54
|
+
return Effect.fail(new Unauthorized({ reason: "The token has expired." }));
|
|
55
|
+
}
|
|
56
|
+
if (refusals.some((refusal) => error instanceof refusal)) {
|
|
57
|
+
return Effect.fail(new Unauthorized({ reason: "The token is not valid." }));
|
|
58
|
+
}
|
|
59
|
+
return Effect.die(error);
|
|
60
|
+
}));
|
|
61
|
+
const parsed = caller.safeParse({
|
|
62
|
+
sub: verified.payload.sub,
|
|
63
|
+
space: verified.payload[options.spaceClaim],
|
|
64
|
+
});
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
return yield* new Unauthorized({
|
|
67
|
+
reason: `The token must name a person in \`sub\` and a space in \`${options.spaceClaim}\`.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const { sub, space } = parsed.data;
|
|
71
|
+
const name = nameOf(verified.payload[nameClaim]);
|
|
72
|
+
const access = { space, actor: name === undefined ? { id: sub } : { id: sub, name } };
|
|
73
|
+
if (options.check !== undefined) {
|
|
74
|
+
const check = options.check;
|
|
75
|
+
const refusal = yield* Effect.promise(() => check(verified.payload, access));
|
|
76
|
+
if (refusal !== undefined)
|
|
77
|
+
return yield* new Unauthorized({ reason: refusal });
|
|
78
|
+
}
|
|
79
|
+
return access;
|
|
80
|
+
}),
|
|
81
|
+
authorizationServers: Effect.succeed([options.issuer]),
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A JWT gate configured in code, from the Worker's environment: how a host composing Loopback uses
|
|
86
|
+
* it. `options` may take the host's own environment type, such as the one `wrangler types` writes.
|
|
87
|
+
*/
|
|
88
|
+
// The parameter is what lets `options` take the host's own environment type: a function of that
|
|
89
|
+
// type does not accept every LoopbackBindings, so it could not be passed as one.
|
|
90
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
|
|
91
|
+
export const jwtGate = (options) => Layer.effect(Gate, WorkerEnv.use((env) => Effect.sync(() => makeJwtGate(options(env)))));
|
|
92
|
+
/** The settings a JWT-gated deployment reads, and the token gate's secret. */
|
|
93
|
+
const SETTINGS = {
|
|
94
|
+
token: "LOOPBACK_TOKEN",
|
|
95
|
+
jwksUrl: "LOOPBACK_JWKS_URL",
|
|
96
|
+
issuer: "LOOPBACK_JWT_ISSUER",
|
|
97
|
+
audience: "LOOPBACK_JWT_AUDIENCE",
|
|
98
|
+
spaceClaim: "LOOPBACK_SPACE_CLAIM",
|
|
99
|
+
nameClaim: "LOOPBACK_NAME_CLAIM",
|
|
100
|
+
};
|
|
101
|
+
const setting = (name) => Config.option(Config.NonEmptyString(name)).pipe(Effect.catch(() => Effect.succeed(Option.none())));
|
|
102
|
+
const required = (name) => setting(name).pipe(Effect.flatMap(Option.match({
|
|
103
|
+
onNone: () => Effect.die(new NotConfigured(`${name} is not set; a JWT-gated deployment needs it.`)),
|
|
104
|
+
onSome: Effect.succeed,
|
|
105
|
+
})));
|
|
106
|
+
/** The JWT gate's options, from the deployment's settings. */
|
|
107
|
+
const configuredJwtOptions = Effect.gen(function* () {
|
|
108
|
+
const nameClaim = yield* setting(SETTINGS.nameClaim);
|
|
109
|
+
const options = {
|
|
110
|
+
jwksUrl: yield* required(SETTINGS.jwksUrl),
|
|
111
|
+
issuer: yield* required(SETTINGS.issuer),
|
|
112
|
+
audience: (yield* required(SETTINGS.audience))
|
|
113
|
+
.split(",")
|
|
114
|
+
.map((audience) => audience.trim())
|
|
115
|
+
.filter((audience) => audience.length > 0),
|
|
116
|
+
spaceClaim: yield* required(SETTINGS.spaceClaim),
|
|
117
|
+
...(Option.isSome(nameClaim) ? { nameClaim: nameClaim.value } : {}),
|
|
118
|
+
};
|
|
119
|
+
return options;
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* The gate a deployment's settings choose: the token gate when `LOOPBACK_TOKEN` is set, the JWT gate
|
|
123
|
+
* when `LOOPBACK_JWKS_URL` is. Exactly one of them: both, or neither, is a deployment that cannot say
|
|
124
|
+
* who anyone is, answered as a 500 that says so. Settings are read on first use, so a deployment whose
|
|
125
|
+
* invoker vouches for every caller needs neither.
|
|
126
|
+
*/
|
|
127
|
+
export const ConfiguredGate = Layer.effect(Gate, Effect.gen(function* () {
|
|
128
|
+
const chosen = yield* Effect.cached(Effect.gen(function* () {
|
|
129
|
+
const token = yield* setting(SETTINGS.token);
|
|
130
|
+
const jwks = yield* setting(SETTINGS.jwksUrl);
|
|
131
|
+
if (Option.isSome(token) && Option.isSome(jwks)) {
|
|
132
|
+
return yield* Effect.die(new NotConfigured(`Both ${SETTINGS.token} and ${SETTINGS.jwksUrl} are set. Set one: the token for a single space, or the JWT settings for a space per tenant.`));
|
|
133
|
+
}
|
|
134
|
+
if (Option.isSome(jwks))
|
|
135
|
+
return makeJwtGate(yield* configuredJwtOptions);
|
|
136
|
+
return yield* makeTokenGate;
|
|
137
|
+
}));
|
|
138
|
+
return Gate.of({
|
|
139
|
+
authenticate: (request) => Effect.flatMap(chosen, (gate) => gate.authenticate(request)),
|
|
140
|
+
authorizationServers: Effect.flatMap(chosen, (gate) => gate.authorizationServers),
|
|
141
|
+
});
|
|
142
|
+
}));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Access } from "./gate.ts";
|
|
2
|
+
import { type WorkerRuntime } from "./run.ts";
|
|
3
|
+
/** Where an inbox, or one ask in it, is read and answered. */
|
|
4
|
+
export interface InboxLink {
|
|
5
|
+
readonly origin: string;
|
|
6
|
+
readonly space: string;
|
|
7
|
+
readonly askId?: string;
|
|
8
|
+
}
|
|
9
|
+
/** The inbox this Worker serves itself, as `@loopbacktools/app` routes it: the space's, or one ask in it. */
|
|
10
|
+
export declare const defaultInboxUrl: ({ origin, space, askId }: InboxLink) => string;
|
|
11
|
+
/**
|
|
12
|
+
* The MCP endpoint. The gate runs first; what it established reaches the per-request server
|
|
13
|
+
* through the `authInfo` the SDK passes along untouched.
|
|
14
|
+
*/
|
|
15
|
+
export declare const makeMcp: (inboxUrl: (link: InboxLink) => string) => (request: Request, runtime: WorkerRuntime, access: Access, requestId: string) => Promise<Response>;
|