@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
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { type ConversationBinding } from "./binding.js";
3
+ export type VisitorSql = Readonly<{
4
+ exec: (statement: string, ...bindings: (ArrayBuffer | string | number | null)[]) => Readonly<{
5
+ toArray: () => readonly unknown[];
6
+ }>;
7
+ }>;
8
+ /** Credentials handed to one anonymous visitor's iframe. */
9
+ export type VisitorCredentials = Readonly<{
10
+ visitorId: string;
11
+ accessToken: string;
12
+ accessExpiresAt: number;
13
+ refreshSecret: string;
14
+ }>;
15
+ /** Closed mint/refresh result. */
16
+ export type VisitorGrant = Readonly<{
17
+ kind: "granted";
18
+ credentials: VisitorCredentials;
19
+ }> | Readonly<{
20
+ kind: "denied";
21
+ reason: "unmapped" | "suspended" | "new_party_limit" | "address_limit" | "refresh_invalid" | "refresh_reused";
22
+ }>;
23
+ /** Bearer grammar: `vt_` or `vr_` plus 43 base64url characters. */
24
+ export declare const visitorBearerSchema: z.ZodString;
25
+ /**
26
+ * Anonymous visitor identity for web bindings. A visitor principal
27
+ * names exactly one binding and one visitor, so it can reach exactly that
28
+ * visitor's one conversation Session and nothing else. Only token hashes are
29
+ * stored. Reusing a spent refresh secret revokes the whole family.
30
+ */
31
+ export declare function createChannelVisitors(options: Readonly<{
32
+ sql: VisitorSql;
33
+ transactionSync: <T>(body: () => T) => T;
34
+ now: () => number;
35
+ }>): {
36
+ ensure: () => void;
37
+ mint: (binding: ConversationBinding | null, siteKey: string, addressHash: string) => Promise<VisitorGrant>;
38
+ refresh: (binding: ConversationBinding | null, refreshSecret: string) => Promise<VisitorGrant>;
39
+ authenticate: (bindingId: string, accessToken: string) => Promise<Readonly<{
40
+ visitorId: string;
41
+ expiresAt: number;
42
+ addressHash: string;
43
+ }> | null>;
44
+ forget: (visitorId: string) => void;
45
+ };
46
+ /** Anonymous visitor identity owner. */
47
+ export type ChannelVisitors = ReturnType<typeof createChannelVisitors>;
@@ -0,0 +1,181 @@
1
+ import { z } from "zod";
2
+ import { DEFAULT_BINDING_LIMITS } from "./binding.js";
3
+ import { channelIds } from "./ids.js";
4
+ /** Access tokens live one hour; refresh secrets seven days, rotated on use. */
5
+ const VISITOR_ACCESS_MS = 3_600_000;
6
+ const VISITOR_REFRESH_MS = 7 * 86_400_000;
7
+ /**
8
+ * A visitor identity (one conversation) ends thirty days after it was
9
+ * minted however often it refreshes: no token outlives it.
10
+ */
11
+ const VISITOR_LIFETIME_MS = 30 * 86_400_000;
12
+ /** New visitors one network address may mint per hour on one binding. */
13
+ const VISITORS_PER_ADDRESS_PER_HOUR = 10;
14
+ const HOUR_MS = 3_600_000;
15
+ /** Bearer grammar: `vt_` or `vr_` plus 43 base64url characters. */
16
+ export const visitorBearerSchema = z
17
+ .string()
18
+ .regex(/^v[tr]_[A-Za-z0-9_-]{43}$/u);
19
+ const tokenRow = z.strictObject({
20
+ visitor_id: z.string(),
21
+ family: z.string(),
22
+ kind: z.enum(["access", "refresh"]),
23
+ expires_at: z.number().int(),
24
+ used: z.number().int(),
25
+ });
26
+ const countRow = z.strictObject({ count: z.number().int() });
27
+ function randomBearer(prefix) {
28
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
29
+ let binary = "";
30
+ for (const byte of bytes)
31
+ binary += String.fromCharCode(byte);
32
+ return (prefix +
33
+ btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""));
34
+ }
35
+ async function digest(value) {
36
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
37
+ return [...new Uint8Array(hash)]
38
+ .map((byte) => byte.toString(16).padStart(2, "0"))
39
+ .join("");
40
+ }
41
+ /**
42
+ * Anonymous visitor identity for web bindings. A visitor principal
43
+ * names exactly one binding and one visitor, so it can reach exactly that
44
+ * visitor's one conversation Session and nothing else. Only token hashes are
45
+ * stored. Reusing a spent refresh secret revokes the whole family.
46
+ */
47
+ export function createChannelVisitors(options) {
48
+ const { sql } = options;
49
+ const ensure = () => {
50
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_visitors (
51
+ visitor_id TEXT PRIMARY KEY,
52
+ binding_id TEXT NOT NULL,
53
+ address_hash TEXT NOT NULL,
54
+ created_at INTEGER NOT NULL,
55
+ revoked INTEGER NOT NULL DEFAULT 0
56
+ ) STRICT`);
57
+ sql.exec("CREATE INDEX IF NOT EXISTS hub_channel_visitors_binding ON hub_channel_visitors(binding_id, created_at)");
58
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_visitor_tokens (
59
+ token_hash TEXT PRIMARY KEY,
60
+ visitor_id TEXT NOT NULL,
61
+ binding_id TEXT NOT NULL,
62
+ family TEXT NOT NULL,
63
+ kind TEXT NOT NULL CHECK (kind IN ('access','refresh')),
64
+ expires_at INTEGER NOT NULL,
65
+ used INTEGER NOT NULL DEFAULT 0
66
+ ) STRICT`);
67
+ };
68
+ const count = (statement, ...bindings) => countRow.parse(sql.exec(statement, ...bindings).toArray()[0]).count;
69
+ const issue = async (bindingId, visitorId, family, endsAt) => {
70
+ const accessToken = randomBearer("vt_");
71
+ const refreshSecret = randomBearer("vr_");
72
+ const now = options.now();
73
+ const accessExpiresAt = Math.min(now + VISITOR_ACCESS_MS, endsAt);
74
+ const [accessHash, refreshHash] = await Promise.all([
75
+ digest(accessToken),
76
+ digest(refreshSecret),
77
+ ]);
78
+ options.transactionSync(() => {
79
+ sql.exec("INSERT INTO hub_channel_visitor_tokens VALUES (?,?,?,?,'access',?,0)", accessHash, visitorId, bindingId, family, accessExpiresAt);
80
+ sql.exec("INSERT INTO hub_channel_visitor_tokens VALUES (?,?,?,?,'refresh',?,0)", refreshHash, visitorId, bindingId, family, Math.min(now + VISITOR_REFRESH_MS, endsAt));
81
+ });
82
+ return { visitorId, accessToken, accessExpiresAt, refreshSecret };
83
+ };
84
+ /** Mint a new anonymous visitor on one active web binding. */
85
+ const mint = async (binding, siteKey, addressHash) => {
86
+ ensure();
87
+ if (binding?.selector.provider !== "web" ||
88
+ binding.selector.siteKey !== siteKey)
89
+ return { kind: "denied", reason: "unmapped" };
90
+ if (binding.lifecycle !== "active")
91
+ return { kind: "denied", reason: "suspended" };
92
+ const limits = binding.limits ?? DEFAULT_BINDING_LIMITS;
93
+ const now = options.now();
94
+ const visitorId = channelIds.visitor.generate();
95
+ const admitted = options.transactionSync(() => {
96
+ if (count("SELECT COUNT(*) AS count FROM hub_channel_visitors WHERE binding_id=? AND created_at>?", binding.bindingId, now - HOUR_MS) >= limits.newPartiesPerHour)
97
+ return "new_party_limit";
98
+ if (count("SELECT COUNT(*) AS count FROM hub_channel_visitors WHERE binding_id=? AND address_hash=? AND created_at>?", binding.bindingId, addressHash, now - HOUR_MS) >= VISITORS_PER_ADDRESS_PER_HOUR)
99
+ return "address_limit";
100
+ sql.exec("INSERT INTO hub_channel_visitors (visitor_id,binding_id,address_hash,created_at) VALUES (?,?,?,?)", visitorId, binding.bindingId, addressHash, now);
101
+ return null;
102
+ });
103
+ if (admitted !== null)
104
+ return { kind: "denied", reason: admitted };
105
+ return {
106
+ kind: "granted",
107
+ credentials: await issue(binding.bindingId, visitorId, visitorId, now + VISITOR_LIFETIME_MS),
108
+ };
109
+ };
110
+ const lookup = async (bearer) => {
111
+ ensure();
112
+ const raw = sql
113
+ .exec("SELECT t.visitor_id,t.family,t.kind,t.expires_at,t.used FROM hub_channel_visitor_tokens t JOIN hub_channel_visitors v ON v.visitor_id=t.visitor_id WHERE t.token_hash=? AND v.revoked=0", await digest(bearer))
114
+ .toArray()[0];
115
+ return raw === undefined ? null : tokenRow.parse(raw);
116
+ };
117
+ /** Rotate a refresh secret; a reused one revokes the visitor's family. */
118
+ const refresh = async (binding, refreshSecret) => {
119
+ if (binding?.selector.provider !== "web")
120
+ return { kind: "denied", reason: "unmapped" };
121
+ if (binding.lifecycle !== "active")
122
+ return { kind: "denied", reason: "suspended" };
123
+ const hash = await digest(refreshSecret);
124
+ const row = await lookup(refreshSecret);
125
+ if (row?.kind !== "refresh" ||
126
+ row.expires_at <= options.now() ||
127
+ !sql
128
+ .exec("SELECT 1 FROM hub_channel_visitor_tokens WHERE token_hash=? AND binding_id=?", hash, binding.bindingId)
129
+ .toArray().length)
130
+ return { kind: "denied", reason: "refresh_invalid" };
131
+ const claimed = options.transactionSync(() => {
132
+ if (row.used !== 0) {
133
+ sql.exec("UPDATE hub_channel_visitors SET revoked=1 WHERE visitor_id=?", row.visitor_id);
134
+ return false;
135
+ }
136
+ sql.exec("UPDATE hub_channel_visitor_tokens SET used=1 WHERE token_hash=?", hash);
137
+ return true;
138
+ });
139
+ if (!claimed)
140
+ return { kind: "denied", reason: "refresh_reused" };
141
+ const created = z
142
+ .strictObject({ created_at: z.number().int() })
143
+ .parse(sql
144
+ .exec("SELECT created_at FROM hub_channel_visitors WHERE visitor_id=?", row.visitor_id)
145
+ .toArray()[0]).created_at;
146
+ const endsAt = created + VISITOR_LIFETIME_MS;
147
+ if (endsAt <= options.now())
148
+ return { kind: "denied", reason: "refresh_invalid" };
149
+ return {
150
+ kind: "granted",
151
+ credentials: await issue(binding.bindingId, row.visitor_id, row.family, endsAt),
152
+ };
153
+ };
154
+ /** The one visitor an access token names on this binding, or null. */
155
+ const authenticate = async (bindingId, accessToken) => {
156
+ const hash = await digest(accessToken);
157
+ const row = await lookup(accessToken);
158
+ if (row?.kind !== "access" || row.expires_at <= options.now())
159
+ return null;
160
+ const scoped = sql
161
+ .exec("SELECT v.address_hash FROM hub_channel_visitor_tokens t JOIN hub_channel_visitors v ON v.visitor_id=t.visitor_id WHERE t.token_hash=? AND t.binding_id=? AND v.revoked=0", hash, bindingId)
162
+ .toArray()[0];
163
+ if (scoped === undefined)
164
+ return null;
165
+ const address = z.strictObject({ address_hash: z.string() }).parse(scoped);
166
+ return {
167
+ visitorId: row.visitor_id,
168
+ expiresAt: row.expires_at,
169
+ addressHash: address.address_hash,
170
+ };
171
+ };
172
+ /** Revoke a swept visitor and drop its token hashes. */
173
+ const forget = (visitorId) => {
174
+ ensure();
175
+ options.transactionSync(() => {
176
+ sql.exec("UPDATE hub_channel_visitors SET revoked=1 WHERE visitor_id=?", visitorId);
177
+ sql.exec("DELETE FROM hub_channel_visitor_tokens WHERE visitor_id=?", visitorId);
178
+ });
179
+ };
180
+ return { ensure, mint, refresh, authenticate, forget };
181
+ }
@@ -4,7 +4,7 @@ export type { BackgroundFrameState } from "./invocation.js";
4
4
  import { WorkerEntrypoint } from "cloudflare:workers";
