@fungi.computer/oyster 0.5.0 → 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.
Files changed (57) hide show
  1. package/README.md +46 -8
  2. package/dist/agents.js +4 -0
  3. package/dist/auth.d.ts +24 -1
  4. package/dist/auth.js +38 -0
  5. package/dist/boot-host-script.js +1 -1
  6. package/dist/build/candidate-cli.mjs +8 -1
  7. package/dist/channels/binding.d.ts +8 -1
  8. package/dist/channels/binding.js +12 -0
  9. package/dist/channels/conversations.d.ts +16 -5
  10. package/dist/channels/visitor-channel.d.ts +53 -0
  11. package/dist/channels/visitor-channel.js +124 -0
  12. package/dist/channels/visitor-edge.d.ts +115 -0
  13. package/dist/channels/visitor-edge.js +582 -0
  14. package/dist/channels/visitor-request.d.ts +7 -0
  15. package/dist/channels/visitor-request.js +17 -0
  16. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  17. package/dist/channels/visitor-stream-slots.js +33 -0
  18. package/dist/channels/visitors.d.ts +47 -0
  19. package/dist/channels/visitors.js +181 -0
  20. package/dist/cloudflare.d.ts +8 -2
  21. package/dist/cloudflare.js +27 -1
  22. package/dist/computer-target.d.ts +5 -0
  23. package/dist/computer-target.js +11 -0
  24. package/dist/host.d.ts +6 -3
  25. package/dist/host.js +75 -13
  26. package/dist/internal/host-gate.d.ts +6 -4
  27. package/dist/internal/host-gate.js +14 -7
  28. package/dist/invocation.d.ts +11 -4
  29. package/dist/invocation.js +134 -91
  30. package/dist/page/capabilities.js +1 -1
  31. package/dist/page/child-selection.d.ts +27 -0
  32. package/dist/page/child-selection.js +38 -0
  33. package/dist/page/mount.js +1 -1
  34. package/dist/page-script.js +4 -4
  35. package/dist/release.d.ts +13 -0
  36. package/dist/release.js +10 -0
  37. package/dist/request-origin.d.ts +4 -0
  38. package/dist/request-origin.js +10 -0
  39. package/dist/standalone/agents.js +11 -11
  40. package/dist/standalone/channel-session-door.d.ts +2 -2
  41. package/dist/standalone/channel-session-door.js +35 -5
  42. package/dist/standalone/computer-fleet.js +7 -1
  43. package/dist/standalone/tenancy.d.ts +3 -0
  44. package/dist/standalone/tenancy.js +7 -2
  45. package/dist/turnstile.d.ts +27 -0
  46. package/dist/turnstile.js +51 -0
  47. package/dist/workers-git.js +20 -1
  48. package/dist/workers.js +15 -12
  49. package/docs/api/auth.md +55 -0
  50. package/docs/api/build-contract.md +2 -2
  51. package/docs/api/cloudflare.md +26 -1
  52. package/docs/api/host.md +22 -13
  53. package/docs/api/invocation.md +7 -3
  54. package/docs/api/workers.md +17 -16
  55. package/docs/site/agent-quickstart.mdx +6 -2
  56. package/examples/standalone/wrangler.celld.jsonc +66 -0
  57. package/package.json +40 -16
