@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.
Files changed (86) hide show
  1. package/README.md +46 -8
  2. package/dist/agents.d.ts +2 -1
  3. package/dist/agents.js +67 -8
  4. package/dist/apps/gate.d.ts +4 -0
  5. package/dist/apps/gate.js +3 -0
  6. package/dist/auth.d.ts +24 -1
  7. package/dist/auth.js +38 -0
  8. package/dist/boot-host-script.js +1 -1
  9. package/dist/build/candidate-cli.mjs +27 -5
  10. package/dist/channels/binding.d.ts +70 -0
  11. package/dist/channels/binding.js +97 -0
  12. package/dist/channels/conversations.d.ts +246 -0
  13. package/dist/channels/conversations.js +705 -0
  14. package/dist/channels/ids.d.ts +8 -0
  15. package/dist/channels/ids.js +15 -0
  16. package/dist/channels/visitor-channel.d.ts +53 -0
  17. package/dist/channels/visitor-channel.js +124 -0
  18. package/dist/channels/visitor-edge.d.ts +115 -0
  19. package/dist/channels/visitor-edge.js +582 -0
  20. package/dist/channels/visitor-request.d.ts +7 -0
  21. package/dist/channels/visitor-request.js +17 -0
  22. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  23. package/dist/channels/visitor-stream-slots.js +33 -0
  24. package/dist/channels/visitors.d.ts +47 -0
  25. package/dist/channels/visitors.js +181 -0
  26. package/dist/cloudflare.d.ts +8 -2
  27. package/dist/cloudflare.js +27 -1
  28. package/dist/computer-target.d.ts +5 -0
  29. package/dist/computer-target.js +11 -0
  30. package/dist/host.d.ts +7 -4
  31. package/dist/host.js +96 -17
  32. package/dist/internal/host-gate.d.ts +6 -4
  33. package/dist/internal/host-gate.js +14 -7
  34. package/dist/invocation.d.ts +11 -4
  35. package/dist/invocation.js +134 -91
  36. package/dist/keys.d.ts +4 -1
  37. package/dist/keys.js +15 -4
  38. package/dist/machine-link/authority.d.ts +7 -0
  39. package/dist/machine-link/delivery.d.ts +12 -0
  40. package/dist/machine-link/delivery.js +22 -1
  41. package/dist/machine-link/disposition.d.ts +16 -0
  42. package/dist/machine-link/disposition.js +65 -0
  43. package/dist/machine-link/host.d.ts +11 -2
  44. package/dist/machine-link/host.js +125 -5
  45. package/dist/machine-link/link.d.ts +4 -0
  46. package/dist/machine-link/link.js +47 -1
  47. package/dist/page/capabilities.js +1 -1
  48. package/dist/page/child-selection.d.ts +27 -0
  49. package/dist/page/child-selection.js +38 -0
  50. package/dist/page/mount.js +1 -1
  51. package/dist/page-script.js +4 -4
  52. package/dist/release.d.ts +13 -0
  53. package/dist/release.js +10 -0
  54. package/dist/request-origin.d.ts +4 -0
  55. package/dist/request-origin.js +10 -0
  56. package/dist/standalone/agent-mail.js +4 -1
  57. package/dist/standalone/agents.d.ts +8 -0
  58. package/dist/standalone/agents.js +30 -11
  59. package/dist/standalone/channel-session-door.d.ts +4 -0
  60. package/dist/standalone/channel-session-door.js +72 -0
  61. package/dist/standalone/computer-fleet.js +7 -1
  62. package/dist/standalone/machine-link.d.ts +2 -0
  63. package/dist/standalone/machine-link.js +34 -1
  64. package/dist/standalone/tenancy.d.ts +3 -0
  65. package/dist/standalone/tenancy.js +7 -2
  66. package/dist/testing/capabilities.d.ts +2 -0
  67. package/dist/testing/capabilities.js +3 -1
  68. package/dist/turnstile.d.ts +27 -0
  69. package/dist/turnstile.js +51 -0
  70. package/dist/workers-git.js +21 -2
  71. package/dist/workers.d.ts +3 -0
  72. package/dist/workers.js +15 -12
  73. package/docs/api/agents.md +8 -4
  74. package/docs/api/apps.md +8 -4
  75. package/docs/api/auth.md +55 -0
  76. package/docs/api/browser.md +40 -16
  77. package/docs/api/build-contract.md +2 -2
  78. package/docs/api/cloudflare.md +26 -1
  79. package/docs/api/host.md +22 -13
  80. package/docs/api/invocation.md +7 -3
  81. package/docs/api/keys.md +25 -3
  82. package/docs/api/workers.md +38 -30
  83. package/docs/site/agent-quickstart.mdx +29 -2
  84. package/examples/standalone/wrangler.celld.jsonc +66 -0
  85. package/examples/standalone/wrangler.jsonc +3 -4
  86. package/package.json +53 -17