5
5
  import { type GuestLimits } from "./guest-policy.js";
6
6
  export type { GuestLimits } from "./guest-policy.js";
7
- import type { ArtifactHeads, ArtifactStore, ArtifactStreams } from "./release.js";
7
+ import type { ArtifactHeads, ArtifactStore, ArtifactStreams, VerifiedObjectCache } from "./release.js";
8
8
  import { type AppBackendBindingName } from "@fungi.computer/puffball/app";
9
9
  import { type InvocationFrame, type InvocationGate } from "./invocation.js";
10
10
  import * as Schema from "effect/Schema";
@@ -14,6 +14,12 @@ import { type UiBootCheckOutcome } from "./boot-gate.js";
14
14
  export { checkBackendConstruction } from "./boot-construction.js";
15
15
  import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
16
16
  import { AppInstanceError, type AppInstance, type InstancePorts } from "./index.js";
17
+ /**
18
+ * The zone's edge cache as a {@link VerifiedObjectCache}: each verified object
19
+ * under a digest-named key on `origin` that no route serves. A failed write is
20
+ * logged and answers `unstored`; the next reader verifies from the store again.
21
+ */
22
+ export declare function edgeVerifiedObjectCache(cache: Pick<Cache, "match" | "put">, origin: string): VerifiedObjectCache;
17
23
  /** Store Release artifacts without loading the Worker ownership barrel. */
