@fungi.computer/oyster 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -8
- package/dist/agents.d.ts +2 -1
- package/dist/agents.js +67 -8
- package/dist/apps/gate.d.ts +4 -0
- package/dist/apps/gate.js +3 -0
- package/dist/auth.d.ts +24 -1
- package/dist/auth.js +38 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +27 -5
- package/dist/channels/binding.d.ts +70 -0
- package/dist/channels/binding.js +97 -0
- package/dist/channels/conversations.d.ts +246 -0
- package/dist/channels/conversations.js +705 -0
- package/dist/channels/ids.d.ts +8 -0
- package/dist/channels/ids.js +15 -0
- package/dist/channels/visitor-channel.d.ts +53 -0
- package/dist/channels/visitor-channel.js +124 -0
- package/dist/channels/visitor-edge.d.ts +115 -0
- package/dist/channels/visitor-edge.js +582 -0
- package/dist/channels/visitor-request.d.ts +7 -0
- package/dist/channels/visitor-request.js +17 -0
- package/dist/channels/visitor-stream-slots.d.ts +2 -0
- package/dist/channels/visitor-stream-slots.js +33 -0
- package/dist/channels/visitors.d.ts +47 -0
- package/dist/channels/visitors.js +181 -0
- package/dist/cloudflare.d.ts +8 -2
- package/dist/cloudflare.js +27 -1
- package/dist/computer-target.d.ts +5 -0
- package/dist/computer-target.js +11 -0
- package/dist/host.d.ts +7 -4
- package/dist/host.js +96 -17
- package/dist/internal/host-gate.d.ts +6 -4
- package/dist/internal/host-gate.js +14 -7
- package/dist/invocation.d.ts +11 -4
- package/dist/invocation.js +134 -91
- package/dist/keys.d.ts +4 -1
- package/dist/keys.js +15 -4
- package/dist/machine-link/authority.d.ts +7 -0
- package/dist/machine-link/delivery.d.ts +12 -0
- package/dist/machine-link/delivery.js +22 -1
- package/dist/machine-link/disposition.d.ts +16 -0
- package/dist/machine-link/disposition.js +65 -0
- package/dist/machine-link/host.d.ts +11 -2
- package/dist/machine-link/host.js +125 -5
- package/dist/machine-link/link.d.ts +4 -0
- package/dist/machine-link/link.js +47 -1
- package/dist/page/capabilities.js +1 -1
- package/dist/page/child-selection.d.ts +27 -0
- package/dist/page/child-selection.js +38 -0
- package/dist/page/mount.js +1 -1
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +13 -0
- package/dist/release.js +10 -0
- package/dist/request-origin.d.ts +4 -0
- package/dist/request-origin.js +10 -0
- package/dist/standalone/agent-mail.js +4 -1
- package/dist/standalone/agents.d.ts +8 -0
- package/dist/standalone/agents.js +30 -11
- package/dist/standalone/channel-session-door.d.ts +4 -0
- package/dist/standalone/channel-session-door.js +72 -0
- package/dist/standalone/computer-fleet.js +7 -1
- package/dist/standalone/machine-link.d.ts +2 -0
- package/dist/standalone/machine-link.js +34 -1
- package/dist/standalone/tenancy.d.ts +3 -0
- package/dist/standalone/tenancy.js +7 -2
- package/dist/testing/capabilities.d.ts +2 -0
- package/dist/testing/capabilities.js +3 -1
- package/dist/turnstile.d.ts +27 -0
- package/dist/turnstile.js +51 -0
- package/dist/workers-git.js +21 -2
- package/dist/workers.d.ts +3 -0
- package/dist/workers.js +15 -12
- package/docs/api/agents.md +8 -4
- package/docs/api/apps.md +8 -4
- package/docs/api/auth.md +55 -0
- package/docs/api/browser.md +40 -16
- package/docs/api/build-contract.md +2 -2
- package/docs/api/cloudflare.md +26 -1
- package/docs/api/host.md +22 -13
- package/docs/api/invocation.md +7 -3
- package/docs/api/keys.md +25 -3
- package/docs/api/workers.md +38 -30
- package/docs/site/agent-quickstart.mdx +29 -2
- package/examples/standalone/wrangler.celld.jsonc +66 -0
- package/examples/standalone/wrangler.jsonc +3 -4
- package/package.json +53 -17
|
@@ -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,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
|
|
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,
|
|
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
|
|
301
|
-
return new Response(
|
|
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
|
-
|
|
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
|
|
362
|
+
const body = await readCachedVerified(host.artifacts, host.uiCache, asset.artifactRef, asset.digest);
|
|
320
363
|
const scope = `${origin}${prefix}/`;
|
|
321
|
-
return new Response(
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
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
|
-
|
|
835
|
-
? resolved
|
|
836
|
-
|
|
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
|
-
|
|
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
|
|
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>;
|