package/dist/release.d.ts CHANGED
@@ -60,6 +60,19 @@ export type VerifiedRelease = Readonly<{
60
60
  export declare function sha256(bytes: Uint8Array): Promise<string>;
61
61
  /** Read one artifact and refuse it unless its bytes match `digest`. */
62
62
  export declare function readVerified(store: ArtifactStore, artifactRef: ArtifactRef, digest: Sha256Digest): Promise<Uint8Array>;
63
+ /**
64
+ * Copies of verified artifacts near the reader, keyed by content digest. An
65
+ * entry holds only bytes that already matched its digest, so a hit is served
66
+ * without another store read or hash.
67
+ */
68
+ export type VerifiedObjectCache = Readonly<{
69
+ /** The cached bytes for `digest`, or null on a miss. */
70
+ match: (digest: Sha256Digest) => Promise<ReadableStream<Uint8Array> | null>;
71
+ /** Keep verified bytes; an unstored write only costs the next reader a store read. */
72
+ put: (digest: Sha256Digest, bytes: Uint8Array) => Promise<"stored" | "unstored">;
73
+ }>;
74
+ /** {@link readVerified} through an optional digest-keyed cache it fills on a miss. */
75
+ export declare function readCachedVerified(store: ArtifactStore, cache: VerifiedObjectCache | undefined, artifactRef: ArtifactRef, digest: Sha256Digest): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>>;
63
76
  /** Decode an untrusted Release record, as an install request carries it. */
64
77
  export declare function parseRelease(value: unknown): Release;
65
78
  /**
package/dist/release.js CHANGED
@@ -36,6 +36,16 @@ export async function readVerified(store, artifactRef, digest) {
36
36
  throw new ReleaseError("digest_mismatch", `Digest mismatch ${artifactRef}`);
37
37
  return bytes;
38
38
  }
39
+ /** {@link readVerified} through an optional digest-keyed cache it fills on a miss. */
40
+ export async function readCachedVerified(store, cache, artifactRef, digest) {
41
+ const hit = await cache?.match(digest);
42
+ if (hit !== undefined && hit !== null)
43
+ return hit;
44
+ const bytes = await readVerified(store, artifactRef, digest);
45
+ await cache?.put(digest, bytes);
46
+ // A response body owns a plain ArrayBuffer, whatever view the store returned.
47
+ return new Uint8Array(bytes);
48
+ }
39
49
  const decodeText = (bytes) => new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
40
50
  /** Decode an untrusted Release record, as an install request carries it. */
41
51
  export function parseRelease(value) {
@@ -0,0 +1,4 @@
1
+ /** Require the request's own Origin and refuse cross-site provenance. */
2
+ export declare function sameOriginRequest(request: Request): boolean;
3
+ /** Channel reads allow absent Origin but never conflicting or cross-site provenance. */
4
+ export declare function sameOriginReadRequest(request: Request): boolean;
@@ -0,0 +1,10 @@
1
+ /** Require the request's own Origin and refuse cross-site provenance. */
2
+ export function sameOriginRequest(request) {
3
+ return request.headers.has("origin") && sameOriginReadRequest(request);
4
+ }
5
+ /** Channel reads allow absent Origin but never conflicting or cross-site provenance. */
6
+ export function sameOriginReadRequest(request) {
7
+ const origin = request.headers.get("origin");
8
+ return (request.headers.get("sec-fetch-site") !== "cross-site" &&
9
+ (origin === null || origin === new URL(request.url).origin));
10
+ }
@@ -1,5 +1,4 @@
1
- import { normalizeModel, streamModel } from "@fungi.computer/mule/effect";
2
- import { observeProviderStatus } from "@fungi.computer/mule/effect";
1
+ import { isRetryableProviderRejection, normalizeModel, observeProviderStatus, streamModel, } from "@fungi.computer/mule/effect";
3
2
  import { standaloneTenancy, } from "./tenancy.js";
4
3
  import { AcceptedSchema } from "@fungi.computer/shiitake/client";
5
4
  import { createShiitakeClient } from "@fungi.computer/shiitake/client";
@@ -53,10 +52,10 @@ function observedModels(model, defaults) {
53
52
  acquire: ({ signal }) => {
54
53
  const retryableMessages = new WeakSet();
55
54
  const safeFailures = new WeakMap();
56
- const failure = (message, code) => {
55
+ const failure = (message, rejection) => {
57
56
  // Pi can include raw response bodies in errorMessage. No provider
58
57
  // prose enters a Session, retry journal, model context or diagnostics.
59
- const safe = `Model request failed (${code}). Retry the request.`;
58
+ const safe = `Model request failed (${rejection?.code ?? "provider_rejected"}). Retry the request.`;
60
59
  const terminal = {
61
60
  role: "assistant",
62
61
  content: [],
@@ -69,7 +68,7 @@ function observedModels(model, defaults) {
69
68
  timestamp: message.timestamp,
70
69
  };
71
70
  safeFailures.set(terminal, safe);
72
- if (code === "provider_unavailable")
71
+ if (isRetryableProviderRejection(rejection))
73
72
  retryableMessages.add(terminal);
74
73
  return terminal;
75
74
  };
@@ -92,7 +91,7 @@ function observedModels(model, defaults) {
92
91
  });
93
92
  for await (const event of response) {
94
93
  if (event.type === "error") {
95
- const terminal = failure(event.error, observed.rejection()?.code ?? "provider_rejected");
94
+ const terminal = failure(event.error, observed.rejection(event.error));
96
95
  events.push({ ...event, error: terminal });
97
96
  events.end(terminal);
98
97
  return;
@@ -100,7 +99,7 @@ function observedModels(model, defaults) {
100
99
  if (event.type === "done" &&
101
100
  (event.message.stopReason === "error" ||
102
101
  event.message.stopReason === "aborted")) {
103
- const terminal = failure(event.message, observed.rejection()?.code ?? "provider_rejected");
102
+ const terminal = failure(event.message, observed.rejection(event.message));
104
103
  events.push({ ...event, message: terminal });
105
104
  events.end(terminal);
106
105
  return;
@@ -109,7 +108,7 @@ function observedModels(model, defaults) {
109
108
  }
110
109
  const result = await response.result();
111
110
  events.end(result.stopReason === "error" || result.stopReason === "aborted"
112
- ? failure(result, observed.rejection()?.code ?? "provider_rejected")
111
+ ? failure(result, observed.rejection(result))
113
112
  : result);
114
113
  }
115
114
  catch {
@@ -136,7 +135,7 @@ function observedModels(model, defaults) {
136
135
  },
137
136
  stopReason: signal.aborted ? "aborted" : "error",
138
137
  timestamp: Date.now(),
139
- }, "provider_unavailable");
138
+ }, { code: "provider_unavailable" });
140
139
  events.push({
141
140
  type: "error",
142
141
  reason: signal.aborted ? "aborted" : "error",
@@ -236,15 +235,16 @@ export class StandaloneAgentBase extends ShiitakeDO {
236
235
  * this does not install an inbound route or mint provider credentials.
237
236
  */
238
237
  channelConversations(binding) {
239
- return createChannelConversations({
238
+ const owner = createChannelConversations({
240
239
  sql: this.ctx.storage.sql,
241
240
  transactionSync: (body) => this.ctx.storage.transactionSync(body),
242
241
  binding,
243
242
  door: channelSessionDoor(() => this.shiitake(), () => {
244
243
  // Standalone Agents have no Hub Team grants to deliver.
245
- }),
244
+ }, () => owner),
246
245
  now: () => Date.now(),
247
246
  });
247
+ return owner;
248
248
  }
249
249
  /** Each installed Release's verified catalog surface, built once per Release for this Agent's lifetime. */
250
250
  #appSurfaces;
@@ -1,4 +1,4 @@
1
1
  import type { ShiitakeOwnerAgent } from "@fungi.computer/shiitake/cloudflare";
2
- import type { SessionDoor } from "../channels/conversations.js";
2
+ import type { ChannelConversations, SessionDoor } from "../channels/conversations.js";
3
3
  /** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
4
- export declare function channelSessionDoor(open: () => Promise<ShiitakeOwnerAgent>, deliverGrants: (sessionId: string) => void): SessionDoor;
4
+ export declare function channelSessionDoor(open: () => Promise<ShiitakeOwnerAgent>, deliverGrants: (sessionId: string) => void, conversations: () => ChannelConversations): SessionDoor;
@@ -1,15 +1,45 @@
1
1
  import { authorMeta } from "@fungi.computer/agar/agents";
2
2
  import { ShiitakeError } from "@fungi.computer/shiitake";
3
+ import { participantSnapshot, visitorPendingPrompt, visitorSnapshot, } from "@fungi.computer/shiitake/client";
4
+ import { visitorStream } from "@fungi.computer/shiitake/server-visitor-stream";
3
5
  /** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
4
- export function channelSessionDoor(open, deliverGrants) {
6
+ export function channelSessionDoor(open, deliverGrants, conversations) {
7
+ const policy = () => conversations();
5
8
  return {
6
9
  createSession: async (requestId) => (await open()).createSession(requestId),
7
10
  deliverGrants,
8
11
  withdraw: async (sessionId, requestId) => (await open()).session(sessionId).withdraw(requestId),
9
- latestPending: async (sessionId, visitor) => (await open())
10
- .withMeta(authorMeta(visitor))
11
- .session(sessionId)
12
- .latestPending(),
12
+ latestPending: async (sessionId, visitor) => {
13
+ const prompt = await (await open())
14
+ .withMeta(authorMeta(visitor))
15
+ .session(sessionId)
16
+ .latestPending();
17
+ return prompt === null ? null : visitorPendingPrompt(prompt, visitor.id);
18
+ },
19
+ participantDocument: async (sessionId, visitorId, options) => {
20
+ const snapshot = await (await open()).session(sessionId).read(options);
21
+ return JSON.stringify(visitorSnapshot(participantSnapshot(snapshot, policy()), visitorId));
22
+ },
23
+ participantStream: async (sessionId, visitorId, options) => {
24
+ let watch;
25
+ try {
26
+ watch = await (await open()).session(sessionId).watch();
27
+ }
28
+ catch (cause) {
29
+ options.release();
30
+ throw cause;
31
+ }
32
+ return visitorStream({
33
+ ...options,
34
+ watch,
35
+ visitorId,
36
+ filter: (snapshot) => participantSnapshot(snapshot, policy()),
37
+ read: async () => (await open()).session(sessionId).read(),
38
+ onError: (event, cause) => {
39
+ console.warn(event, cause);
40
+ },
41
+ });
42
+ },
13
43
  prompt: async (sessionId, { requestId, text, meta }) => {
14
44
  // The conversation row exists now, so its channel admission is known;
15
45
  // deliver Agent tools before the first run can call one.
@@ -1,8 +1,10 @@
1
1
  import { Hono } from "hono";
2
+ import { endTime, startTime } from "hono/timing";
2
3
  import { HTTPException } from "hono/http-exception";
3
4
  import { z } from "zod";
4
5
  import { computerCreateRequestSchema, computerPageSchema, } from "@fungi.computer/computer/fleet";
5
6
  import { oysterPrincipalIsOwner, } from "../host.js";
7
+ import { computerTargetParam } from "../computer-target.js";
6
8
  /** Fleet controls never accept an actor from the request body. */
7
9
  const fleetComputerId = z.string().min(1).max(256);
8
10
  export const standaloneFleetRequestSchema = z.discriminatedUnion("operation", [
@@ -48,7 +50,7 @@ function fleetResponse(result) {
48
50
  export async function standaloneComputerSelection(registry, incoming) {
49
51
  if (incoming === undefined)
50
52
  return registry.selectedComputer();
51
- const requested = new URL(incoming.request.url).searchParams.getAll("computerId");
53
+ const requested = new URL(incoming.request.url).searchParams.getAll(computerTargetParam);
52
54
  if (requested.length === 0)
53
55
  return registry.selectedComputer();
54
56
  const admission = await incoming.auth.authenticate(incoming.request);
@@ -91,16 +93,20 @@ export function standaloneComputerFleetRoutes(registryFor) {
91
93
  if (!oysterPrincipalIsOwner(principal))
92
94
  return context.json({ error: "Owner only" }, 403);
93
95
  const registry = registryFor();
96
+ startTime(context, "fleet");
94
97
  const admitted = await registry.computerFleet(principal.actor.memberId, {
95
98
  operation: "list",
96
99
  page: { limit: 1, offset: 0 },
97
100
  });
101
+ endTime(context, "fleet");
98
102
  if (admitted === null || "error" in admitted)
99
103
  return fleetResponse(admitted);
104
+ startTime(context, "registry");
100
105
  const [directory, computerId] = await Promise.all([
101
106
  registry.directory(),
102
107
  registry.selectedComputer(),
103
108
  ]);
109
+ endTime(context, "registry");
104
110
  return context.json({
105
111
  ...directory,
106
112
  computer: { computerId, name: computerId },
@@ -1,5 +1,6 @@
1
1
  import { TenancyRegistryDO } from "../tenancy/cloudflare.js";
2
2
  import { type Identity, type Tenant, type ReadonlyTenancy } from "../tenancy/index.js";
3
+ import { type OysterKeySessionResolver } from "../auth.js";
3
4
  import type { OysterGitWorkerEnv } from "../workers-git.js";
4
5
  import { type OysterTenancy, type TenantId } from "../tenant.js";
5
6
  /** Trusted standalone provisioning inputs; credentials never enter the seed. */
@@ -28,6 +29,8 @@ export declare class StandaloneTenants extends TenancyRegistryDO {
28
29
  /** The same trusted bindings are used by HTTP, MCP and cold root readers. */
29
30
  export type StandaloneTenancyEnv = Pick<OysterGitWorkerEnv, "OYSTER_ORIGIN" | "OYSTER_OWNER_NAME" | "OYSTER_OWNER_KEY" | "KEYS"> & Readonly<{
30
31
  TENANTS: DurableObjectNamespace<StandaloneTenants>;
32
+ /** Optional host-owned named-key browser sessions; Oyster mints none. */
33
+ OYSTER_KEY_SESSIONS?: OysterKeySessionResolver;
31
34
  }>;
32
35
  /** A live registry-owned owner; the configured label is never its member ID. */
33
36
  export type StandaloneOwner = Readonly<{
@@ -2,7 +2,7 @@ import { TenancyRegistryDO, admittedRegistry, durableObjectRegistry, } from "../
2
2
  import { createTenancy, } from "../tenancy/index.js";
3
3
  import { ownerKey } from "../tenancy/owner-key.js";
4
4
  import { hosts } from "../tenancy/resolve.js";
5
- import { ownerOrKeyAuth } from "../auth.js";
5
+ import { keySessionAuth, ownerOrKeyAuth, } from "../auth.js";
6
6
  import { HTTPException } from "hono/http-exception";
7
7
  import { defaultTenant, tenantScope, } from "../tenant.js";
8
8
  import * as Schema from "effect/Schema";
@@ -173,7 +173,12 @@ async function composeTenancy(env) {
173
173
  async authenticate(request) {
174
174
  if (owner === null)
175
175
  return { kind: "refused", status: 403 };
176
- const admission = await ownerOrKeyAuth(env.OYSTER_OWNER_KEY, owner.actor.memberId, () => env.KEYS.getByName(scope.durableName("keys"))).authenticate(request);
176
+ const keys = () => env.KEYS.getByName(scope.durableName("keys"));
177
+ const namedKeyAuth = ownerOrKeyAuth(env.OYSTER_OWNER_KEY, owner.actor.memberId, keys);
178
+ const auth = env.OYSTER_KEY_SESSIONS === undefined
179
+ ? namedKeyAuth
180
+ : keySessionAuth(namedKeyAuth, owner.actor.memberId, keys, env.OYSTER_KEY_SESSIONS);
181
+ const admission = await auth.authenticate(request);
177
182
  // This entry admits only named keys; the owner key has its own entry.
178
183
  return admission.kind === "admitted" &&
179
184
  admission.principal.actor.kind === "team-key"
@@ -0,0 +1,27 @@
1
+ /** Host-owned configuration inputs, decoded before any challenge is opened. */
2
+ export type TurnstileEnvironment = Readonly<{
3
+ TURNSTILE?: string;
4
+ TURNSTILE_SITE_KEY?: string;
5
+ TURNSTILE_SECRET_KEY?: string;
6
+ }>;
7
+ /** Explicit opt-out, complete verification credentials, or a closed door. */
8
+ export type TurnstileConfiguration = Readonly<{
9
+ kind: "disabled";
10
+ }> | Readonly<{
11
+ kind: "enabled";
12
+ siteKey: string;
13
+ secretKey: string;
14
+ }> | Readonly<{
15
+ kind: "misconfigured";
16
+ }>;
17
+ /** Retain the development default; HTTPS hosts without keys fail closed. */
18
+ export declare function readTurnstileConfiguration(environment: TurnstileEnvironment, origin: string | undefined): TurnstileConfiguration;
19
+ /** Verify with Cloudflare; timeout policy and safe failure reporting belong to the host. */
20
+ export declare function verifyTurnstileToken(input: {
21
+ secretKey: string;
22
+ token: string;
23
+ remoteIp?: string;
24
+ }, options: {
25
+ timeoutMs: number;
26
+ onError(cause: unknown): void;
27
+ }): Promise<"passed" | "failed" | "unavailable">;
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ const siteverifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
3
+ const environmentSchema = z.object({
4
+ TURNSTILE: z.literal("off").optional(),
5
+ TURNSTILE_SITE_KEY: z.string().trim().min(1).optional(),
6
+ TURNSTILE_SECRET_KEY: z.string().trim().min(1).optional(),
7
+ });
8
+ const responseSchema = z.object({ success: z.boolean() });
9
+ /** Retain the development default; HTTPS hosts without keys fail closed. */
10
+ export function readTurnstileConfiguration(environment, origin) {
11
+ const parsed = environmentSchema.safeParse(environment);
12
+ if (!parsed.success)
13
+ return { kind: "misconfigured" };
14
+ if (parsed.data.TURNSTILE === "off")
15
+ return { kind: "disabled" };
16
+ const { TURNSTILE_SITE_KEY: siteKey, TURNSTILE_SECRET_KEY: secretKey } = parsed.data;
17
+ if (siteKey === undefined && secretKey === undefined)
18
+ return URL.parse(origin ?? "")?.protocol === "https:"
19
+ ? { kind: "misconfigured" }
20
+ : { kind: "disabled" };
21
+ if (siteKey === undefined || secretKey === undefined)
22
+ return { kind: "misconfigured" };
23
+ return { kind: "enabled", siteKey, secretKey };
24
+ }
25
+ /** Verify with Cloudflare; timeout policy and safe failure reporting belong to the host. */
26
+ export async function verifyTurnstileToken(input, options) {
27
+ try {
28
+ const body = {
29
+ secret: input.secretKey,
30
+ response: input.token,
31
+ };
32
+ if (input.remoteIp !== undefined)
33
+ body.remoteip = input.remoteIp;
34
+ const response = await fetch(siteverifyUrl, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify(body),
38
+ signal: AbortSignal.timeout(options.timeoutMs),
39
+ });
40
+ if (!response.ok)
41
+ return "unavailable";
42
+ const verdict = responseSchema.safeParse(await response.json());
43
+ if (!verdict.success)
44
+ return "unavailable";
45
+ return verdict.data.success ? "passed" : "failed";
46
+ }
47
+ catch (cause) {
48
+ options.onError(cause);
49
+ return "unavailable";
50
+ }
51
+ }
@@ -889,6 +889,7 @@ export function createOysterGitWorker(root) {
889
889
  hosts = new Map();
890
890
  environments.set(env, hosts);
891
891
  }
892
+ const started = Date.now();
892
893
  let resolved;
893
894
  try {
894
895
  resolved = await root.admit(env, request);
@@ -907,6 +908,7 @@ export function createOysterGitWorker(root) {
907
908
  const boot = await serveOysterBootCapability(request, env, scope);
908
909
  if (boot !== null)
909
910
  return boot;
911
+ const admitted = Date.now();
910
912
  let host;
911
913
  try {
912
914
  host = await root.hostFor(env, scope, request);
@@ -916,6 +918,7 @@ export function createOysterGitWorker(root) {
916
918
  return error.getResponse();
917
919
  throw error;
918
920
  }
921
+ const hosted = Date.now();
919
922
  const key = `${scope.tenantId}:${scope.origin}:${host.computerId ?? ""}`;
920
923
  let entry = hosts.get(key);
921
924
  let cache = true;
@@ -938,7 +941,10 @@ export function createOysterGitWorker(root) {
938
941
  }
939
942
  entry.active += 1;
940
943
  try {
941
- return await entry.app.fetch(request, env, ctx);
944
+ return withStageTimings(await entry.app.fetch(request, env, ctx), {
945
+ admit: admitted - started,
946
+ host: hosted - admitted,
947
+ });
942
948
  }
943
949
  finally {
944
950
  entry.active -= 1;
@@ -946,6 +952,19 @@ export function createOysterGitWorker(root) {
946
952
  },
947
953
  };
948
954
  }
955
+ /**
956
+ * Add the root's own stages (tenant admission, host selection) to the App
957
+ * server's Server-Timing, so a slow request names the hop it waited on. An
958
+ * upgraded socket keeps its response untouched.
959
+ */
960
+ function withStageTimings(response, stages) {
961
+ if (response.status === 101)
962
+ return response;
963
+ const timed = new Response(response.body, response);
964
+ for (const [name, duration] of Object.entries(stages))
965
+ timed.headers.append("Server-Timing", `${name};dur=${String(duration)}`);
966
+ return timed;
967
+ }
949
968
  /**
950
969
  * `host.git` for an Oyster Worker: the git door's writer projection and the
951
970
  * admitted, installation-scoped Artifacts path with the App's push policy and
package/dist/workers.js CHANGED
@@ -35,10 +35,11 @@ import * as Schema from "effect/Schema";
35
35
  import * as Stream from "effect/Stream";
36
36
  import { releaseSchema } from "@fungi.computer/puffball/effect";
37
37
  import { AppInstanceError, } from "./index.js";
38
- import { createCloudflareAppInstance, r2ArtifactStore, createCloudflareInvocationGate, decodeFrameBindingProps, captureFrameBindingReply, frameBindingEntrypoint, backgroundFrameMethods, } from "./cloudflare.js";
38
+ import { createCloudflareAppInstance, r2ArtifactStore, createCloudflareInvocationGate, edgeVerifiedObjectCache, decodeFrameBindingProps, captureFrameBindingReply, frameBindingEntrypoint, backgroundFrameMethods, } from "./cloudflare.js";
39
39
  import { invocationFactsSchema, assertBackgroundBinding, admitBackgroundFrame, } from "./invocation.js";
40
40
  import { deliverBackground, deliverGitPush } from "./internal/delivery.js";
41
- import { createOyster, verifyRelease, defaultAssetPolicy, } from "./host.js";
41
+ import { releaseCache } from "./internal/host-gate.js";
42
+ import { createOyster, } from "./host.js";
42
43
  import { createGitPushJournal } from "./git-journal.js";
43
44
  import { AppStorageError, } from "@fungi.computer/agar/storage";
44
45
  import { storageFrameMethods } from "./storage-frame.js";
@@ -148,6 +149,7 @@ export class OysterRegistry extends DurableObject {
148
149
  return this.approvalHome(pin).capability(actor).setAutoApprove(input);
149
150
  }
150
151
  #rootData;
152
+ #releases = releaseCache(r2ArtifactStore(this.env.RELEASES));
151
153
  /** Append-only App source seed receipts; a Git-capable subclass forks over these. */
152
154
  appSourceReceipts;
153
155
  /** Team-owned fork registrations, one App publisher record per App. */
@@ -196,7 +198,7 @@ export class OysterRegistry extends DurableObject {
196
198
  installations: {
197
199
  list: () => Promise.resolve(this.list()),
198
200
  },
199
- resolveRelease: (release) => verifyRelease(r2ArtifactStore(env.RELEASES), release, defaultAssetPolicy, false),
201
+ resolveRelease: this.#releases,
200
202
  });
201
203
  }
202
204
  /** The installation owner's RPC: admit, read, load, plan or commit one
@@ -319,7 +321,7 @@ export class OysterRegistry extends DurableObject {
319
321
  expected.selection.releaseId ||
320
322
  installation.release.appId !== expected.selection.appId)))
321
323
  throw new AppStorageError("unavailable");
322
- const resolved = await verifyRelease(r2ArtifactStore(this.env.RELEASES), installation.release, defaultAssetPolicy, false);
324
+ const resolved = await this.#releases(installation.release);
323
325
  if (expected !== undefined &&
324
326
  resolved.backend?.digest !== expected.artifactDigest)
325
327
  throw new AppStorageError("unavailable");
@@ -756,9 +758,9 @@ export class OysterInstance extends DurableObject {
756
758
  }
757
759
  async executeBindingCall(raw, method, input) {
758
760
  const props = decodeFrameBindingProps(raw);
759
- const frame = this.frames.active();
760
- if (props.instanceId !== frame.facts.instanceId ||
761
- props.frameId !== frame.frameId)
761
+ // The packet's own frame: a closed or foreign frame id refuses.
762
+ const frame = this.frames.active(props.frameId);
763
+ if (props.instanceId !== frame.facts.instanceId)
762
764
  throw new AppInstanceError("revoked");
763
765
  assertBackgroundBinding(frame, props.binding);
764
766
  // A guest packet is its own entry: exactly one registry read re-admits it.
@@ -787,7 +789,7 @@ export class OysterInstance extends DurableObject {
787
789
  .every((operation) => granted.includes(operation)))
788
790
  throw new AppInstanceError("binding_not_granted");
789
791
  const result = await row.dispatch(this.#bindingContext, frame, props, method, args);
790
- if (this.frames.active() !== frame)
792
+ if (this.frames.active(props.frameId) !== frame)
791
793
  throw new AppInstanceError("revoked");
792
794
  assertBackgroundBinding(frame, props.binding);
793
795
  return descriptor.decodeResult(result);
@@ -1102,10 +1104,7 @@ export function oysterWorkerHost(env, auth, options = {}) {
1102
1104
  },
1103
1105
  auth,
1104
1106
  workbench: {
1105
- directory: async () => {
1106
- await installations.list();
1107
- return registry().directory();
1108
- },
1107
+ directory: () => registry().directory(),
1109
1108
  storage: (installationId, ownerId) => ({
1110
1109
  read: (key) => registry().storageRead(installationId, ownerId, key),
1111
1110
  write: (input) => registry().storageWrite(installationId, ownerId, input),
@@ -1115,6 +1114,10 @@ export function oysterWorkerHost(env, auth, options = {}) {
1115
1114
  },
1116
1115
  guestLimits: guestLimits(options.guestLimits),
1117
1116
  artifacts,
1117
+ // Workers and workerd keep a zone cache; a runtime without one reads R2.
1118
+ ...("caches" in globalThis && {
1119
+ uiCache: edgeVerifiedObjectCache(caches.default, scope.origin),
1120
+ }),
1118
1121
  bootUi: oysterBootUi(env, scope),
1119
1122
  // File-only hosts may keep workspace files beside their Release artifacts.
1120
1123
  capabilities: [
package/docs/api/auth.md CHANGED
@@ -8,6 +8,26 @@
8
8
 
9
9
  # auth
10
10
 
11
+ ## Type Aliases
12
+
13
+ ### OysterKeySessionResolver
14
+
15
+ ```ts
16
+ type OysterKeySessionResolver = Readonly<{
17
+ resolve: (value) => Promise<Readonly<{
18
+ keyId: string;
19
+ }> | null>;
20
+ }>;
21
+ ```
22
+
23
+ The host owns session minting, hashing/storage and expiry. A session resolves
24
+ only to a key id, never to owner or grant authority. Hosts should use Path=/,
25
+ HttpOnly, Secure and SameSite=Lax (or stricter) as defense in depth; Oyster
26
+ independently checks request provenance. Cookie-only admission allows GET/HEAD
27
+ navigation. Every other method requires Origin to be present and equal the
28
+ request URL's origin, and Sec-Fetch-Site not to be cross-site. Missing Origin is
29
+ refused.
30
+
11
31
  ## Variables
12
32
 
13
33
  ### ownerKeyCookieName
@@ -18,6 +38,16 @@ const ownerKeyCookieName: "oyster_session" = "oyster_session";
18
38
 
19
39
  Cookie emitted by ownerKeyAuth, for composing credential-specific adapters.
20
40
 
41
+ ---
42
+
43
+ ### keySessionCookieName
44
+
45
+ ```ts
46
+ const keySessionCookieName: "oyster_key_session" = "oyster_key_session";
47
+ ```
48
+
49
+ Host-minted opaque sessions for named keys, separate from the owner cookie.
50
+
21
51
  ## Functions
22
52
 
23
53
  ### ownerKeyAuth()
@@ -61,3 +91,28 @@ The owner key, or a named key admitted as its canonical Team-key actor.
61
91
  #### Returns
62
92
 
63
93
  [`OysterAuth`](host.md#oysterauth)
94
+
95
+ ---
96
+
97
+ ### keySessionAuth()
98
+
99
+ ```ts
100
+ function keySessionAuth(auth, ownerId, keys, sessions): OysterAuth;
101
+ ```
102
+
103
+ Add host-owned browser sessions to ownerOrKeyAuth. Explicit credentials and
104
+ non-401 refusals keep their ordinary result; ambient cookies cannot undo them.
105
+ Every cookie admission rechecks the key's liveness and canonical owner.
106
+
107
+ #### Parameters
108
+
109
+ | Parameter | Type |
110
+ | ---------- | --------------------------------------------------------------------- |
111
+ | `auth` | [`OysterAuth`](host.md#oysterauth) |
112
+ | `ownerId` | `string` |
113
+ | `keys` | () => `NonNullable`\<[`OysterHost`](host.md#oysterhost)\[`"keys"`\]\> |
114
+ | `sessions` | [`OysterKeySessionResolver`](#oysterkeysessionresolver) |
115
+
116
+ #### Returns
117
+
118
+ [`OysterAuth`](host.md#oysterauth)
@@ -966,7 +966,7 @@ function putContentObject(
966
966
  object,
967
967
  body,
968
968
  mediaType,
969
- ): Promise<"refused" | "stored">;
969
+ ): Promise<"stored" | "refused">;
970
970
  ```
971
971
 
972
972
  Stream one object into the store under its digest. The store refuses a body
@@ -987,7 +987,7 @@ the body is discarded, never stored.
987
987
 
988
988
  #### Returns
989
989
 
990
- `Promise`\<`"refused"` \| `"stored"`\>
990
+ `Promise`\<`"stored"` \| `"refused"`\>
991
991
 
992
992
  ---
993
993
 
@@ -305,6 +305,30 @@ frame or guestEnv factory is accepted.
305
305
 
306
306
  ---
307
307
 
308
+ ### edgeVerifiedObjectCache()
309
+
310
+ ```ts
311
+ function edgeVerifiedObjectCache(cache, origin): VerifiedObjectCache;
312
+ ```
313
+
314
+ The zone's edge cache as a [VerifiedObjectCache](host.md#verifiedobjectcache):
315
+ each verified object under a digest-named key on `origin` that no route serves.
316
+ A failed write is logged and answers `unstored`; the next reader verifies from
317
+ the store again.
318
+
319
+ #### Parameters
320
+
321
+ | Parameter | Type |
322
+ | --------- | --------------------------------------- |
323
+ | `cache` | `Pick`\<`Cache`, `"match"` \| `"put"`\> |
324
+ | `origin` | `string` |
325
+
326
+ #### Returns
327
+
328
+ [`VerifiedObjectCache`](host.md#verifiedobjectcache)
329
+
330
+ ---
331
+
308
332
  ### r2ArtifactStore()
309
333
 
310
334
  ```ts
@@ -435,7 +459,8 @@ function createCloudflareInvocationGate<Host, FacetClass>(
435
459
  ): InvocationGate<Host>;
436
460
  ```
437
461
 
438
- End the native App facet before shared frame admission releases its permit.
462
+ End the native App facet when live authority closes early, before the gate
463
+ admits again.
439
464
 
440
465
  #### Type Parameters
441
466