@fungi.computer/oyster 0.6.0 → 0.8.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/README.md +133 -78
- package/dist/agent-archive-projection.d.ts +8 -0
- package/dist/agent-archive-projection.js +49 -0
- package/dist/agent-schedules.d.ts +61 -0
- package/dist/agent-schedules.js +19 -0
- package/dist/agents.d.ts +35 -14
- package/dist/agents.js +93 -144
- package/dist/app-builds.d.ts +7 -2
- package/dist/app-builds.js +19 -2
- package/dist/approval-capability.d.ts +1 -1
- package/dist/approval-capability.js +11 -16
- package/dist/approval-home.d.ts +2 -1
- package/dist/approval-home.js +5 -3
- package/dist/apps/catalog.js +1 -1
- package/dist/apps/gate.d.ts +4 -7
- package/dist/apps/gate.js +14 -29
- package/dist/apps/index.d.ts +1 -1
- package/dist/apps/installation.d.ts +4 -42
- package/dist/apps/installation.js +7 -75
- package/dist/apps/operations.js +0 -10
- package/dist/apps/permissions.d.ts +18 -8
- package/dist/apps/permissions.js +7 -1
- package/dist/auth.d.ts +1 -24
- package/dist/auth.js +9 -39
- package/dist/authorization.d.ts +307 -0
- package/dist/authorization.js +542 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/build-jobs.d.ts +2 -2
- package/dist/build/candidate-builder.d.ts +2 -0
- package/dist/build/candidate-builder.js +5 -1
- package/dist/build/candidate-cli.js +3 -0
- package/dist/build/candidate-cli.mjs +70 -22
- package/dist/build/job-log.d.ts +23 -0
- package/dist/build/job-log.js +88 -0
- package/dist/build/local-build.d.ts +9 -1
- package/dist/build/local-build.js +11 -9
- package/dist/cloudflare.d.ts +4 -1
- package/dist/cloudflare.js +1 -1
- package/dist/files.js +9 -8
- package/dist/git-admission.d.ts +3 -3
- package/dist/git-admission.js +4 -6
- package/dist/git-door.d.ts +8 -7
- package/dist/git-door.js +18 -17
- package/dist/git.d.ts +1 -1
- package/dist/host.d.ts +33 -40
- package/dist/host.js +156 -275
- package/dist/installation-execution.js +14 -5
- package/dist/installation-operations.d.ts +4 -180
- package/dist/installation-operations.js +1 -5
- package/dist/installation-store-generation.d.ts +4 -1
- package/dist/installation-store-generation.js +59 -6
- package/dist/internal/delivery.js +0 -1
- package/dist/internal/host-gate.d.ts +3 -2
- package/dist/internal/host-gate.js +5 -18
- package/dist/internal/invocation-timing.d.ts +16 -0
- package/dist/internal/invocation-timing.js +23 -0
- package/dist/internal/registry-installations.js +1 -0
- package/dist/invocation.d.ts +15 -14
- package/dist/invocation.js +58 -26
- package/dist/keys.d.ts +2 -19
- package/dist/keys.js +1 -37
- package/dist/mcp.d.ts +15 -27
- package/dist/mcp.js +105 -155
- package/dist/page/agent-browser.d.ts +15 -0
- package/dist/page/agent-browser.js +162 -0
- package/dist/page/app-permissions-port.d.ts +3 -0
- package/dist/page/app-permissions-port.js +43 -0
- package/dist/page/app-permissions.d.ts +11 -0
- package/dist/page/app-permissions.js +296 -0
- package/dist/page/isolated-page-theme.d.ts +2 -0
- package/dist/page/isolated-page-theme.js +11 -0
- package/dist/page/mount.d.ts +2 -13
- package/dist/page/mount.js +7 -93
- package/dist/page-script.d.ts +0 -3
- package/dist/page-script.js +3 -4
- package/dist/standalone/agent-archive-owner.d.ts +42 -0
- package/dist/standalone/agent-archive-owner.js +116 -0
- package/dist/standalone/agent-archive-schedules.d.ts +6 -0
- package/dist/standalone/agent-archive-schedules.js +76 -0
- package/dist/standalone/agent-archive.d.ts +31 -0
- package/dist/standalone/agent-archive.js +67 -0
- package/dist/standalone/agent-computers.d.ts +7 -6
- package/dist/standalone/agent-computers.js +4 -7
- package/dist/standalone/agent-mail.d.ts +2 -2
- package/dist/standalone/agents.d.ts +186 -20
- package/dist/standalone/agents.js +251 -30
- package/dist/standalone/app-builds-binding.js +1 -1
- package/dist/standalone/app-set.js +2 -54
- package/dist/standalone/app-source.d.ts +3 -4
- package/dist/standalone/app-source.js +12 -9
- package/dist/standalone/builds.d.ts +2 -14
- package/dist/standalone/builds.js +15 -13
- package/dist/standalone/computer-fleet.d.ts +4 -4
- package/dist/standalone/computer-fleet.js +13 -42
- package/dist/standalone/computer.d.ts +4 -3
- package/dist/standalone/computer.js +52 -53
- package/dist/standalone/dashboard-approval.d.ts +10 -0
- package/dist/standalone/dashboard-approval.js +66 -0
- package/dist/standalone/machine-link.d.ts +4 -221
- package/dist/standalone/machine-link.js +21 -15
- package/dist/standalone/media.d.ts +1 -0
- package/dist/standalone/media.js +20 -14
- package/dist/standalone/owner-routes.d.ts +16 -0
- package/dist/standalone/owner-routes.js +21 -0
- package/dist/standalone/permission-resources.d.ts +5 -0
- package/dist/standalone/permission-resources.js +31 -0
- package/dist/standalone/registry.d.ts +7 -0
- package/dist/standalone/registry.js +3 -0
- package/dist/standalone/tenancy.d.ts +0 -3
- package/dist/standalone/tenancy.js +2 -5
- package/dist/standalone/worker-routes.d.ts +19 -0
- package/dist/standalone/worker-routes.js +9 -0
- package/dist/standalone/worker.d.ts +1 -1
- package/dist/standalone/worker.js +27 -35
- package/dist/standalone/workflow-binding.d.ts +10 -3
- package/dist/standalone/workflow-binding.js +22 -5
- package/dist/standalone/workflow-git-capture.js +2 -1
- package/dist/standalone/workflow-host.d.ts +8 -103
- package/dist/standalone/workflow-host.js +1 -1
- package/dist/standalone/workflow-node-capture.js +2 -2
- package/dist/standalone/workflow-root-authority.d.ts +1 -1
- package/dist/standalone/workflow-root-authority.js +1 -1
- package/dist/standalone/workflow-schedules.d.ts +31 -4
- package/dist/standalone/workflow-schedules.js +18 -5
- package/dist/standalone/workflow-webhooks.d.ts +4 -18
- package/dist/standalone/workflow-webhooks.js +5 -5
- package/dist/standalone/workflows.d.ts +53 -27
- package/dist/standalone/workflows.js +9 -3
- package/dist/storage-frame.d.ts +6 -6
- package/dist/testing/capabilities.d.ts +7 -6
- package/dist/testing/capabilities.js +3 -6
- package/dist/testing/installation.d.ts +0 -1
- package/dist/testing/installation.js +2 -5
- package/dist/workers-git.d.ts +12 -4
- package/dist/workers-git.js +38 -35
- package/dist/workers.d.ts +9 -1
- package/dist/workers.js +44 -8
- package/dist/workflows.d.ts +4 -0
- package/dist/workflows.js +2 -0
- package/docs/api/README.md +7 -0
- package/docs/api/agent-schedules.md +176 -0
- package/docs/api/agents.md +34 -13
- package/docs/api/app-storage.md +1 -1
- package/docs/api/apps.md +323 -225
- package/docs/api/auth.md +0 -55
- package/docs/api/authorization.md +1300 -0
- package/docs/api/build.md +42 -38
- package/docs/api/cloudflare.md +3 -3
- package/docs/api/git.md +20 -11
- package/docs/api/host.md +64 -192
- package/docs/api/index.md +1 -1
- package/docs/api/invocation.md +14 -14
- package/docs/api/keys.md +1 -98
- package/docs/api/mcp.md +43 -40
- package/docs/api/modules.md +2 -0
- package/docs/api/page/mount.md +16 -19
- package/docs/api/workers-git.md +46 -12
- package/docs/api/workers.md +111 -222
- package/package.json +41 -22
- package/dist/page/computer-target.d.ts +0 -3
- package/dist/page/computer-target.js +0 -9
- package/dist/standalone/channel-session-door.d.ts +0 -4
- package/dist/standalone/channel-session-door.js +0 -72
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oyster's one authorization owner. Every Oyster HTTP route is registered
|
|
3
|
+
* through {@link routes} with a typed {@link Requirement}; a table's `serve`
|
|
4
|
+
* is the only place those declarations become a Hono router, so an undeclared
|
|
5
|
+
* route cannot be served and a declared one cannot skip its decision.
|
|
6
|
+
*
|
|
7
|
+
* Who may reach what is decided here once, from one {@link Authority}: the
|
|
8
|
+
* owner reaches everything; a named key reaches exactly its Ear Tag grants
|
|
9
|
+
* (Apps, Agents, the Computer); an Agent reaches its App tool policy. An
|
|
10
|
+
* anonymous request reaches only the public routes: sign-in, robots.txt and
|
|
11
|
+
* digest-pinned assets. The App gate
|
|
12
|
+
* (`apps/gate.ts`) still owns App-declaration rules; it asks this module for a
|
|
13
|
+
* principal's App reach.
|
|
14
|
+
*/
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
import { timing } from "hono/timing";
|
|
17
|
+
import * as Schema from "effect/Schema";
|
|
18
|
+
import { oysterKeyVocabulary } from "./keys.js";
|
|
19
|
+
/**
|
|
20
|
+
* One live key's access to one installation: `write` when its grants admit
|
|
21
|
+
* `apps.use` there, `read` when they admit `apps.read`, otherwise `null` --
|
|
22
|
+
* including for an installation added after the key was issued, unless the
|
|
23
|
+
* key holds "All Apps" (`targets: {kind: "all"}`). The same shape as an
|
|
24
|
+
* Agent's stored {@link AppToolPolicy}; its caller adds `bindingName` (which
|
|
25
|
+
* varies by caller: the plain installation id for the gate, the uppercased
|
|
26
|
+
* binding name for MCP) to reach the full `AppToolAccess`.
|
|
27
|
+
*/
|
|
28
|
+
export function keyAppAccess(key, installationId) {
|
|
29
|
+
const level = oysterKeyVocabulary.grantsAllow(key.grants, "apps.use", installationId)
|
|
30
|
+
? "write"
|
|
31
|
+
: oysterKeyVocabulary.grantsAllow(key.grants, "apps.read", installationId)
|
|
32
|
+
? "read"
|
|
33
|
+
: null;
|
|
34
|
+
if (level === null)
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
level,
|
|
38
|
+
revision: `key:${key.keyId}:${String(key.grantVersion)}`,
|
|
39
|
+
grantedBy: key.owner,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Agent access is scoped to the granted Agent ids, never inferred from App grants. */
|
|
43
|
+
export function keyAgentAccess(key, agentId) {
|
|
44
|
+
return oysterKeyVocabulary.grantsAllow(key.grants, "agents.use", agentId);
|
|
45
|
+
}
|
|
46
|
+
/** Whether a live key's own grants turn its Computer access on. Off by default. */
|
|
47
|
+
export function keyAllowsComputerUse(key) {
|
|
48
|
+
return oysterKeyVocabulary.grantsAllow(key.grants, "computer.use");
|
|
49
|
+
}
|
|
50
|
+
/** True only for a member admitted with the owner role. */
|
|
51
|
+
export function oysterPrincipalIsOwner(principal) {
|
|
52
|
+
return "role" in principal && principal.role === "owner";
|
|
53
|
+
}
|
|
54
|
+
/** A host's own ports: its key store's liveness and its Agents' tool policy. */
|
|
55
|
+
export function hostAuthorityPorts(host) {
|
|
56
|
+
return {
|
|
57
|
+
liveKey: async (keyId) => (await host.keys?.live(keyId)) ?? null,
|
|
58
|
+
appToolPolicy: async (actor) => (await host.appToolPolicy?.(actor)) ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Resolve one principal's authority through the live key port. */
|
|
62
|
+
export async function authorityOf(principal, ports) {
|
|
63
|
+
if (oysterPrincipalIsOwner(principal))
|
|
64
|
+
return { kind: "owner", principal };
|
|
65
|
+
if ("role" in principal)
|
|
66
|
+
return { kind: "member", principal };
|
|
67
|
+
const { actor } = principal;
|
|
68
|
+
switch (actor.kind) {
|
|
69
|
+
case "team-key":
|
|
70
|
+
return {
|
|
71
|
+
kind: "key",
|
|
72
|
+
principal,
|
|
73
|
+
key: await ports.liveKey(actor.keyId),
|
|
74
|
+
};
|
|
75
|
+
case "agent":
|
|
76
|
+
return { kind: "agent", principal, actor };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Whether this authority reaches one Agent and its Sessions. Only the owner and granted keys do. */
|
|
80
|
+
export function reachesAgent(authority, agentId) {
|
|
81
|
+
switch (authority.kind) {
|
|
82
|
+
case "owner":
|
|
83
|
+
return true;
|
|
84
|
+
case "key":
|
|
85
|
+
return authority.key !== null && keyAgentAccess(authority.key, agentId);
|
|
86
|
+
case "member":
|
|
87
|
+
case "agent":
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether this authority reaches the host's Computer, its workspace files and
|
|
93
|
+
* the media written there. Members do; a key only with `computer.use`;
|
|
94
|
+
* Agents reach a Computer only through their own attachment owner.
|
|
95
|
+
*/
|
|
96
|
+
export function reachesComputer(authority) {
|
|
97
|
+
switch (authority.kind) {
|
|
98
|
+
case "owner":
|
|
99
|
+
case "member":
|
|
100
|
+
return true;
|
|
101
|
+
case "key":
|
|
102
|
+
return authority.key !== null && keyAllowsComputerUse(authority.key);
|
|
103
|
+
case "agent":
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A key's or Agent's tool access to one installation, in the App gate's
|
|
109
|
+
* policy shape; `null` when it reaches none. Members and the owner hold
|
|
110
|
+
* no tool policy: the App gate decides them by role.
|
|
111
|
+
*/
|
|
112
|
+
export async function appToolAccess(authority, installationId, ports) {
|
|
113
|
+
switch (authority.kind) {
|
|
114
|
+
case "key":
|
|
115
|
+
return authority.key === null
|
|
116
|
+
? null
|
|
117
|
+
: keyAppAccess(authority.key, installationId);
|
|
118
|
+
case "agent":
|
|
119
|
+
return ports.appToolPolicy(authority.actor);
|
|
120
|
+
case "owner":
|
|
121
|
+
case "member":
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether this authority may see or open one installation at all (the home
|
|
127
|
+
* list, Git). A key needs `apps.read` there; `use` needs `apps.use`.
|
|
128
|
+
* Nothing anonymous reaches one.
|
|
129
|
+
*/
|
|
130
|
+
export async function reachesInstallation(authority, installation, access, ports) {
|
|
131
|
+
switch (authority.kind) {
|
|
132
|
+
case "owner":
|
|
133
|
+
case "member":
|
|
134
|
+
return true;
|
|
135
|
+
case "key":
|
|
136
|
+
case "agent": {
|
|
137
|
+
const policy = await appToolAccess(authority, installation.installationId, ports);
|
|
138
|
+
return policy !== null && (access === "read" || policy.level === "write");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Whether a live key's grants permit a one-use device enrollment. */
|
|
143
|
+
export function allowsMachineEnrollment(key) {
|
|
144
|
+
return (key.grants.length === 1 &&
|
|
145
|
+
key.grants[0]?.capability === "machine.enroll" &&
|
|
146
|
+
oysterKeyVocabulary.grantsAllow(key.grants, "machine.enroll"));
|
|
147
|
+
}
|
|
148
|
+
/** A refusal this module returns before any handler runs. */
|
|
149
|
+
function refused(status, error) {
|
|
150
|
+
return Response.json({ error }, { status });
|
|
151
|
+
}
|
|
152
|
+
/** The token only this module holds; a requirement cannot be minted without it. */
|
|
153
|
+
const mint = Symbol("oyster.requirement.mint");
|
|
154
|
+
/** Module-private reader of a requirement's mint record; set once below. */
|
|
155
|
+
let mintRecordOf;
|
|
156
|
+
/**
|
|
157
|
+
* One route's declared requirement: an opaque, frozen value only this module
|
|
158
|
+
* mints. Its label and decision live in private fields that only this module
|
|
159
|
+
* reads, so a copy, a look-alike, an overridden method or a relabelled field
|
|
160
|
+
* changes nothing a route serves, and registering a non-minted value throws.
|
|
161
|
+
*/
|
|
162
|
+
export class Requirement {
|
|
163
|
+
#declared;
|
|
164
|
+
#admit;
|
|
165
|
+
static {
|
|
166
|
+
mintRecordOf = (value) => #declared in value
|
|
167
|
+
? { declared: value.#declared, admit: value.#admit }
|
|
168
|
+
: undefined;
|
|
169
|
+
}
|
|
170
|
+
/** Module-private: use the requirement constructors below. */
|
|
171
|
+
constructor(token, declared, admit) {
|
|
172
|
+
if (token !== mint)
|
|
173
|
+
throw new Error("Only authorization.ts mints route requirements");
|
|
174
|
+
this.#declared = Object.freeze({ ...declared });
|
|
175
|
+
this.#admit = admit;
|
|
176
|
+
Object.freeze(this);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function requirement(declared, admit) {
|
|
180
|
+
return new Requirement(mint, declared, admit);
|
|
181
|
+
}
|
|
182
|
+
/** Authenticate, or answer the table's own unauthenticated refusal. */
|
|
183
|
+
async function signedIn(facts) {
|
|
184
|
+
const result = await facts.principal();
|
|
185
|
+
return result.kind === "admitted"
|
|
186
|
+
? result.principal
|
|
187
|
+
: unauthenticated(facts.request, result);
|
|
188
|
+
}
|
|
189
|
+
/** A browser page asks to sign in; every other refused request gets its status. */
|
|
190
|
+
export function unauthenticated(request, result) {
|
|
191
|
+
if ((result.status === 401 || result.status === 403) &&
|
|
192
|
+
request.method === "GET" &&
|
|
193
|
+
(request.headers.get("Accept") ?? "").includes("text/html"))
|
|
194
|
+
return new Response(null, { status: 302, headers: { Location: "/login" } });
|
|
195
|
+
const headers = new Headers();
|
|
196
|
+
if (result.retryAfter !== undefined)
|
|
197
|
+
headers.set("Retry-After", String(result.retryAfter));
|
|
198
|
+
return Response.json({ error: "Sign in to use this Oyster" }, { status: result.status, headers });
|
|
199
|
+
}
|
|
200
|
+
/** Served without any principal: sandboxed App bytes, page assets, sign-in. */
|
|
201
|
+
export function publicRoute(reason) {
|
|
202
|
+
return requirement({ kind: "public", reason }, () => Promise.resolve(null));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The route verifies its own credential (MCP, Git, signed webhook delivery,
|
|
206
|
+
* device enrollment); `reason` names that credential. A door that reads an
|
|
207
|
+
* Oyster principal decides its reach through this module's functions.
|
|
208
|
+
*/
|
|
209
|
+
export function door(reason) {
|
|
210
|
+
return requirement({ kind: "door", reason }, () => Promise.resolve(null));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The owner only. `direct` also refuses an App-mediated request (one the
|
|
214
|
+
* page bridge sends with `X-Oyster-Installation`, and every request a
|
|
215
|
+
* capability table serves): consent and secrets never ride an App.
|
|
216
|
+
*/
|
|
217
|
+
export function owner(via) {
|
|
218
|
+
return requirement({ kind: "owner", via }, async (facts) => {
|
|
219
|
+
const principal = await signedIn(facts);
|
|
220
|
+
if (principal instanceof Response)
|
|
221
|
+
return principal;
|
|
222
|
+
if (!oysterPrincipalIsOwner(principal))
|
|
223
|
+
return refused(403, "Owner only");
|
|
224
|
+
const origin = facts.mediated ? "app" : "owner-direct";
|
|
225
|
+
if (via === "direct" && origin === "app")
|
|
226
|
+
return refused(403, "Owner direct only");
|
|
227
|
+
return { principal, origin };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Any member, owner or not; never a key or Agent. `deny` shapes the
|
|
232
|
+
* refusal for a contract whose wire already names one.
|
|
233
|
+
*/
|
|
234
|
+
export function member(deny = () => refused(403, "Members only")) {
|
|
235
|
+
return requirement({ kind: "member" }, async (facts) => {
|
|
236
|
+
const principal = await signedIn(facts);
|
|
237
|
+
if (principal instanceof Response)
|
|
238
|
+
return principal;
|
|
239
|
+
return "role" in principal ? { principal } : deny();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* An App route: any signed-in principal, decided per call by the App gate,
|
|
244
|
+
* which reads this module's {@link appToolAccess}.
|
|
245
|
+
*/
|
|
246
|
+
export function app() {
|
|
247
|
+
return requirement({ kind: "app" }, async (facts) => {
|
|
248
|
+
const principal = await signedIn(facts);
|
|
249
|
+
return principal instanceof Response ? principal : { principal };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** The installation list (the home page): the owner sees all, keys their granted ones. */
|
|
253
|
+
export function apps() {
|
|
254
|
+
return requirement({ kind: "apps" }, async (facts) => {
|
|
255
|
+
const authority = await signedInAuthority(facts);
|
|
256
|
+
if (authority instanceof Response)
|
|
257
|
+
return authority;
|
|
258
|
+
return {
|
|
259
|
+
authority,
|
|
260
|
+
reaches: (installation) => reachesInstallation(authority, installation, "read", facts.ports),
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** Resolve a signed-in principal's authority, refusing an anonymous request. */
|
|
265
|
+
async function signedInAuthority(facts) {
|
|
266
|
+
const principal = await signedIn(facts);
|
|
267
|
+
if (principal instanceof Response)
|
|
268
|
+
return principal;
|
|
269
|
+
return authorityOf(principal, facts.ports);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* One Agent, named by the route's `:agentId` (or by `id` from the request):
|
|
273
|
+
* the owner, or a key granted `agents.use` for that Agent id.
|
|
274
|
+
*/
|
|
275
|
+
export function agent(id = (facts) => Promise.resolve(facts.param("agentId") ?? null)) {
|
|
276
|
+
return requirement({ kind: "agent" }, async (facts) => {
|
|
277
|
+
const authority = await signedInAuthority(facts);
|
|
278
|
+
if (authority instanceof Response)
|
|
279
|
+
return authority;
|
|
280
|
+
const agentId = await id(facts);
|
|
281
|
+
if (agentId === null)
|
|
282
|
+
return refused(400, "Name the Agent");
|
|
283
|
+
if (!reachesAgent(authority, agentId))
|
|
284
|
+
return refused(403, "Agent access denied");
|
|
285
|
+
return { authority, principal: authority.principal, agentId };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/** Agents (and their inbox rows): the owner sees all, a key its granted Agents. */
|
|
289
|
+
export function agents() {
|
|
290
|
+
return requirement({ kind: "agents" }, async (facts) => {
|
|
291
|
+
const authority = await signedInAuthority(facts);
|
|
292
|
+
if (authority instanceof Response)
|
|
293
|
+
return authority;
|
|
294
|
+
return {
|
|
295
|
+
authority,
|
|
296
|
+
reaches: (agentId) => Promise.resolve(reachesAgent(authority, agentId)),
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/** The host's Computer, its workspace files and media: see {@link reachesComputer}. */
|
|
301
|
+
export function computer() {
|
|
302
|
+
return requirement({ kind: "computer" }, async (facts) => {
|
|
303
|
+
const authority = await signedInAuthority(facts);
|
|
304
|
+
if (authority instanceof Response)
|
|
305
|
+
return authority;
|
|
306
|
+
if (!reachesComputer(authority))
|
|
307
|
+
return refused(403, "Computer access requires an owner or key");
|
|
308
|
+
return { principal: authority.principal };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/** Module-private reader of a table's record; set once below. */
|
|
312
|
+
let tableRecordOf;
|
|
313
|
+
/** A table's record, or a refusal for anything `routes()` did not build. */
|
|
314
|
+
function recordOf(table) {
|
|
315
|
+
const record = tableRecordOf(table);
|
|
316
|
+
if (record === undefined)
|
|
317
|
+
throw new Error("routes() did not build this route table");
|
|
318
|
+
return record;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* A declared route table: a frozen, opaque value only {@link routes} builds.
|
|
322
|
+
* Its routes live in a private record; including or serving reads only that
|
|
323
|
+
* record, never a method a copy could override. A table cannot serve itself:
|
|
324
|
+
* only {@link serveHost}, {@link serveDoors} and {@link serveCapability} do,
|
|
325
|
+
* each with the principal its composition root admits. There is no `use`: a
|
|
326
|
+
* path-wide middleware could serve a path no route declares.
|
|
327
|
+
*/
|
|
328
|
+
export class Routes {
|
|
329
|
+
#entries = [];
|
|
330
|
+
#errorHandler = undefined;
|
|
331
|
+
static {
|
|
332
|
+
tableRecordOf = (table) => #entries in table
|
|
333
|
+
? {
|
|
334
|
+
entries: table.#entries,
|
|
335
|
+
errorHandler: () => table.#errorHandler,
|
|
336
|
+
}
|
|
337
|
+
: undefined;
|
|
338
|
+
}
|
|
339
|
+
/** Module-private: use {@link routes}. */
|
|
340
|
+
constructor(token) {
|
|
341
|
+
if (token !== mint)
|
|
342
|
+
throw new Error("Only authorization.ts builds route tables");
|
|
343
|
+
Object.freeze(this);
|
|
344
|
+
}
|
|
345
|
+
/** Register one GET route. */
|
|
346
|
+
get(path, requirement, handler, options) {
|
|
347
|
+
return this.#register(["GET"], path, requirement, handler, options);
|
|
348
|
+
}
|
|
349
|
+
/** Register one POST route. */
|
|
350
|
+
post(path, requirement, handler, options) {
|
|
351
|
+
return this.#register(["POST"], path, requirement, handler, options);
|
|
352
|
+
}
|
|
353
|
+
/** Register one PUT route. */
|
|
354
|
+
put(path, requirement, handler, options) {
|
|
355
|
+
return this.#register(["PUT"], path, requirement, handler, options);
|
|
356
|
+
}
|
|
357
|
+
/** Register one DELETE route. */
|
|
358
|
+
delete(path, requirement, handler, options) {
|
|
359
|
+
return this.#register(["DELETE"], path, requirement, handler, options);
|
|
360
|
+
}
|
|
361
|
+
/** Register one route for every method. */
|
|
362
|
+
all(path, requirement, handler, options) {
|
|
363
|
+
return this.#register("ALL", path, requirement, handler, options);
|
|
364
|
+
}
|
|
365
|
+
/** Register one route for several methods. */
|
|
366
|
+
on(methods, path, requirement, handler, options) {
|
|
367
|
+
return this.#register(methods, path, requirement, handler, options);
|
|
368
|
+
}
|
|
369
|
+
/** Serve another table's routes at this position, keeping its own error handler. */
|
|
370
|
+
include(other) {
|
|
371
|
+
recordOf(other);
|
|
372
|
+
this.#entries.push({ kind: "table", table: other });
|
|
373
|
+
return this;
|
|
374
|
+
}
|
|
375
|
+
/** Answer an error thrown by this table's own handlers. */
|
|
376
|
+
onError(handler) {
|
|
377
|
+
this.#errorHandler = handler;
|
|
378
|
+
return this;
|
|
379
|
+
}
|
|
380
|
+
/** Every route this table and its included tables declare, in serving order. */
|
|
381
|
+
declarations() {
|
|
382
|
+
return declarationsOf(this);
|
|
383
|
+
}
|
|
384
|
+
#register(methods, path, requirement, handler, options = {}) {
|
|
385
|
+
// Only the mint record decides: a copy or look-alike has none, and a
|
|
386
|
+
// minted value's own properties are never read.
|
|
387
|
+
const record = mintRecordOf(requirement);
|
|
388
|
+
if (record === undefined)
|
|
389
|
+
throw new Error(`${path} names a requirement authorization.ts did not mint`);
|
|
390
|
+
this.#entries.push({
|
|
391
|
+
kind: "route",
|
|
392
|
+
declaration: { methods, path, requirement: record.declared },
|
|
393
|
+
mount: (hono, principal, ports, mediated) => {
|
|
394
|
+
// One admission per request, shared down the middleware chain.
|
|
395
|
+
const admittedFor = new WeakMap();
|
|
396
|
+
const gate = async (context, next) => {
|
|
397
|
+
const admitted = await record.admit({
|
|
398
|
+
request: context.req.raw,
|
|
399
|
+
param: (name) => context.req.param(name),
|
|
400
|
+
body: async (schema) => {
|
|
401
|
+
const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(schema))(await context.req.text());
|
|
402
|
+
return decoded._tag === "Some" ? decoded.value : null;
|
|
403
|
+
},
|
|
404
|
+
principal: () => principal(context),
|
|
405
|
+
mediated: mediated(context.req.raw),
|
|
406
|
+
ports,
|
|
407
|
+
});
|
|
408
|
+
if (admitted instanceof Response)
|
|
409
|
+
return admitted;
|
|
410
|
+
admittedFor.set(context, { value: admitted });
|
|
411
|
+
await next();
|
|
412
|
+
return undefined;
|
|
413
|
+
};
|
|
414
|
+
const finish = async (context) => {
|
|
415
|
+
const admitted = admittedFor.get(context);
|
|
416
|
+
if (admitted === undefined)
|
|
417
|
+
throw new Error("A route handler ran without its admission");
|
|
418
|
+
return handler(context, admitted.value);
|
|
419
|
+
};
|
|
420
|
+
// Hono runs same-path handlers in registration order.
|
|
421
|
+
const chain = [gate, ...(options.middleware ?? [])];
|
|
422
|
+
for (const step of chain)
|
|
423
|
+
if (methods === "ALL")
|
|
424
|
+
hono.all(path, step);
|
|
425
|
+
else
|
|
426
|
+
hono.on([...methods], path, step);
|
|
427
|
+
if (methods === "ALL")
|
|
428
|
+
hono.all(path, (context) => finish(context, null));
|
|
429
|
+
else
|
|
430
|
+
hono.on([...methods], path, (context) => finish(context, null));
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
return this;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** Start a declared route table. */
|
|
437
|
+
export function routes() {
|
|
438
|
+
return new Routes(mint);
|
|
439
|
+
}
|
|
440
|
+
/** A table's declarations, read from its record and its included records. */
|
|
441
|
+
function declarationsOf(table) {
|
|
442
|
+
return recordOf(table).entries.flatMap((entry) => entry.kind === "route" ? [entry.declaration] : declarationsOf(entry.table));
|
|
443
|
+
}
|
|
444
|
+
/** The page bridge names the installation an App call comes from. */
|
|
445
|
+
const bridged = (request) => request.headers.has("X-Oyster-Installation");
|
|
446
|
+
/**
|
|
447
|
+
* Serve one table: the only `Hono` the Oyster host builds. Each route runs
|
|
448
|
+
* its requirement first, then its middleware, then its handler. Nothing a
|
|
449
|
+
* caller supplies runs before a route's requirement: timing is Hono's own
|
|
450
|
+
* middleware, and header hooks run on the finished response.
|
|
451
|
+
*/
|
|
452
|
+
function serveTable(table, principal, ports, rules) {
|
|
453
|
+
const { entries, errorHandler } = recordOf(table);
|
|
454
|
+
const hono = new Hono();
|
|
455
|
+
if (rules.serverTiming !== null) {
|
|
456
|
+
const { totalDescription } = rules.serverTiming;
|
|
457
|
+
hono.use(timing({
|
|
458
|
+
totalDescription,
|
|
459
|
+
enabled: (context) => context.res.status !== 101,
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
const hooks = rules.headers;
|
|
463
|
+
hono.use(async (context, next) => {
|
|
464
|
+
await next();
|
|
465
|
+
const response = context.res;
|
|
466
|
+
// An upgraded socket keeps the runtime's own response.
|
|
467
|
+
if (response.status === 101)
|
|
468
|
+
return;
|
|
469
|
+
const headers = new Headers(response.headers);
|
|
470
|
+
for (const hook of hooks) {
|
|
471
|
+
const added = hook({
|
|
472
|
+
status: response.status,
|
|
473
|
+
headers: new Headers(response.headers),
|
|
474
|
+
});
|
|
475
|
+
if (added !== null)
|
|
476
|
+
for (const [name, value] of Object.entries(added))
|
|
477
|
+
headers.set(name, value);
|
|
478
|
+
}
|
|
479
|
+
// A fresh response with mutable headers, the route's status and body.
|
|
480
|
+
context.res = new Response(response.body, {
|
|
481
|
+
status: response.status,
|
|
482
|
+
statusText: response.statusText,
|
|
483
|
+
headers,
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
for (const entry of entries)
|
|
487
|
+
if (entry.kind === "table")
|
|
488
|
+
hono.route("/", serveTable(entry.table, principal, ports, {
|
|
489
|
+
headers: [],
|
|
490
|
+
serverTiming: null,
|
|
491
|
+
mediated: rules.mediated,
|
|
492
|
+
}));
|
|
493
|
+
else
|
|
494
|
+
entry.mount(hono, principal, ports, rules.mediated);
|
|
495
|
+
const handler = errorHandler();
|
|
496
|
+
if (handler !== undefined)
|
|
497
|
+
hono.onError(handler);
|
|
498
|
+
return hono;
|
|
499
|
+
}
|
|
500
|
+
function servedView(hono) {
|
|
501
|
+
return Object.freeze({
|
|
502
|
+
fetch: async (...request) => hono.fetch(...request),
|
|
503
|
+
request: async (...request) => hono.request(...request),
|
|
504
|
+
servedPaths: () => hono.routes.map((route) => route.path),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const hostServeOptionNames = new Set(["headers", "serverTiming"]);
|
|
508
|
+
/**
|
|
509
|
+
* Serve a tenant host's table: the composition root (`oysterRouteTable`)
|
|
510
|
+
* supplies how a request authenticates. An option this function does not
|
|
511
|
+
* know is refused rather than ignored.
|
|
512
|
+
*/
|
|
513
|
+
export function serveHost(table, authenticate, ports, options = {}) {
|
|
514
|
+
for (const name of Reflect.ownKeys(options))
|
|
515
|
+
if (!hostServeOptionNames.has(name))
|
|
516
|
+
throw new Error(`serveHost has no option ${String(name)}`);
|
|
517
|
+
return servedView(serveTable(table, (context) => authenticate(context.req.raw), ports, {
|
|
518
|
+
headers: Object.hasOwn(options, "headers") ? (options.headers ?? []) : [],
|
|
519
|
+
serverTiming: Object.hasOwn(options, "serverTiming")
|
|
520
|
+
? (options.serverTiming ?? null)
|
|
521
|
+
: null,
|
|
522
|
+
mediated: bridged,
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Serve a table of doors and public routes only (a Worker's top-level
|
|
527
|
+
* dispatch, before any tenant is selected): a route that needs an Oyster
|
|
528
|
+
* principal is refused, never admitted.
|
|
529
|
+
*/
|
|
530
|
+
export function serveDoors(table) {
|
|
531
|
+
return servedView(serveTable(table, () => Promise.resolve({ kind: "refused", status: 401 }), { liveKey: () => Promise.resolve(null), appToolPolicy: () => null }, { headers: [], serverTiming: null, mediated: bridged }));
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Serve one capability's declared table under the host dispatcher's
|
|
535
|
+
* admission, read from `c.env`: the App already holds the capability, and
|
|
536
|
+
* each route still decides its own resource. Every request here is
|
|
537
|
+
* App-mediated, however it was admitted (a header or a browser socket's
|
|
538
|
+
* query), so `owner("direct")` always refuses inside a capability.
|
|
539
|
+
*/
|
|
540
|
+
export function serveCapability(table, ports) {
|
|
541
|
+
return servedView(serveTable(table, (context) => Promise.resolve({ kind: "admitted", principal: context.env.principal }), ports, { headers: [], serverTiming: null, mediated: () => true }));
|
|
542
|
+
}
|