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