@fungi.computer/oyster 0.4.1 → 0.6.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 +46 -8
- package/dist/agents.d.ts +2 -1
- package/dist/agents.js +67 -8
- package/dist/apps/gate.d.ts +4 -0
- package/dist/apps/gate.js +3 -0
- package/dist/auth.d.ts +24 -1
- package/dist/auth.js +38 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +27 -5
- package/dist/channels/binding.d.ts +70 -0
- package/dist/channels/binding.js +97 -0
- package/dist/channels/conversations.d.ts +246 -0
- package/dist/channels/conversations.js +705 -0
- package/dist/channels/ids.d.ts +8 -0
- package/dist/channels/ids.js +15 -0
- package/dist/channels/visitor-channel.d.ts +53 -0
- package/dist/channels/visitor-channel.js +124 -0
- package/dist/channels/visitor-edge.d.ts +115 -0
- package/dist/channels/visitor-edge.js +582 -0
- package/dist/channels/visitor-request.d.ts +7 -0
- package/dist/channels/visitor-request.js +17 -0
- package/dist/channels/visitor-stream-slots.d.ts +2 -0
- package/dist/channels/visitor-stream-slots.js +33 -0
- package/dist/channels/visitors.d.ts +47 -0
- package/dist/channels/visitors.js +181 -0
- package/dist/cloudflare.d.ts +8 -2
- package/dist/cloudflare.js +27 -1
- package/dist/computer-target.d.ts +5 -0
- package/dist/computer-target.js +11 -0
- package/dist/host.d.ts +7 -4
- package/dist/host.js +96 -17
- package/dist/internal/host-gate.d.ts +6 -4
- package/dist/internal/host-gate.js +14 -7
- package/dist/invocation.d.ts +11 -4
- package/dist/invocation.js +134 -91
- package/dist/keys.d.ts +4 -1
- package/dist/keys.js +15 -4
- package/dist/machine-link/authority.d.ts +7 -0
- package/dist/machine-link/delivery.d.ts +12 -0
- package/dist/machine-link/delivery.js +22 -1
- package/dist/machine-link/disposition.d.ts +16 -0
- package/dist/machine-link/disposition.js +65 -0
- package/dist/machine-link/host.d.ts +11 -2
- package/dist/machine-link/host.js +125 -5
- package/dist/machine-link/link.d.ts +4 -0
- package/dist/machine-link/link.js +47 -1
- package/dist/page/capabilities.js +1 -1
- package/dist/page/child-selection.d.ts +27 -0
- package/dist/page/child-selection.js +38 -0
- package/dist/page/mount.js +1 -1
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +13 -0
- package/dist/release.js +10 -0
- package/dist/request-origin.d.ts +4 -0
- package/dist/request-origin.js +10 -0
- package/dist/standalone/agent-mail.js +4 -1
- package/dist/standalone/agents.d.ts +8 -0
- package/dist/standalone/agents.js +30 -11
- package/dist/standalone/channel-session-door.d.ts +4 -0
- package/dist/standalone/channel-session-door.js +72 -0
- package/dist/standalone/computer-fleet.js +7 -1
- package/dist/standalone/machine-link.d.ts +2 -0
- package/dist/standalone/machine-link.js +34 -1
- package/dist/standalone/tenancy.d.ts +3 -0
- package/dist/standalone/tenancy.js +7 -2
- package/dist/testing/capabilities.d.ts +2 -0
- package/dist/testing/capabilities.js +3 -1
- package/dist/turnstile.d.ts +27 -0
- package/dist/turnstile.js +51 -0
- package/dist/workers-git.js +21 -2
- package/dist/workers.d.ts +3 -0
- package/dist/workers.js +15 -12
- package/docs/api/agents.md +8 -4
- package/docs/api/apps.md +8 -4
- package/docs/api/auth.md +55 -0
- package/docs/api/browser.md +40 -16
- package/docs/api/build-contract.md +2 -2
- package/docs/api/cloudflare.md +26 -1
- package/docs/api/host.md +22 -13
- package/docs/api/invocation.md +7 -3
- package/docs/api/keys.md +25 -3
- package/docs/api/workers.md +38 -30
- package/docs/site/agent-quickstart.mdx +29 -2
- package/examples/standalone/wrangler.celld.jsonc +66 -0
- package/examples/standalone/wrangler.jsonc +3 -4
- package/package.json +53 -17
package/README.md
CHANGED
|
@@ -62,6 +62,10 @@ Workers AI, Workflows and media.
|
|
|
62
62
|
|
|
63
63
|
## Quick start: deploy your own Oyster
|
|
64
64
|
|
|
65
|
+
Deciding what to run?
|
|
66
|
+
[Is Oyster for you?](https://docs.fungi.computer/is-oyster-for-you/) compares
|
|
67
|
+
agent Computers and App builds with Cloudflare Workers Builds.
|
|
68
|
+
|
|
65
69
|
You need:
|
|
66
70
|
|
|
67
71
|
- a Cloudflare account on Workers Paid, with Containers, Artifacts, Workers AI,
|
|
@@ -69,11 +73,12 @@ You need:
|
|
|
69
73
|
- Docker, which Wrangler uses to build the Computer's container image;
|
|
70
74
|
- Node 24, pnpm 11 or newer and Git.
|
|
71
75
|
|
|
72
|
-
The first install takes about 15 to 20 minutes,
|
|
73
|
-
on the Computer,
|
|
74
|
-
and Wrangler, and copy the example Worker out of the
|
|
75
|
-
policy allows esbuild and workerd to install their
|
|
76
|
-
unneeded optional dependency scripts, so a fresh
|
|
76
|
+
The first install takes about 15 to 20 minutes, mostly building the seven Apps
|
|
77
|
+
on the Computer. By default, Oyster builds two Apps at a time. Start a project,
|
|
78
|
+
add Oyster, its Computer and Wrangler, and copy the example Worker out of the
|
|
79
|
+
package. The local pnpm policy allows esbuild and workerd to install their
|
|
80
|
+
native binaries and disables unneeded optional dependency scripts, so a fresh
|
|
81
|
+
install exits successfully:
|
|
77
82
|
|
|
78
83
|
```sh
|
|
79
84
|
mkdir my-oyster && cd my-oyster
|
|
@@ -462,10 +467,43 @@ the Computer. `examples/standalone/wrangler.jsonc` declares every binding it
|
|
|
462
467
|
uses. Set the owner key as a secret (`OYSTER_OWNER_KEY`, 24+ characters) and
|
|
463
468
|
deploy with `wrangler deploy`. Sign in at `/login`.
|
|
464
469
|
|
|
470
|
+
Hosts may supply the optional `OYSTER_KEY_SESSIONS` binding, implementing
|
|
471
|
+
`OysterKeySessionResolver` from `@fungi.computer/oyster/auth`. Its
|
|
472
|
+
`resolve(value)` returns `{ keyId }` for a live host-owned session, or `null`.
|
|
473
|
+
The host mints and expires opaque sessions and sets the `keySessionCookieName`
|
|
474
|
+
cookie; it is not a named key's bearer secret. Hosts should use `Path=/`,
|
|
475
|
+
`HttpOnly`, `Secure` and `SameSite=Lax` (or stricter) as defense in depth.
|
|
476
|
+
Oyster independently checks request provenance. Cookie-only GET/HEAD navigation
|
|
477
|
+
is allowed. Other methods require `Origin` to be present and equal the request
|
|
478
|
+
URL's origin, and refuse `Sec-Fetch-Site: cross-site`. Missing `Origin` is
|
|
479
|
+
refused, using the same strict predicate as the channel's POST routes. Oyster
|
|
480
|
+
stores no browser sessions. `keySessionAuth` wraps ordinary `ownerOrKeyAuth` at
|
|
481
|
+
the standalone tenancy composition root. Each cookie admission reopens the live
|
|
482
|
+
key and checks its canonical owner; every authenticated door still uses that
|
|
483
|
+
key's current grants. App pages refuse keys without App access, even when the
|
|
484
|
+
App is public to visitors. Explicit header authentication is unchanged, and a
|
|
485
|
+
failed Authorization header cannot fall back to a named-key cookie. The owner
|
|
486
|
+
cookie and owner-only management API are unchanged; without the binding
|
|
487
|
+
named-key cookies admit nobody.
|
|
488
|
+
|
|
489
|
+
The celld example is a limited trial, not the full Cloudflare setup above. It
|
|
490
|
+
requires access to Fungi's private celld fork and Botanical source repository.
|
|
491
|
+
Use `examples/standalone/wrangler.celld.jsonc` beside `worker.ts`, not the
|
|
492
|
+
Cloudflare-only config. The published `0.5.0` tarball does not yet contain this
|
|
493
|
+
celld example; copy it from your authorized Botanical source checkout until a
|
|
494
|
+
package including it is released.
|
|
495
|
+
|
|
465
496
|
The layers it is built from stay exported for hosts that compose their own:
|
|
466
|
-
`@fungi.computer/oyster/
|
|
467
|
-
|
|
468
|
-
|
|
497
|
+
`@fungi.computer/oyster/turnstile` owns Cloudflare challenge configuration and
|
|
498
|
+
token verification. Hosts supply their origin, verification timeout and safe
|
|
499
|
+
error reporter; they retain their own challenged routes and HTTP responses.
|
|
500
|
+
Configuration keeps the explicit `TURNSTILE=off` opt-out and fails closed on
|
|
501
|
+
HTTPS hosts without both keys. Hub retains its five-second verification policy.
|
|
502
|
+
|
|
503
|
+
Other host building blocks: `@fungi.computer/oyster/workers` (Releases,
|
|
504
|
+
Instances, MCP and App storage) and `@fungi.computer/oyster/workers-git` (the
|
|
505
|
+
git door), with `oysterAppMethods` supplying the installed App catalog to an
|
|
506
|
+
Agent runtime.
|
|
469
507
|
|
|
470
508
|
Pack and install an App with `@fungi.computer/oyster/pack`: `packRelease` turns
|
|
471
509
|
an exact Git commit and a canonical build recipe into a Release with immutable
|
package/dist/agents.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* transport (`/browser/…`), served by the adapter behind the same App grants.
|
|
8
8
|
*/
|
|
9
9
|
import { Hono } from "hono";
|
|
10
|
+
import { type LiveOysterKey } from "./keys.js";
|
|
10
11
|
import type { AppToolPolicy } from "./apps/tools.js";
|
|
11
12
|
import * as Schema from "effect/Schema";
|
|
12
13
|
import { type OysterCapability, type OysterEnv, type OysterPrincipal } from "./host.js";
|
|
@@ -125,7 +126,7 @@ export type OysterAgents = Readonly<{
|
|
|
125
126
|
visit(agentId: string, sessionId: string): Promise<boolean>;
|
|
126
127
|
}>;
|
|
127
128
|
/** Agent adapters admitted against each App's declared capabilities. */
|
|
128
|
-
export declare function agentCapabilities(agents: OysterAgents): readonly OysterCapability[];
|
|
129
|
+
export declare function agentCapabilities(agents: OysterAgents, liveKey: (keyId: string) => Promise<LiveOysterKey | null>): readonly OysterCapability[];
|
|
129
130
|
/** Host-owned direct approval under the admitted owner; never an App capability. */
|
|
130
131
|
export declare function agentApprovalRoutes(agents: OysterAgents): Hono<OysterEnv>;
|
|
131
132
|
/**
|
package/dist/agents.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* transport (`/browser/…`), served by the adapter behind the same App grants.
|
|
8
8
|
*/
|
|
9
9
|
import { Hono } from "hono";
|
|
10
|
+
import { keyAgentAccess } from "./keys.js";
|
|
10
11
|
import * as Schema from "effect/Schema";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import { oysterPrincipalIsOwner, } from "./host.js";
|
|
13
14
|
import { agentAvatarSchema } from "./agent-avatars.js";
|
|
15
|
+
import { computerTargetParam } from "./computer-target.js";
|
|
14
16
|
import { agentAvatar, agentList, agentManage, agentRename, agentSessionRead, agentSessionWrite, agentToolPolicy, } from "@fungi.computer/agar/agents";
|
|
15
17
|
/** At most this many MCP servers per Agent. */
|
|
16
18
|
export const maxAgentMcpServers = 16;
|
|
@@ -51,8 +53,8 @@ function refusal(status, error) {
|
|
|
51
53
|
return Response.json({ error }, { status });
|
|
52
54
|
}
|
|
53
55
|
/** Agent adapters admitted against each App's declared capabilities. */
|
|
54
|
-
export function agentCapabilities(agents) {
|
|
55
|
-
const routes = createAgentRoutes(agents);
|
|
56
|
+
export function agentCapabilities(agents, liveKey) {
|
|
57
|
+
const routes = createAgentRoutes(agents, liveKey);
|
|
56
58
|
const shared = { base: "/", routes };
|
|
57
59
|
return [
|
|
58
60
|
{
|
|
@@ -134,9 +136,41 @@ export function agentCapabilities(agents) {
|
|
|
134
136
|
},
|
|
135
137
|
];
|
|
136
138
|
}
|
|
137
|
-
function createAgentRoutes(agents) {
|
|
139
|
+
function createAgentRoutes(agents, liveKey) {
|
|
138
140
|
const routes = new Hono();
|
|
139
|
-
|
|
141
|
+
// Every Agent and Session lookup shares this live principal-scoped door.
|
|
142
|
+
const access = async (principal, agentId) => {
|
|
143
|
+
if (oysterPrincipalIsOwner(principal))
|
|
144
|
+
return true;
|
|
145
|
+
if (principal.actor.kind !== "team-key")
|
|
146
|
+
return false;
|
|
147
|
+
const key = await liveKey(principal.actor.keyId);
|
|
148
|
+
return key !== null && keyAgentAccess(key, agentId);
|
|
149
|
+
};
|
|
150
|
+
routes.use("/agents/:agentId/*", async (context, next) => {
|
|
151
|
+
if (!(await access(context.env.principal, context.req.param("agentId"))))
|
|
152
|
+
return refusal(403, "Agent access denied");
|
|
153
|
+
await next();
|
|
154
|
+
return undefined;
|
|
155
|
+
});
|
|
156
|
+
routes.use("/agent-policies/:agentId", async (context, next) => {
|
|
157
|
+
if (!(await access(context.env.principal, context.req.param("agentId"))))
|
|
158
|
+
return refusal(403, "Agent access denied");
|
|
159
|
+
await next();
|
|
160
|
+
return undefined;
|
|
161
|
+
});
|
|
162
|
+
routes.get("/agents", async (context) => {
|
|
163
|
+
const listed = await agents.list();
|
|
164
|
+
const actor = context.env.principal.actor;
|
|
165
|
+
if (oysterPrincipalIsOwner(context.env.principal))
|
|
166
|
+
return Response.json(listed);
|
|
167
|
+
if (actor.kind !== "team-key")
|
|
168
|
+
return Response.json([]);
|
|
169
|
+
const key = await liveKey(actor.keyId);
|
|
170
|
+
return Response.json(key === null
|
|
171
|
+
? []
|
|
172
|
+
: listed.filter((agent) => keyAgentAccess(key, agent.agentId)));
|
|
173
|
+
});
|
|
140
174
|
routes.post("/agents", async (context) => {
|
|
141
175
|
const { principal } = context.env;
|
|
142
176
|
if (!oysterPrincipalIsOwner(principal))
|
|
@@ -213,22 +247,43 @@ function createAgentRoutes(agents) {
|
|
|
213
247
|
if (length > maxAgentRequestBytes)
|
|
214
248
|
return refusal(413, "Request too large");
|
|
215
249
|
const target = new URL(`https://agent.internal${path}${url.search}`);
|
|
250
|
+
// A desk window's Computer routing hint is the host's to admit; the
|
|
251
|
+
// Agent's Session routes decode a closed query that refuses it.
|
|
252
|
+
target.searchParams.delete(computerTargetParam);
|
|
216
253
|
const response = await agents.fetch(agentId, new Request(target, incoming));
|
|
217
254
|
return response ?? refusal(404, "No such agent");
|
|
218
255
|
});
|
|
219
|
-
routes.get("/member/inbox", async () => {
|
|
256
|
+
routes.get("/member/inbox", async (context) => {
|
|
220
257
|
const inbox = await agents.inbox();
|
|
258
|
+
const principal = context.env.principal;
|
|
259
|
+
if (oysterPrincipalIsOwner(principal))
|
|
260
|
+
return Response.json({
|
|
261
|
+
items: inbox.items,
|
|
262
|
+
next: null,
|
|
263
|
+
cursor: inbox.revision,
|
|
264
|
+
});
|
|
265
|
+
if (principal.actor.kind !== "team-key")
|
|
266
|
+
return refusal(403, "Agent access denied");
|
|
267
|
+
const key = await liveKey(principal.actor.keyId);
|
|
221
268
|
return Response.json({
|
|
222
|
-
items:
|
|
269
|
+
items: key === null
|
|
270
|
+
? []
|
|
271
|
+
: inbox.items.filter((item) => "agentId" in item && keyAgentAccess(key, item.agentId)),
|
|
223
272
|
next: null,
|
|
224
|
-
|
|
273
|
+
// The global revision changes for ungranted Agents too. Key feeds have
|
|
274
|
+
// no scoped event stream, so expose neither that revision nor its events.
|
|
275
|
+
cursor: 0,
|
|
225
276
|
});
|
|
226
277
|
});
|
|
227
|
-
routes.get("/member/inbox/notices", () =>
|
|
278
|
+
routes.get("/member/inbox/notices", (context) => oysterPrincipalIsOwner(context.env.principal)
|
|
279
|
+
? Response.json({ items: [], next: null })
|
|
280
|
+
: refusal(403, "Agent access denied"));
|
|
228
281
|
routes.post("/member/inbox/visits", async (context) => {
|
|
229
282
|
const body = Schema.decodeUnknownOption(visitSchema)(await context.req.json());
|
|
230
283
|
if (body._tag === "None")
|
|
231
284
|
return refusal(400, "Invalid visit");
|
|
285
|
+
if (!(await access(context.env.principal, body.value.agentId)))
|
|
286
|
+
return refusal(403, "Agent access denied");
|
|
232
287
|
return (await agents.visit(body.value.agentId, body.value.sessionId))
|
|
233
288
|
? new Response(null, { status: 204 })
|
|
234
289
|
: refusal(404, "No such Session");
|
|
@@ -236,6 +291,10 @@ function createAgentRoutes(agents) {
|
|
|
236
291
|
// The feed is small (one person's agents), so a change is announced as a
|
|
237
292
|
// `reset` and the client re-reads the whole inbox.
|
|
238
293
|
routes.get("/member/inbox/events", (context) => {
|
|
294
|
+
// The unscoped revision also changes for other Agents. Do not expose it
|
|
295
|
+
// until a principal-scoped revision exists.
|
|
296
|
+
if (!oysterPrincipalIsOwner(context.env.principal))
|
|
297
|
+
return refusal(403, "Agent access denied");
|
|
239
298
|
const after = Number(context.req.query("after") ?? "0");
|
|
240
299
|
const signal = context.req.raw.signal;
|
|
241
300
|
const encoder = new TextEncoder();
|
package/dist/apps/gate.d.ts
CHANGED
|
@@ -36,6 +36,10 @@ export declare const gateRequestSchema: z.ZodLazy<z.ZodDiscriminatedUnion<[z.Zod
|
|
|
36
36
|
kind: z.ZodLiteral<"capability-route">;
|
|
37
37
|
capability: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"approvals" | "agent.list" | "agent.manage" | "agent.rename" | "agent.avatar" | "agent.toolPolicy" | "agent.read" | "agent.send" | "computer.list" | "computer.readFile" | "computer.writeFile" | "computer.execute" | "llm.generation" | "media.generation" | "games:servers" | "git.repository" | "apps.open" | "apps.call" | "app.storage" | "app.events.emit" | "team.workbenchHost" | "marketplace.browse" | "marketplace.purchase" | "workflow.run" | "workflow.publish" | "workflow.arm" | "world" | "channels.read" | "channels.personal.manage", unknown>>;
|
|
38
38
|
operation: z.ZodString;
|
|
39
|
+
effect: z.ZodEnum<{
|
|
40
|
+
read: "read";
|
|
41
|
+
write: "write";
|
|
42
|
+
}>;
|
|
39
43
|
}, z.core.$strict>, z.ZodObject<{
|
|
40
44
|
kind: z.ZodLiteral<"binding">;
|
|
41
45
|
binding: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"BACKGROUND" | "GIT" | "EVENTS" | "APPS" | "WORKFLOWS" | "APP_STORAGE" | "MEDIA" | "COMPUTER" | "INBOX" | "AGENTS", unknown>>;
|
package/dist/apps/gate.js
CHANGED
|
@@ -26,6 +26,7 @@ export const gateRequestSchema = z.lazy(() => z.discriminatedUnion("kind", [
|
|
|
26
26
|
kind: z.literal("capability-route"),
|
|
27
27
|
capability: ownerCodec(appCapabilityIdSchema),
|
|
28
28
|
operation: z.string().min(1),
|
|
29
|
+
effect: z.enum(["read", "write"]),
|
|
29
30
|
}),
|
|
30
31
|
z.strictObject({
|
|
31
32
|
kind: z.literal("binding"),
|
|
@@ -259,6 +260,8 @@ export function admits(facts, rawRequest) {
|
|
|
259
260
|
case "capability-route": {
|
|
260
261
|
if (visitor)
|
|
261
262
|
return refusal("visitor_read_only");
|
|
263
|
+
if (request.effect === "write" && facts.access?.level === "read")
|
|
264
|
+
return refusal("level_too_low");
|
|
262
265
|
if (!request.operation.startsWith("route:") ||
|
|
263
266
|
!facts.declaration.capabilities.includes(request.capability) ||
|
|
264
267
|
facts.declaredOperations.capabilities
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import type { OysterAuth } from "./host.js";
|
|
1
|
+
import type { OysterAuth, OysterHost } from "./host.js";
|
|
2
2
|
import type { OysterKeyStore } from "./keys.js";
|
|
3
3
|
/** Cookie emitted by ownerKeyAuth, for composing credential-specific adapters. */
|
|
4
4
|
export declare const ownerKeyCookieName = "oyster_session";
|
|
5
|
+
/** Host-minted opaque sessions for named keys, separate from the owner cookie. */
|
|
6
|
+
export declare const keySessionCookieName = "oyster_key_session";
|
|
7
|
+
/**
|
|
8
|
+
* The host owns session minting, hashing/storage and expiry.
|
|
9
|
+
* A session resolves only to a key id, never to owner or grant authority.
|
|
10
|
+
* Hosts should use Path=/, HttpOnly, Secure and SameSite=Lax (or stricter)
|
|
11
|
+
* as defense in depth; Oyster independently checks request provenance.
|
|
12
|
+
* Cookie-only admission allows GET/HEAD navigation. Every other method requires
|
|
13
|
+
* Origin to be present and equal the request URL's origin, and Sec-Fetch-Site
|
|
14
|
+
* not to be cross-site. Missing Origin is refused.
|
|
15
|
+
*/
|
|
16
|
+
export type OysterKeySessionResolver = Readonly<{
|
|
17
|
+
/** Resolve an opaque host session to its named key, or null when unavailable. */
|
|
18
|
+
resolve: (value: string) => Promise<Readonly<{
|
|
19
|
+
keyId: string;
|
|
20
|
+
}> | null>;
|
|
21
|
+
}>;
|
|
5
22
|
/**
|
|
6
23
|
* Single-owner sign-in: a bearer token or a session cookie holding the
|
|
7
24
|
* owner key's digest. The key itself is never stored in the browser.
|
|
@@ -9,3 +26,9 @@ export declare const ownerKeyCookieName = "oyster_session";
|
|
|
9
26
|
export declare function ownerKeyAuth(ownerKey: string, ownerId?: string): OysterAuth;
|
|
10
27
|
/** The owner key, or a named key admitted as its canonical Team-key actor. */
|
|
11
28
|
export declare function ownerOrKeyAuth(ownerKey: string, ownerId: string, keys: () => Pick<OysterKeyStore, "verify">): OysterAuth;
|
|
29
|
+
/**
|
|
30
|
+
* Add host-owned browser sessions to ownerOrKeyAuth. Explicit credentials and
|
|
31
|
+
* non-401 refusals keep their ordinary result; ambient cookies cannot undo them.
|
|
32
|
+
* Every cookie admission rechecks the key's liveness and canonical owner.
|
|
33
|
+
*/
|
|
34
|
+
export declare function keySessionAuth(auth: OysterAuth, ownerId: string, keys: () => NonNullable<OysterHost["keys"]>, sessions: OysterKeySessionResolver): OysterAuth;
|
package/dist/auth.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import * as Option from "effect/Option";
|
|
2
2
|
import * as Schema from "effect/Schema";
|
|
3
|
+
import { parse } from "hono/utils/cookie";
|
|
3
4
|
import { presentedGitKey } from "./git-auth.js";
|
|
4
5
|
import { sha256 } from "./release.js";
|
|
6
|
+
import { sameOriginRequest } from "./request-origin.js";
|
|
5
7
|
/** Cookie emitted by ownerKeyAuth, for composing credential-specific adapters. */
|
|
6
8
|
export const ownerKeyCookieName = "oyster_session";
|
|
9
|
+
/** Host-minted opaque sessions for named keys, separate from the owner cookie. */
|
|
10
|
+
export const keySessionCookieName = "oyster_key_session";
|
|
7
11
|
const loginFormSchema = Schema.Struct({ key: Schema.String });
|
|
8
12
|
/** Compare two secrets through their fixed-length digests, in constant time. */
|
|
9
13
|
async function constantTimeEqual(a, b) {
|
|
@@ -91,3 +95,37 @@ export function ownerOrKeyAuth(ownerKey, ownerId, keys) {
|
|
|
91
95
|
Promise.resolve(new Response("Sign-in is not available", { status: 404 })),
|
|
92
96
|
};
|
|
93
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Add host-owned browser sessions to ownerOrKeyAuth. Explicit credentials and
|
|
100
|
+
* non-401 refusals keep their ordinary result; ambient cookies cannot undo them.
|
|
101
|
+
* Every cookie admission rechecks the key's liveness and canonical owner.
|
|
102
|
+
*/
|
|
103
|
+
export function keySessionAuth(auth, ownerId, keys, sessions) {
|
|
104
|
+
return {
|
|
105
|
+
...auth,
|
|
106
|
+
async authenticate(request) {
|
|
107
|
+
const result = await auth.authenticate(request);
|
|
108
|
+
if (result.kind === "admitted" ||
|
|
109
|
+
result.status !== 401 ||
|
|
110
|
+
request.headers.has("Authorization"))
|
|
111
|
+
return result;
|
|
112
|
+
if (request.method !== "GET" &&
|
|
113
|
+
request.method !== "HEAD" &&
|
|
114
|
+
!sameOriginRequest(request))
|
|
115
|
+
return result;
|
|
116
|
+
const value = parse(request.headers.get("Cookie") ?? "", keySessionCookieName)[keySessionCookieName];
|
|
117
|
+
if (value === undefined || value.length === 0)
|
|
118
|
+
return result;
|
|
119
|
+
const session = await sessions.resolve(value);
|
|
120
|
+
if (session === null)
|
|
121
|
+
return result;
|
|
122
|
+
const key = await keys().live(session.keyId);
|
|
123
|
+
return key !== null && key.owner === ownerId
|
|
124
|
+
? {
|
|
125
|
+
kind: "admitted",
|
|
126
|
+
principal: { actor: { kind: "team-key", keyId: key.keyId } },
|
|
127
|
+
}
|
|
128
|
+
: result;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|