@@ -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,11 +14,12 @@ 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";
20
21
  import type { AppToolPolicy } from "./apps/tools.js";
21
- import type { LiveOysterKey } from "./keys.js";
22
+ import { type LiveOysterKey } from "./keys.js";
22
23
  import { type AppMethodReply } from "@fungi.computer/agar/backend";
23
24
  import type { AppCapabilityId, HostActor } from "@fungi.computer/puffball/app";
24
25
  import { type CapabilityDescriptor } from "./capability-descriptor.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";
@@ -20,12 +21,13 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
20
21
  import { applyInstallationCommand, installationIdSchema, } from "./apps/installation.js";
21
22
  import { admits, scopedBindings, grantsFor } from "./apps/gate.js";
22
23
  import { appPermissionPageSchema, permissionSaveSchema, } from "./apps/permissions.js";
24
+ import { keyAppAccess } from "./keys.js";
23
25
  import { deriveDeclaredOperations, appCallOperation, } from "./apps/operations.js";
24
26
  import { appBackendActorHeader, appBackendComputerHeader, appMethodRefusalStatus, } from "@fungi.computer/agar/backend";
25
27
  import { appCallsDescriptor, } from "./capability-descriptor.js";
26
28
  export { capabilityDescriptorSchema, capabilityMethodKindSchema, capabilityMethodSchema, } from "./capability-descriptor.js";
27
29
  import { selectionOf, } from "./index.js";
28
- import { parseRelease, readVerified, sha256, } from "./release.js";
30
+ import { parseRelease, readCachedVerified, sha256, } from "./release.js";
29
31
  import { createGitDoor } from "./git.js";
30
32
  import { buildGitPassPrefix, presentedGitKey } from "./git-auth.js";
31
33
  import { workflowGitPassPrefix } from "@fungi.computer/braid/runtime";
@@ -201,6 +203,19 @@ const tabIcon = `<link rel="icon" type="image/svg+xml" href="${fungiFaviconHref}
201
203
  export function createOyster(host) {
202
204
  const limits = guestLimits(host.guestLimits);
203
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
+ });
204
219
  const releaseOf = releaseCache(host.artifacts);
205
220
  const operationDescriptors = host.capabilities ?? [];
206
221
  const gateFacts = (installation, release, principal) => hostGateFacts(host, installation, release, principal);
@@ -257,6 +272,30 @@ export function createOyster(host) {
257
272
  ? cause
258
273
  : new PageAssetsLoadError(cause);
259
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
+ }));
260
299
  // The home page imports this host-owned entry, never the Release module's
261
300
  // own URL: released Oyster 0.2.0 served that URL immutable, so a browser may
262
301
  // hold a copy it would import without a request. The entry is never cached
@@ -284,7 +323,9 @@ export function createOyster(host) {
284
323
  // App UI: digest-pinned, sandboxed, and readable without a session because
285
324
  // the opaque-origin frame cannot carry one.
286
325
  app.get("/ui/:installationId/:releaseId/*", async (context) => {
326
+ startTime(context, "registry");
287
327
  const installation = await host.installations.get(context.req.param("installationId"));
328
+ endTime(context, "registry");
288
329
  if (installation?.status !== "enabled" ||
289
330
  installation.release.releaseId !== context.req.param("releaseId"))
290
331
  return refusal(404, "Not found");
@@ -297,13 +338,15 @@ export function createOyster(host) {
297
338
  if (release.ui.kind === "html") {
298
339
  if (path !== "/")
299
340
  return refusal(404, "Not found");
300
- const bytes = await readVerified(host.artifacts, release.ui.artifactRef, release.ui.digest);
301
- 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, {
302
343
  headers: {
303
344
  "Content-Type": "text/html; charset=utf-8",
304
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}`,
305
346
  "X-Content-Type-Options": "nosniff",
306
- "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",
307
350
  },
308
351
  });
309
352
  }
