@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.
- package/README.md +46 -8
- package/dist/agents.js +4 -0
- package/dist/auth.d.ts +24 -1
- package/dist/auth.js +38 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +8 -1
- package/dist/channels/binding.d.ts +8 -1
- package/dist/channels/binding.js +12 -0
- package/dist/channels/conversations.d.ts +16 -5
- package/dist/channels/visitor-channel.d.ts +53 -0
- package/dist/channels/visitor-channel.js +124 -0
- package/dist/channels/visitor-edge.d.ts +115 -0
- package/dist/channels/visitor-edge.js +582 -0
- package/dist/channels/visitor-request.d.ts +7 -0
- package/dist/channels/visitor-request.js +17 -0
- package/dist/channels/visitor-stream-slots.d.ts +2 -0
- package/dist/channels/visitor-stream-slots.js +33 -0
- package/dist/channels/visitors.d.ts +47 -0
- package/dist/channels/visitors.js +181 -0
- package/dist/cloudflare.d.ts +8 -2
- package/dist/cloudflare.js +27 -1
- package/dist/computer-target.d.ts +5 -0
- package/dist/computer-target.js +11 -0
- package/dist/host.d.ts +6 -3
- package/dist/host.js +75 -13
- package/dist/internal/host-gate.d.ts +6 -4
- package/dist/internal/host-gate.js +14 -7
- package/dist/invocation.d.ts +11 -4
- package/dist/invocation.js +134 -91
- package/dist/page/capabilities.js +1 -1
- package/dist/page/child-selection.d.ts +27 -0
- package/dist/page/child-selection.js +38 -0
- package/dist/page/mount.js +1 -1
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +13 -0
- package/dist/release.js +10 -0
- package/dist/request-origin.d.ts +4 -0
- package/dist/request-origin.js +10 -0
- package/dist/standalone/agents.js +11 -11
- package/dist/standalone/channel-session-door.d.ts +2 -2
- package/dist/standalone/channel-session-door.js +35 -5
- package/dist/standalone/computer-fleet.js +7 -1
- package/dist/standalone/tenancy.d.ts +3 -0
- package/dist/standalone/tenancy.js +7 -2
- package/dist/turnstile.d.ts +27 -0
- package/dist/turnstile.js +51 -0
- package/dist/workers-git.js +20 -1
- package/dist/workers.js +15 -12
- package/docs/api/auth.md +55 -0
- package/docs/api/build-contract.md +2 -2
- package/docs/api/cloudflare.md +26 -1
- package/docs/api/host.md +22 -13
- package/docs/api/invocation.md +7 -3
- package/docs/api/workers.md +17 -16
- package/docs/site/agent-quickstart.mdx +6 -2
- package/examples/standalone/wrangler.celld.jsonc +66 -0
- 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
|
+
}
|
package/dist/cloudflare.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/cloudflare.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
302
|
-
return new Response(
|
|
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
|
-
|
|
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
|
|
362
|
+
const body = await readCachedVerified(host.artifacts, host.uiCache, asset.artifactRef, asset.digest);
|
|
321
363
|
const scope = `${origin}${prefix}/`;
|
|
322
|
-
return new Response(
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
849
|
-
? resolved
|
|
850
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
* next request reads it again.
|
|
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
|
|
7
|
-
* next request reads it again.
|
|
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
|
-
|
|
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
|
};
|
package/dist/invocation.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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">;
|