18
24
  export declare function r2ArtifactStore(bucket: R2Bucket): ArtifactStore & ArtifactHeads & ArtifactStreams;
19
25
  /** Encode supervisor refusals before native RPC can discard Error properties. */
@@ -113,7 +119,7 @@ export type AppInstanceLoader<FacetClass> = Readonly<{
113
119
  getDurableObjectClass: (exportName: string) => FacetClass;
114
120
  }>;
115
121
  }>;
116
- /** End the native App facet before shared frame admission releases its permit. */
122
+ /** End the native App facet when live authority closes early, before the gate admits again. */
117
123
  export declare function createCloudflareInvocationGate<Host, FacetClass>(ctx: Pick<AppInstanceContext<FacetClass>, "facets">, close: (frame: InvocationFrame<Host>) => Promise<void>): InvocationGate<Host>;
118
124
  /**
119
125
  * Run an App Instance inside a SQLite-backed Durable Object on workerd, celld
@@ -20,6 +20,32 @@ import { BootGateError } from "./boot-gate.js";
20
20
  export { checkBackendConstruction } from "./boot-construction.js";
21
21
  import { createAppInstance, AppInstanceError, instanceIdSchema, instanceSchema, appInstanceErrorCodeSchema, } from "./index.js";
22
22
  const bindingWire = createFrameBindingWire(appInstanceErrorCodeSchema.literals);
23
+ /**
24
+ * The zone's edge cache as a {@link VerifiedObjectCache}: each verified object
25
+ * under a digest-named key on `origin` that no route serves. A failed write is
26
+ * logged and answers `unstored`; the next reader verifies from the store again.
27
+ */
28
+ export function edgeVerifiedObjectCache(cache, origin) {
29
+ const key = (digest) => new Request(new URL(`/_oyster/verified/${digest}`, origin));
30
+ return {
31
+ match: async (digest) => (await cache.match(key(digest)))?.body ?? null,
32
+ put: async (digest, bytes) => {
33
+ try {
34
+ await cache.put(key(digest), new Response(new Uint8Array(bytes), {
35
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" },
36
+ }));
37
+ return "stored";
38
+ }
39
+ catch (cause) {
40
+ console.error("Verified object cache write failed", {
41
+ digest,
42
+ failure: cause instanceof Error ? cause.name : "non-error rejection",
43
+ });
44
+ return "unstored";
45
+ }
46
+ },
47
+ };
48
+ }
23
49
  /** Store Release artifacts without loading the Worker ownership barrel. */