@@ -316,9 +359,9 @@ export function createOyster(host) {
316
359
  const shellEntry = wanted === shellModuleAssetPath;
317
360
  if (shellEntry && !(await shellApproved(installation, release)))
318
361
  return noStoreRefusal(403, "not_approved");
319
- const bytes = await readVerified(host.artifacts, asset.artifactRef, asset.digest);
362
+ const body = await readCachedVerified(host.artifacts, host.uiCache, asset.artifactRef, asset.digest);
320
363
  const scope = `${origin}${prefix}/`;
321
- return new Response(new Uint8Array(bytes), {
364
+ return new Response(body, {
322
365
  headers: {
323
366
  "Content-Type": asset.contentType,
324
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}`,
@@ -388,9 +431,20 @@ export function createOyster(host) {
388
431
  ? null
389
432
  : git.buildPass(presented.value, target);
390
433
  const result = await host.auth.authenticate(request);
391
- return result.kind === "admitted"
392
- ? git.writerOf(result.principal)
393
- : 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);
394
448
  },
395
449
  isPublic: async (installationId) => (await host.installations.get(installationId))?.visibility ===
396
450
  "public",
@@ -487,6 +541,10 @@ export function createOyster(host) {
487
541
  kind: "capability-route",
488
542
  capability: capability.permission,
489
543
  operation,
544
+ effect: (context.req.method === "GET" || context.req.method === "HEAD") &&
545
+ context.req.header("Upgrade")?.toLowerCase() !== "websocket"
546
+ ? "read"
547
+ : "write",
490
548
  }).kind === "refused")
491
549
  continue;
492
550
  url.pathname = `/${url.pathname.slice(capability.base.length)}`;
@@ -498,14 +556,23 @@ export function createOyster(host) {
498
556
  return refusal(403, "Capability permission denied");
499
557
  });
500
558
  app.get("/", async (context) => {
501
- const publicHome = context.get("principal").actor.kind === "visitor";
559
+ const actor = context.get("principal").actor;
560
+ const publicHome = actor.kind === "visitor";
561
+ const key = actor.kind === "team-key"
562
+ ? ((await host.keys?.live(actor.keyId)) ?? null)
563
+ : null;
502
564
  if (!publicHome && host.homeInstallationId !== undefined) {
503
565
  const home = await host.installations.get(host.homeInstallationId);
504
- if (home?.status === "enabled")
566
+ if (home?.status === "enabled" &&
567
+ (actor.kind !== "team-key" ||
568
+ (key !== null && keyAppAccess(key, home.installationId) !== null)))
505
569
  return context.redirect(`/apps/${encodeURIComponent(home.installationId)}/`);
506
570
  }
507
571
  const installations = (await host.installations.list()).filter((installation) => installation.status === "enabled" &&
508
- (!publicHome || installation.visibility === "public"));
572
+ (!publicHome || installation.visibility === "public") &&
573
+ (actor.kind !== "team-key" ||
574
+ (key !== null &&
575
+ keyAppAccess(key, installation.installationId) !== null)));
509
576
  if (publicHome) {
510
577
  const apps = [];
511
578
  for (const installation of installations) {
@@ -586,7 +653,9 @@ export function createOyster(host) {
586
653
  return refusal(403, "Owner only");
587
654
  if (host.workbench === undefined)
588
655
  return refusal(404, "Not found");
656
+ startTime(context, "registry");
589
657
  let directory = await host.workbench.directory();
658
+ endTime(context, "registry");
590
659
  if (host.computerId !== undefined)
591
660
  directory = {
592
661
  ...directory,
@@ -831,9 +900,11 @@ export function createOyster(host) {
831
900
  const enabled = async (context) => {
832
901
  const installationId = context.req.param("installationId") ?? "";
833
902
  const resolved = context.get("installation");
834
- const installation = resolved?.installationId === installationId
835
- ? resolved
836
- : 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");
837
908
  return installation?.status === "enabled" ? installation : null;
838
909
  };
839
910
  app.get("/apps/:installationId/", async (context) => {
@@ -844,12 +915,15 @@ export function createOyster(host) {
844
915
  const declaration = release.declaration;
845
916
  const principal = context.get("principal");
846
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");
847
920
  const capabilities = [];
848
921
  for (const capability of host.capabilities ?? []) {
849
- const methods = Object.fromEntries(Object.entries(capability.methods).filter(([name]) => admits(facts, {
922
+ const methods = Object.fromEntries(Object.entries(capability.methods).filter(([name, method]) => admits(facts, {
850
923
  kind: "capability-route",
851
924
  capability: capability.permission,
852
925
  operation: `route:${capability.id}:${name}`,
926
+ effect: method.method === "GET" ? "read" : "write",
853
927
  }).kind === "admitted"));
854
928
  if (Object.keys(methods).length > 0) {
855
929
  const { id, version, base } = capability;
@@ -862,6 +936,7 @@ export function createOyster(host) {
862
936
  kind: "capability-route",
863
937
  capability: "apps.call",
864
938
  operation: appCallOperation(call.appId, method),
939
+ effect: "write",
865
940
  }).kind === "admitted"))) {
866
941
  capabilities.push({
867
942
  ...appCallsDescriptor,
@@ -1099,7 +1174,10 @@ export function createOyster(host) {
1099
1174
  const installation = await enabled(context);
1100
1175
  if (installation === null)
1101
1176
  return refusal(404, "Not found");
1102
- 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;
1103
1181
  });
1104
1182
  app.post("/apps/:caller/calls/:appId/:method", async (context) => {
1105
1183
  const principal = context.get("principal");
@@ -1119,6 +1197,7 @@ export function createOyster(host) {
1119
1197
  kind: "capability-route",
1120
1198
  capability: "apps.call",
1121
1199
  operation: appCallOperation(appId, method),
1200
+ effect: "write",
1122
1201
  }).kind === "admitted");
1123
1202
  };
1124
1203
  if (!(await allows(caller, 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>;