24
50
  export function r2ArtifactStore(bucket) {
25
51
  const abortError = (signal) => {
@@ -241,7 +267,7 @@ const facetInvocationSchema = rpcHandleSchema({
241
267
  invoke: rpcMethod(z.union([z.json(), z.undefined()])).input(z.tuple([z.string(), z.array(z.unknown()), z.unknown()])),
242
268
  http: rpcMethod(facetHttpReplySchema).input(z.tuple([facetHttpInputSchema, z.unknown()])),
243
269
  });
244
- /** End the native App facet before shared frame admission releases its permit. */
270
+ /** End the native App facet when live authority closes early, before the gate admits again. */
245
271
  export function createCloudflareInvocationGate(ctx, close) {
246
272
  return createInvocationGate({
247
273
  abortGuest: () => {
@@ -0,0 +1,5 @@
1
+ /** The query parameter that carries a window's Computer routing hint. */
2
+ export declare const computerTargetParam = "computerId";
3
+ /** Serialize a window's routing hint. The server must admit it through the live
4
+ * owner and fleet before constructing that request's workspace. */
5
+ export declare function computerTargetUrl(path: string, computerId?: string): string;
@@ -0,0 +1,11 @@
1
+ /** The query parameter that carries a window's Computer routing hint. */
2
+ export const computerTargetParam = "computerId";
3
+ /** Serialize a window's routing hint. The server must admit it through the live
4
+ * owner and fleet before constructing that request's workspace. */
5
+ export function computerTargetUrl(path, computerId) {
6
+ if (computerId === undefined)
7
+ return path;
8
+ const url = new URL(path, "https://oyster.invalid");
9
+ url.searchParams.set(computerTargetParam, computerId);
10
+ return url.pathname + url.search;
11
+ }
package/dist/host.d.ts CHANGED
@@ -14,6 +14,7 @@ import { type GuestLimits } from "./guest-policy.js";
14
14
  import { type InvocationFacts } from "./invocation.js";
15
15
  export type { GuestLimits } from "./guest-policy.js";
16
16
  import { Hono } from "hono";
17
+ import { type TimingVariables } from "hono/timing";
17
18
  import * as Schema from "effect/Schema";
18
19
  import { type BackgroundSyncRefusal } from "@fungi.computer/puffball/background-replay";
19
20
  import { type Installation, type InstallationReader } from "./apps/installation.js";
@@ -26,12 +27,12 @@ export { capabilityDescriptorSchema, capabilityMethodKindSchema, capabilityMetho
26
27
  import type { Release } from "@fungi.computer/puffball";
27
28
  import type { UiBootCheckOutcome } from "./boot-gate.js";
28
29
  import { type Instance, type InstanceId, type OwnerState, type AppInstanceErrorCode } from "./index.js";
29
- import { type ArtifactStore } from "./release.js";
30
+ import { type ArtifactStore, type VerifiedObjectCache } from "./release.js";
30
31
  import { type GitDoorTarget } from "./git.js";
31
32
  import type { AdmittedGit } from "./git-admission.js";
32
33
  import { type PageAssetLoader } from "./page-assets.js";
33
34
  import { type AppApi } from "@fungi.computer/puffball";
34
- export { ReleaseError, defaultAssetPolicy, parseRelease, resolveBackend, sha256, verifyRelease, type ArtifactStore, type VerifiedRelease, } from "./release.js";
35
+ export { ReleaseError, defaultAssetPolicy, parseRelease, resolveBackend, sha256, verifyRelease, type ArtifactStore, type VerifiedObjectCache, type VerifiedRelease, } from "./release.js";
35
36
  /**
36
37
  * The anonymous reader of a public installation. The host admits it only when
37
38
  * authentication refuses; it reaches that App's page and its `read` methods.
@@ -173,7 +174,7 @@ export type OysterCapability = Readonly<CapabilityDescriptor & {
173
174
  routes: Hono<CapabilityEnv>;
174
175
  }>;
175
176
  /** What the host stamps once per request; inner routes read it with `c.get`. */
176
- export type OysterVariables = {
177
+ export type OysterVariables = TimingVariables & {
177
178
  principal: OysterPrincipal;
178
179
  installation?: Installation;
179
180
  };
@@ -201,6 +202,8 @@ export type OysterHost = Readonly<{
201
202
  */
202
203
  backgroundOriginKey?: () => Promise<CryptoKey>;
203
204
  artifacts: ArtifactStore;
205
+ /** Digest-keyed copies of verified UI objects; absent, every read is verified from `artifacts`. */
206
+ uiCache?: VerifiedObjectCache;
204
207
  installations: InstallationStore;
205
208
  instances: InstanceDirectory;
206
209
  /** Report-only host UI check; backend construction remains Instance-owned and blocking. */
package/dist/host.js CHANGED
@@ -12,6 +12,7 @@ import { guestLimits } from "./guest-policy.js";
12
12
  import { APP_INVOCATION_CEILING_MS, admittedCallerFacts, admittedMethodCallerFacts, invocationAuthorityOf, invocationCallerContext, invocationFacts, } from "./invocation.js";
13
13
  import { appFacetResponse } from "./backend-request.js";
14
14
  import { Hono } from "hono";
15
+ import { endTime, startTime, timing } from "hono/timing";
15
16
  import { bodyLimit } from "hono/body-limit";
16
17
  import { match } from "path-to-regexp";
17
18
  import * as Schema from "effect/Schema";
@@ -26,7 +27,7 @@ import { appBackendActorHeader, appBackendComputerHeader, appMethodRefusalStatus
26
27
  import { appCallsDescriptor, } from "./capability-descriptor.js";
27
28
  export { capabilityDescriptorSchema, capabilityMethodKindSchema, capabilityMethodSchema, } from "./capability-descriptor.js";
28
29
  import { selectionOf, } from "./index.js";
29
- import { parseRelease, readVerified, sha256, } from "./release.js";
30
+ import { parseRelease, readCachedVerified, sha256, } from "./release.js";
30
31
  import { createGitDoor } from "./git.js";
31
32
  import { buildGitPassPrefix, presentedGitKey } from "./git-auth.js";
32
33
  import { workflowGitPassPrefix } from "@fungi.computer/braid/runtime";
@@ -202,6 +203,19 @@ const tabIcon = `<link rel="icon" type="image/svg+xml" href="${fungiFaviconHref}
202
203
  export function createOyster(host) {
203
204
  const limits = guestLimits(host.guestLimits);
204
205
  const app = new Hono();
206
+ // Server-Timing names the Durable Object hops a request waited on.
207
+ // An upgraded socket keeps its response untouched.
208
+ app.use(timing({
209
+ totalDescription: "Oyster",
210
+ enabled: (context) => context.res.status !== 101,
211
+ }));
212
+ // A forwarded Durable Object or fetch response has immutable headers;
213
+ // give the timing middleware a copy it can append Server-Timing to.
214
+ app.use(async (context, next) => {
215
+ await next();
216
+ if (context.res.status !== 101)
217
+ context.res = new Response(context.res.body, context.res);
218
+ });
205
219
  const releaseOf = releaseCache(host.artifacts);
206
220
  const operationDescriptors = host.capabilities ?? [];
207
221
  const gateFacts = (installation, release, principal) => hostGateFacts(host, installation, release, principal);
@@ -258,6 +272,30 @@ export function createOyster(host) {
258
272
  ? cause
259
273
  : new PageAssetsLoadError(cause);
260
274
  }));
275
+ // An Oyster is a private instance, never a search result: every response
276
+ // says so, and robots.txt is public so crawlers read the rule rather than a
277
+ // 401 (which Google treats as "crawl everything").
278
+ app.use("*", async (context, next) => {
279
+ await next();
280
+ try {
281
+ context.res.headers.set("X-Robots-Tag", "noindex, nofollow");
282
+ }
283
+ catch (cause) {
284
+ // Fetched responses (proxied Apps, the git door) have immutable headers.
285
+ if (!(cause instanceof TypeError))
286
+ throw cause;
287
+ const { body, status, statusText, headers } = context.res;
288
+ const marked = new Headers(headers);
289
+ marked.set("X-Robots-Tag", "noindex, nofollow");
290
+ context.res = new Response(body, { status, statusText, headers: marked });
291
+ }
292
+ });
293
+ app.get("/robots.txt", () => new Response("User-agent: *\nDisallow: /\n", {
294
+ headers: {
295
+ "Content-Type": "text/plain; charset=utf-8",
296
+ "Cache-Control": "public, max-age=3600",
297
+ },
298
+ }));
261
299
  // The home page imports this host-owned entry, never the Release module's
262
300
  // own URL: released Oyster 0.2.0 served that URL immutable, so a browser may
263
301
  // hold a copy it would import without a request. The entry is never cached
@@ -285,7 +323,9 @@ export function createOyster(host) {
285
323
  // App UI: digest-pinned, sandboxed, and readable without a session because
286
324
  // the opaque-origin frame cannot carry one.
287
325
  app.get("/ui/:installationId/:releaseId/*", async (context) => {
326
+ startTime(context, "registry");
288
327
  const installation = await host.installations.get(context.req.param("installationId"));
328
+ endTime(context, "registry");
289
329
  if (installation?.status !== "enabled" ||
290
330
  installation.release.releaseId !== context.req.param("releaseId"))
291
331
  return refusal(404, "Not found");
@@ -298,13 +338,15 @@ export function createOyster(host) {
298
338
  if (release.ui.kind === "html") {
299
339
  if (path !== "/")
300
340
  return refusal(404, "Not found");
301
- const bytes = await readVerified(host.artifacts, release.ui.artifactRef, release.ui.digest);
302
- return new Response(new Uint8Array(bytes), {
341
+ const body = await readCachedVerified(host.artifacts, host.uiCache, release.ui.artifactRef, release.ui.digest);
342
+ return new Response(body, {
303
343
  headers: {
304
344
  "Content-Type": "text/html; charset=utf-8",
305
345
  "Content-Security-Policy": `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src blob: data:; media-src blob:; font-src data:; connect-src 'none'; frame-src 'none'; frame-ancestors ${origin}; base-uri 'none'; form-action 'none'; sandbox ${appSandbox}`,
306
346
  "X-Content-Type-Options": "nosniff",
307
- "Cache-Control": "no-store",
347
+ // Digest-pinned like an assets UI's index.html: the frame's
348
+ // authority is its host connection, never these bytes.
349
+ "Cache-Control": "public, max-age=31536000, immutable",
308
350
  },
309
351
  });
310
352
  }
@@ -317,9 +359,9 @@ export function createOyster(host) {
317
359
  const shellEntry = wanted === shellModuleAssetPath;
318
360
  if (shellEntry && !(await shellApproved(installation, release)))
319
361
  return noStoreRefusal(403, "not_approved");
320
- const bytes = await readVerified(host.artifacts, asset.artifactRef, asset.digest);
362
+ const body = await readCachedVerified(host.artifacts, host.uiCache, asset.artifactRef, asset.digest);
321
363
  const scope = `${origin}${prefix}/`;
322
- return new Response(new Uint8Array(bytes), {
364
+ return new Response(body, {
323
365
  headers: {
324
366
  "Content-Type": asset.contentType,
325
367
  "Content-Security-Policy": `default-src 'none'; script-src ${scope} 'wasm-unsafe-eval'; style-src ${scope} 'unsafe-inline'; img-src ${scope} blob: data:; media-src blob:; font-src ${scope} data:; connect-src data: ${scope}; worker-src blob:; frame-src 'none'; frame-ancestors ${origin}; base-uri 'none'; form-action 'none'; sandbox ${appSandbox}`,
@@ -389,9 +431,20 @@ export function createOyster(host) {
389
431
  ? null
390
432
  : git.buildPass(presented.value, target);
391
433
  const result = await host.auth.authenticate(request);
392
- return result.kind === "admitted"
393
- ? git.writerOf(result.principal)
394
- : null;
434
+ if (result.kind !== "admitted")
435
+ return null;
436
+ // A named key signs Git in only where its own grants reach that
437
+ // App: a clone needs App read, a push App use. Otherwise it is
438
+ // anonymous, which reaches only a public repository's reads.
439
+ const { actor } = result.principal;
440
+ if (actor.kind === "team-key") {
441
+ const key = (await host.keys?.live(actor.keyId)) ?? null;
442
+ const access = key === null ? null : keyAppAccess(key, target.installationId);
443
+ if (access === null ||
444
+ (target.scope === "write" && access.level !== "write"))
445
+ return null;
446
+ }
447
+ return git.writerOf(result.principal);
395
448
  },
396
449
  isPublic: async (installationId) => (await host.installations.get(installationId))?.visibility ===
397
450
  "public",
@@ -600,7 +653,9 @@ export function createOyster(host) {
600
653
  return refusal(403, "Owner only");
601
654
  if (host.workbench === undefined)
602
655
  return refusal(404, "Not found");
656
+ startTime(context, "registry");
603
657
  let directory = await host.workbench.directory();
658
+ endTime(context, "registry");
604
659
  if (host.computerId !== undefined)
605
660
  directory = {
606
661
  ...directory,
@@ -845,9 +900,11 @@ export function createOyster(host) {
845
900
  const enabled = async (context) => {
846
901
  const installationId = context.req.param("installationId") ?? "";
847
902
  const resolved = context.get("installation");
848
- const installation = resolved?.installationId === installationId
849
- ? resolved
850
- : await host.installations.get(installationId);
903
+ if (resolved?.installationId === installationId)
904
+ return resolved.status === "enabled" ? resolved : null;
905
+ startTime(context, "registry");
906
+ const installation = await host.installations.get(installationId);
907
+ endTime(context, "registry");
851
908
  return installation?.status === "enabled" ? installation : null;
852
909
  };
853
910
  app.get("/apps/:installationId/", async (context) => {
@@ -858,6 +915,8 @@ export function createOyster(host) {
858
915
  const declaration = release.declaration;
859
916
  const principal = context.get("principal");
860
917
  const facts = await gateFacts(installation, release, principal);
918
+ if (principal.actor.kind === "team-key" && facts.access === null)
919
+ return refusal(403, "App access denied");
861
920
  const capabilities = [];
862
921
  for (const capability of host.capabilities ?? []) {
863
922
  const methods = Object.fromEntries(Object.entries(capability.methods).filter(([name, method]) => admits(facts, {
@@ -1115,7 +1174,10 @@ export function createOyster(host) {
1115
1174
  const installation = await enabled(context);
1116
1175
  if (installation === null)
1117
1176
  return refusal(404, "Not found");
1118
- return executeMethod(installation, await releaseOf(installation.release), context.get("principal"), context.req.param("name"), context.req.raw);
1177
+ startTime(context, "method", "admission and Instance call");
1178
+ const response = await executeMethod(installation, await releaseOf(installation.release), context.get("principal"), context.req.param("name"), context.req.raw);
1179
+ endTime(context, "method");
1180
+ return response;
1119
1181
  });
1120
1182
  app.post("/apps/:caller/calls/:appId/:method", async (context) => {
1121
1183
  const principal = context.get("principal");
@@ -5,10 +5,12 @@ import type { Installation } from "../apps/installation.js";
5
5
  import type { OysterHost, OysterPrincipal } from "../host.js";
6
6
  import { type ArtifactStore, type VerifiedRelease } from "../release.js";
7
7
  /**
8
- * Verify each Release once per process: a failed read is not cached, so the
9
- * next request reads it again. Installation verified every asset; serving
10
- * verifies each on read.
8
+ * Verify each Release once per owner (an isolate's HTTP host, or the
9
+ * Registry): a failed read is not cached, so the next request reads it again.
10
+ * Installation verified every asset; serving verifies each on read. A Release
11
+ * is content-addressed and immutable, so the key is its ID and root digest and
12
+ * a hit never goes stale; the bound only forgets the oldest-verified Releases.
11
13
  */
12
- export declare function releaseCache(artifacts: ArtifactStore): (release: Release) => Promise<VerifiedRelease>;
14
+ export declare function releaseCache(artifacts: ArtifactStore, limit?: number): (release: Release) => Promise<VerifiedRelease>;
13
15
  /** The gate facts one principal presents to one installation. */
14
16
  export declare function hostGateFacts(host: OysterHost, installation: Installation, release: VerifiedRelease, principal: OysterPrincipal): Promise<GateFacts>;
@@ -2,24 +2,31 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
2
2
  import { deriveDeclaredOperations } from "../apps/operations.js";
3
3
  import { keyAppAccess } from "../keys.js";
4
4
  import { defaultAssetPolicy, verifyRelease, } from "../release.js";
5
+ /** Verified Releases one cache keeps; the least recently added leaves first. */
6
+ const releaseCacheLimit = 256;
5
7
  /**
6
- * Verify each Release once per process: a failed read is not cached, so the
7
- * next request reads it again. Installation verified every asset; serving
8
- * verifies each on read.
8
+ * Verify each Release once per owner (an isolate's HTTP host, or the
9
+ * Registry): a failed read is not cached, so the next request reads it again.
10
+ * Installation verified every asset; serving verifies each on read. A Release
11
+ * is content-addressed and immutable, so the key is its ID and root digest and
12
+ * a hit never goes stale; the bound only forgets the oldest-verified Releases.
9
13
  */
10
- export function releaseCache(artifacts) {
14
+ export function releaseCache(artifacts, limit = releaseCacheLimit) {
11
15
  const verified = new Map();
12
16
  return (release) => {
13
17
  const key = `${release.releaseId}:${release.artifact.artifactDigest}`;
14
18
  let entry = verified.get(key);
15
19
  if (entry === undefined) {
16
- // A failed read is not cached: the next request reads the Release again.
17
- // Installation verified every asset; serving verifies each on read.
18
- entry = verifyRelease(artifacts, release, defaultAssetPolicy, false).then((value) => value, (cause) => {
20
+ entry = verifyRelease(artifacts, release, defaultAssetPolicy, false).catch((cause) => {
19
21
  verified.delete(key);
20
22
  throw cause;
21
23
  });
22
24
  verified.set(key, entry);
25
+ for (const oldest of verified.keys()) {
26
+ if (verified.size <= limit)
27
+ break;
28
+ verified.delete(oldest);
29
+ }
23
30
  }
24
31
  return entry;
25
32
  };
@@ -193,7 +193,7 @@ export declare function assertBackgroundBinding(frame: InvocationFrame<Backgroun
193
193
  export declare function admitBackgroundFrame(key: CryptoKey, frame: InvocationFrame<BackgroundFrameState>, raw: unknown): Promise<ClaimedJobAdmission>;
194
194
  /** The host's native guest-abort and authority-close hooks. */
195
195
  export type InvocationGatePorts<Host> = Readonly<{
196
- /** Stop the guest when the captured invocation's authority closes. */
196
+ /** Stop the guest when live authority closes early (revocation, expiry). */
197
197
  abortGuest: () => Promise<void>;
198
198
  /** Release the host resources belonging to this completed or revoked frame. */
199
199
  close: (frame: InvocationFrame<Host>) => Promise<void>;
@@ -204,8 +204,8 @@ export type InvocationGatePorts<Host> = Readonly<{
204
204
  export type InvocationGate<Host> = Readonly<{
205
205
  /** Run work within one immutable frame and close its authority afterward. */
206
206
  run: <A>(facts: InvocationFacts, host: Host, work: (frame: InvocationFrame<Host>) => Promise<A>) => Promise<A>;
207
- /** Resolve the sole admitted frame, refusing outside an active invocation. */
208
- active: () => InvocationFrame<Host>;
207
+ /** Resolve one live frame by its id, refusing a closed or unknown frame. */
208
+ active: (frameId: string) => InvocationFrame<Host>;
209
209
  /** Close live authority, optionally restricted to frames of a selected run. */
210
210
  revoke: (reason?: Error, target?: Readonly<{
211
211
  runId: string;
@@ -215,7 +215,14 @@ export type InvocationGate<Host> = Readonly<{
215
215
  }>;
216
216
  /** Decode only identities minted from the invocation-frame ID family. */
217
217
  export declare const invocationFrameIdSchema: Schema.String;
218
- /** Serialize authority, preempt replay only for verified writes, and close each guest before admission. */
218
+ /**
219
+ * Admit frames against one guest. Verified foreground reads share it, up to
220
+ * eight at once; writes, raw backend requests, Gills and replay run alone. Every guest binding packet names its frame, so a finished
221
+ * frame's leftover timers cannot borrow a later frame's authority and a
222
+ * normal finish keeps the guest warm. Closing authority early (revocation,
223
+ * expiry, a write preempting replay) aborts the guest and so every frame
224
+ * sharing it; replay only yields to a verified write.
225
+ */
219
226
  export declare function createInvocationGate<Host>(ports: InvocationGatePorts<Host>): InvocationGate<Host>;
220
227
  /** Verified selection, artifact and binding grants before caller attribution. */
221
228
  export type InvocationAuthority = Omit<InvocationFacts, "subject" | "deadlineAt">;