@loopbacktools/server 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +26 -2
- package/dist/package.json +57 -0
- package/dist/src/domain/ask.d.ts +78 -0
- package/dist/src/domain/ask.js +161 -0
- package/dist/src/domain/errors.d.ts +70 -0
- package/dist/src/domain/errors.js +57 -0
- package/dist/src/generated/app.gen.d.ts +2311 -0
- package/dist/src/generated/app.gen.js +400 -0
- package/dist/src/generated/runtime.gen.d.ts +357 -0
- package/dist/src/generated/runtime.gen.js +298 -0
- package/dist/src/generated/schemas.gen.d.ts +6416 -0
- package/dist/src/generated/schemas.gen.js +306 -0
- package/dist/src/generated/wire-contract.gen.d.ts +77 -0
- package/dist/src/generated/wire-contract.gen.js +3 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +8 -0
- package/dist/src/loopback.d.ts +27 -0
- package/dist/src/loopback.js +16 -0
- package/dist/src/space/arguments.d.ts +8 -0
- package/dist/src/space/arguments.js +52 -0
- package/dist/src/space/migrations.d.ts +10 -0
- package/dist/src/space/migrations.js +106 -0
- package/dist/src/space/object.d.ts +30 -0
- package/dist/src/space/object.js +169 -0
- package/dist/src/space/operations.d.ts +149 -0
- package/dist/src/space/operations.js +568 -0
- package/dist/src/space/read.d.ts +8 -0
- package/dist/src/space/read.js +33 -0
- package/dist/src/space/rpc.d.ts +61 -0
- package/dist/src/space/rpc.js +96 -0
- package/dist/src/space/services.d.ts +41 -0
- package/dist/src/space/services.js +58 -0
- package/dist/src/space/store.d.ts +87 -0
- package/dist/src/space/store.js +276 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +3 -0
- package/dist/src/worker/env.d.ts +19 -0
- package/dist/src/worker/env.js +3 -0
- package/dist/src/worker/files.d.ts +21 -0
- package/dist/src/worker/files.js +42 -0
- package/dist/src/worker/gate.d.ts +55 -0
- package/dist/src/worker/gate.js +83 -0
- package/dist/src/worker/http.d.ts +2092 -0
- package/dist/src/worker/http.js +291 -0
- package/dist/src/worker/jwt.d.ts +49 -0
- package/dist/src/worker/jwt.js +142 -0
- package/dist/src/worker/mcp.d.ts +15 -0
- package/dist/src/worker/mcp.js +401 -0
- package/dist/src/worker/run.d.ts +45 -0
- package/dist/src/worker/run.js +71 -0
- package/dist/src/worker/spaces.d.ts +24 -0
- package/dist/src/worker/spaces.js +64 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bison Labs Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loopbacktools/server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Loopback server for Cloudflare Workers: the `Space` Durable Object that holds asks and their
|
|
4
|
+
threads, the REST API and event socket an inbox uses, the MCP tools agents call, and the gates that
|
|
5
|
+
decide who a request is from.
|
|
6
|
+
|
|
7
|
+
Most people deploy [Loopback](https://github.com/LoopBackTools/loopback) as it is. Use this package to
|
|
8
|
+
compose Loopback into a Worker of your own:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { ConfiguredGate, createLoopback } from "@loopbacktools/server";
|
|
12
|
+
|
|
13
|
+
export { Space } from "@loopbacktools/server";
|
|
14
|
+
|
|
15
|
+
export default createLoopback({ gate: ConfiguredGate });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Declare the `Space` class as a Durable Object with SQLite storage, bind an R2 bucket as `FILES`, and
|
|
19
|
+
set either `LOOPBACK_TOKEN` (one space behind one secret) or the JWT settings (a space per tenant
|
|
20
|
+
behind your login). [`docs/hosting.md`](https://github.com/LoopBackTools/loopback/blob/main/docs/hosting.md)
|
|
21
|
+
explains the JWT gate, OAuth for MCP clients, and wrapping the spaces to meter or decline what
|
|
22
|
+
tenants open.
|
|
23
|
+
|
|
24
|
+
The package is written in [Effect](https://effect.website) and compiled against
|
|
25
|
+
`@cloudflare/workers-types`; its types refer to the Workers runtime's globals.
|
|
26
|
+
|
|
27
|
+
MIT licensed.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loopbacktools/server",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "The Loopback server: Durable Object, HTTP API, event socket and MCP tools for Cloudflare Workers.",
|
|
5
|
+
"homepage": "https://github.com/LoopBackTools/loopback/tree/main/packages/server#readme",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/LoopBackTools/loopback.git",
|
|
10
|
+
"directory": "packages/server"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/src/index.d.ts",
|
|
23
|
+
"default": "./dist/src/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"types": "cd test && cf workers types",
|
|
30
|
+
"typecheck": "pnpm types && tsc --noEmit && tsc --noEmit -p test/arch",
|
|
31
|
+
"test": "pnpm types && vitest run",
|
|
32
|
+
"build": "tsc -p tsconfig.build.json"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@effect/sql-sqlite-do": "catalog:",
|
|
36
|
+
"@hono/zod-validator": "catalog:",
|
|
37
|
+
"@loopbacktools/protocol": "workspace:*",
|
|
38
|
+
"@modelcontextprotocol/server": "catalog:",
|
|
39
|
+
"effect": "catalog:",
|
|
40
|
+
"hono": "catalog:",
|
|
41
|
+
"jose": "catalog:",
|
|
42
|
+
"secure-json-parse": "catalog:",
|
|
43
|
+
"zod": "catalog:"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@cloudflare/vitest-plugin": "catalog:",
|
|
47
|
+
"@cloudflare/workers-types": "catalog:",
|
|
48
|
+
"@effect/vitest": "catalog:",
|
|
49
|
+
"@modelcontextprotocol/client": "catalog:",
|
|
50
|
+
"@types/node": "catalog:",
|
|
51
|
+
"archunit": "catalog:",
|
|
52
|
+
"cf": "catalog:",
|
|
53
|
+
"es-module-lexer": "catalog:",
|
|
54
|
+
"typescript": "catalog:",
|
|
55
|
+
"vitest": "catalog:"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type Actor, type Ask, type AskStatus, type Block, type BriefingInput, type BriefingPrompt, type Entry, type QuestionInput, type QuestionPrompt, type Reply, type ReviewInput, type ReviewPrompt } from "@loopbacktools/protocol";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { AskClosed, ContentInvalid, NotYourTurn, ReplyInvalid } from "./errors.ts";
|
|
4
|
+
/** An ask as the space stores it: everything but its thread and whether an agent is waiting. */
|
|
5
|
+
export type AskRecord = AskHead & ({
|
|
6
|
+
readonly kind: "question";
|
|
7
|
+
readonly prompt: QuestionPrompt;
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: "review";
|
|
10
|
+
readonly prompt: ReviewPrompt;
|
|
11
|
+
readonly revises: string | null;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "briefing";
|
|
14
|
+
readonly prompt: BriefingPrompt;
|
|
15
|
+
});
|
|
16
|
+
interface AskHead {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
/** What a retried call is recognised by. See `fingerprint`. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly status: AskStatus;
|
|
21
|
+
readonly project: string | null;
|
|
22
|
+
readonly client: string | null;
|
|
23
|
+
/** Whose agent asked, when the deployment's gate knows. */
|
|
24
|
+
readonly raisedBy: Actor | null;
|
|
25
|
+
readonly createdAt: string;
|
|
26
|
+
readonly updatedAt: string;
|
|
27
|
+
readonly closedAt: string | null;
|
|
28
|
+
/** The last time an agent call was blocked on this ask. */
|
|
29
|
+
readonly lastWaitedAt: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** One turn of a thread as stored, with whether a human reply has been handed to an agent. */
|
|
32
|
+
export interface EntryRecord {
|
|
33
|
+
readonly askId: string;
|
|
34
|
+
readonly seq: number;
|
|
35
|
+
readonly entry: Entry;
|
|
36
|
+
readonly deliveredAt: string | null;
|
|
37
|
+
}
|
|
38
|
+
/** How long a retried call may still join the ask it opened, measured from its last wait. */
|
|
39
|
+
export declare const REATTACH_WINDOW_MS: number;
|
|
40
|
+
/**
|
|
41
|
+
* The most an agent's opening or answer may hold, as JSON. Each is one SQLite row, which holds at
|
|
42
|
+
* most 2 MB, and the snapshot an inbox starts from carries every open ask in one WebSocket frame.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MAX_ASK_BYTES: number;
|
|
45
|
+
/**
|
|
46
|
+
* Refuses what an agent sends when it is too large to keep and show, with advice that fits what it
|
|
47
|
+
* was sending: an ask can be split into several, an answer can only show less.
|
|
48
|
+
*/
|
|
49
|
+
export declare const checkSize: (content: unknown, advice?: string) => Effect.Effect<void, ContentInvalid>;
|
|
50
|
+
export declare const questionPrompt: (input: QuestionInput) => QuestionPrompt;
|
|
51
|
+
export declare const reviewPrompt: (input: ReviewInput) => ReviewPrompt;
|
|
52
|
+
/** A briefing as it opens, its points numbered; refused when it holds more than the inbox shows. */
|
|
53
|
+
export declare const briefingPrompt: (input: BriefingInput) => Effect.Effect<BriefingPrompt, ContentInvalid>;
|
|
54
|
+
/** The uploads a set of blocks shows. */
|
|
55
|
+
export declare const imagesIn: (blocks: readonly Block[]) => string[];
|
|
56
|
+
/** The ask in its wire shape. */
|
|
57
|
+
export declare const toAsk: (record: AskRecord, thread: readonly EntryRecord[], waiting: boolean) => Ask;
|
|
58
|
+
/**
|
|
59
|
+
* Where an ask stands once a reply lands: open for the agent after a follow-up, or after replies on a
|
|
60
|
+
* briefing that leave a point unresolved; closed otherwise.
|
|
61
|
+
*/
|
|
62
|
+
export declare const statusAfter: (record: AskRecord, thread: readonly Entry[], reply: Reply) => AskStatus;
|
|
63
|
+
/**
|
|
64
|
+
* Whether a person may make this reply now. The rules the contract cannot state as a schema:
|
|
65
|
+
* whose turn it is, which outcomes fit which kind, that an answer's choices are among those
|
|
66
|
+
* offered, and that replies on a briefing fit its points. A briefing may be dismissed even while
|
|
67
|
+
* the agent owes an answer, so one whose agent has gone never stays open for good.
|
|
68
|
+
*/
|
|
69
|
+
export declare const checkReply: (record: AskRecord, thread: readonly Entry[], reply: Reply) => Effect.Effect<void, AskClosed | NotYourTurn | ReplyInvalid>;
|
|
70
|
+
/**
|
|
71
|
+
* What identifies a call as a retry of one already made: the hex SHA-256 of what was asked, by
|
|
72
|
+
* which client, for which project. Equal inputs from another session share it too, which is why a
|
|
73
|
+
* match only counts inside `REATTACH_WINDOW_MS`.
|
|
74
|
+
*/
|
|
75
|
+
export declare const fingerprint: (subject: unknown) => Effect.Effect<string>;
|
|
76
|
+
/** The hex SHA-256 of some text: how secrets are kept at rest, and how asks are fingerprinted. */
|
|
77
|
+
export declare const sha256: (text: string) => Effect.Effect<string>;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { foldBriefing, MAX_TOP_POINTS, numberBriefing, respondedProblem, } from "@loopbacktools/protocol";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { AskClosed, ContentInvalid, NotYourTurn, ReplyInvalid } from "./errors.js";
|
|
4
|
+
/** How long a retried call may still join the ask it opened, measured from its last wait. */
|
|
5
|
+
export const REATTACH_WINDOW_MS = 60 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* The most an agent's opening or answer may hold, as JSON. Each is one SQLite row, which holds at
|
|
8
|
+
* most 2 MB, and the snapshot an inbox starts from carries every open ask in one WebSocket frame.
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_ASK_BYTES = 256 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Refuses what an agent sends when it is too large to keep and show, with advice that fits what it
|
|
13
|
+
* was sending: an ask can be split into several, an answer can only show less.
|
|
14
|
+
*/
|
|
15
|
+
export const checkSize = (content, advice = "Show the part that matters.") => {
|
|
16
|
+
const bytes = new TextEncoder().encode(JSON.stringify(content)).byteLength;
|
|
17
|
+
return bytes <= MAX_ASK_BYTES
|
|
18
|
+
? Effect.void
|
|
19
|
+
: Effect.fail(new ContentInvalid({
|
|
20
|
+
reason: `This is too large to show: ${Math.ceil(bytes / 1024)} KiB, where the most is ${MAX_ASK_BYTES / 1024} KiB. ${advice}`,
|
|
21
|
+
}));
|
|
22
|
+
};
|
|
23
|
+
export const questionPrompt = (input) => ({
|
|
24
|
+
question: input.question,
|
|
25
|
+
options: input.options ?? [],
|
|
26
|
+
multiple: input.multiple ?? false,
|
|
27
|
+
...(input.context === undefined ? {} : { context: input.context }),
|
|
28
|
+
});
|
|
29
|
+
export const reviewPrompt = (input) => ({
|
|
30
|
+
title: input.title,
|
|
31
|
+
content: input.content,
|
|
32
|
+
...(input.summary === undefined ? {} : { summary: input.summary }),
|
|
33
|
+
});
|
|
34
|
+
/** A briefing as it opens, its points numbered; refused when it holds more than the inbox shows. */
|
|
35
|
+
export const briefingPrompt = (input) => {
|
|
36
|
+
const count = input.sections.reduce((sum, section) => sum + section.points.length, 0);
|
|
37
|
+
return count <= MAX_TOP_POINTS
|
|
38
|
+
? Effect.succeed(numberBriefing(input))
|
|
39
|
+
: Effect.fail(new ContentInvalid({
|
|
40
|
+
reason: `This briefing has ${count} points, where the most is ${MAX_TOP_POINTS}. Split it into several briefings, or group points as sub-points once the person replies.`,
|
|
41
|
+
}));
|
|
42
|
+
};
|
|
43
|
+
/** The uploads a set of blocks shows. */
|
|
44
|
+
export const imagesIn = (blocks) => blocks.flatMap((block) => (block.type === "image" ? [block.uploadId] : []));
|
|
45
|
+
/** The ask in its wire shape. */
|
|
46
|
+
export const toAsk = (record, thread, waiting) => {
|
|
47
|
+
const common = {
|
|
48
|
+
id: record.id,
|
|
49
|
+
status: record.status,
|
|
50
|
+
waiting,
|
|
51
|
+
thread: thread.map((row) => row.entry),
|
|
52
|
+
createdAt: record.createdAt,
|
|
53
|
+
updatedAt: record.updatedAt,
|
|
54
|
+
...(record.project === null ? {} : { project: record.project }),
|
|
55
|
+
...(record.client === null ? {} : { client: record.client }),
|
|
56
|
+
...(record.raisedBy === null ? {} : { raisedBy: record.raisedBy }),
|
|
57
|
+
...(record.closedAt === null ? {} : { closedAt: record.closedAt }),
|
|
58
|
+
};
|
|
59
|
+
switch (record.kind) {
|
|
60
|
+
case "question":
|
|
61
|
+
return { kind: "question", prompt: record.prompt, ...common };
|
|
62
|
+
case "review":
|
|
63
|
+
return {
|
|
64
|
+
kind: "review",
|
|
65
|
+
prompt: record.prompt,
|
|
66
|
+
...(record.revises === null ? {} : { revises: record.revises }),
|
|
67
|
+
...common,
|
|
68
|
+
};
|
|
69
|
+
case "briefing":
|
|
70
|
+
return { kind: "briefing", prompt: record.prompt, ...common };
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Where an ask stands once a reply lands: open for the agent after a follow-up, or after replies on a
|
|
75
|
+
* briefing that leave a point unresolved; closed otherwise.
|
|
76
|
+
*/
|
|
77
|
+
export const statusAfter = (record, thread, reply) => {
|
|
78
|
+
if (reply.outcome === "follow_up")
|
|
79
|
+
return "awaiting_agent";
|
|
80
|
+
if (reply.outcome !== "responded" || record.kind !== "briefing")
|
|
81
|
+
return "closed";
|
|
82
|
+
const after = foldBriefing(record.prompt, [...thread, { author: "human", reply, at: "" }]);
|
|
83
|
+
return after.resolved ? "closed" : "awaiting_agent";
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Whether a person may make this reply now. The rules the contract cannot state as a schema:
|
|
87
|
+
* whose turn it is, which outcomes fit which kind, that an answer's choices are among those
|
|
88
|
+
* offered, and that replies on a briefing fit its points. A briefing may be dismissed even while
|
|
89
|
+
* the agent owes an answer, so one whose agent has gone never stays open for good.
|
|
90
|
+
*/
|
|
91
|
+
export const checkReply = (record, thread, reply) => {
|
|
92
|
+
if (record.status === "closed")
|
|
93
|
+
return Effect.fail(new AskClosed({ askId: record.id }));
|
|
94
|
+
if (record.status === "awaiting_agent" &&
|
|
95
|
+
!(record.kind === "briefing" && reply.outcome === "dismissed")) {
|
|
96
|
+
return Effect.fail(new NotYourTurn({ askId: record.id, turn: "agent" }));
|
|
97
|
+
}
|
|
98
|
+
const invalid = (reason) => Effect.fail(new ReplyInvalid({ reason }));
|
|
99
|
+
switch (reply.outcome) {
|
|
100
|
+
case "dismissed":
|
|
101
|
+
return Effect.void;
|
|
102
|
+
case "follow_up":
|
|
103
|
+
return record.kind === "briefing"
|
|
104
|
+
? invalid("A briefing takes replies on its points, not a question back: write on a point.")
|
|
105
|
+
: Effect.void;
|
|
106
|
+
case "responded": {
|
|
107
|
+
if (record.kind !== "briefing") {
|
|
108
|
+
return invalid(`"responded" is a reply to a briefing, and this ask is a ${record.kind}.`);
|
|
109
|
+
}
|
|
110
|
+
const problem = respondedProblem(record.prompt, thread, reply);
|
|
111
|
+
return problem === null ? Effect.void : invalid(problem);
|
|
112
|
+
}
|
|
113
|
+
case "approved":
|
|
114
|
+
case "changes_requested":
|
|
115
|
+
return record.kind === "review"
|
|
116
|
+
? Effect.void
|
|
117
|
+
: invalid(`"${reply.outcome}" is a reply to a review, and this ask is a ${record.kind}.`);
|
|
118
|
+
case "answered": {
|
|
119
|
+
if (record.kind !== "question") {
|
|
120
|
+
return invalid(`"answered" is a reply to a question, and this ask is a ${record.kind}.`);
|
|
121
|
+
}
|
|
122
|
+
const offered = new Set(record.prompt.options.map((option) => option.label));
|
|
123
|
+
const unknown = reply.selected.filter((label) => !offered.has(label));
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
return invalid(`Not among the choices offered: ${unknown.map((l) => `"${l}"`).join(", ")}.`);
|
|
126
|
+
}
|
|
127
|
+
if (new Set(reply.selected).size !== reply.selected.length) {
|
|
128
|
+
return invalid("A choice is selected more than once.");
|
|
129
|
+
}
|
|
130
|
+
if (!record.prompt.multiple && reply.selected.length > 1) {
|
|
131
|
+
return invalid("This question takes one choice.");
|
|
132
|
+
}
|
|
133
|
+
if (reply.selected.length === 0 && (reply.text ?? "").trim() === "") {
|
|
134
|
+
return invalid("An answer needs a choice or some text.");
|
|
135
|
+
}
|
|
136
|
+
return Effect.void;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/** Object keys sorted at every depth, so equal inputs serialise equally however they were built. */
|
|
141
|
+
const canonical = (value) => {
|
|
142
|
+
if (Array.isArray(value))
|
|
143
|
+
return value.map(canonical);
|
|
144
|
+
if (typeof value !== "object" || value === null)
|
|
145
|
+
return value;
|
|
146
|
+
return Object.fromEntries(Object.entries(value)
|
|
147
|
+
.filter(([, field]) => field !== undefined)
|
|
148
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
149
|
+
.map(([key, field]) => [key, canonical(field)]));
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* What identifies a call as a retry of one already made: the hex SHA-256 of what was asked, by
|
|
153
|
+
* which client, for which project. Equal inputs from another session share it too, which is why a
|
|
154
|
+
* match only counts inside `REATTACH_WINDOW_MS`.
|
|
155
|
+
*/
|
|
156
|
+
export const fingerprint = (subject) => sha256(JSON.stringify(canonical(subject)));
|
|
157
|
+
/** The hex SHA-256 of some text: how secrets are kept at rest, and how asks are fingerprinted. */
|
|
158
|
+
export const sha256 = (text) => Effect.promise(async () => {
|
|
159
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
160
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
161
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
declare const AskNotFound_base: Schema.Class<AskNotFound, Schema.TaggedStruct<"AskNotFound", {
|
|
3
|
+
readonly askId: Schema.String;
|
|
4
|
+
}>, import("effect/Cause").YieldableError>;
|
|
5
|
+
/** No ask with this id in the space. */
|
|
6
|
+
export declare class AskNotFound extends AskNotFound_base {
|
|
7
|
+
}
|
|
8
|
+
declare const AskClosed_base: Schema.Class<AskClosed, Schema.TaggedStruct<"AskClosed", {
|
|
9
|
+
readonly askId: Schema.String;
|
|
10
|
+
}>, import("effect/Cause").YieldableError>;
|
|
11
|
+
/** The ask has already been closed by a reply. */
|
|
12
|
+
export declare class AskClosed extends AskClosed_base {
|
|
13
|
+
}
|
|
14
|
+
declare const NotYourTurn_base: Schema.Class<NotYourTurn, Schema.TaggedStruct<"NotYourTurn", {
|
|
15
|
+
readonly askId: Schema.String;
|
|
16
|
+
readonly turn: Schema.Literals<readonly ["human", "agent"]>;
|
|
17
|
+
}>, import("effect/Cause").YieldableError>;
|
|
18
|
+
/** The move belongs to the other party: a reply while the agent owes an answer, or the reverse. */
|
|
19
|
+
export declare class NotYourTurn extends NotYourTurn_base {
|
|
20
|
+
}
|
|
21
|
+
declare const ReplyInvalid_base: Schema.Class<ReplyInvalid, Schema.TaggedStruct<"ReplyInvalid", {
|
|
22
|
+
readonly reason: Schema.String;
|
|
23
|
+
}>, import("effect/Cause").YieldableError>;
|
|
24
|
+
/** A reply that does not fit the ask it answers. */
|
|
25
|
+
export declare class ReplyInvalid extends ReplyInvalid_base {
|
|
26
|
+
}
|
|
27
|
+
declare const ContentInvalid_base: Schema.Class<ContentInvalid, Schema.TaggedStruct<"ContentInvalid", {
|
|
28
|
+
readonly reason: Schema.String;
|
|
29
|
+
}>, import("effect/Cause").YieldableError>;
|
|
30
|
+
/** Review content that refers to something the space does not hold. */
|
|
31
|
+
export declare class ContentInvalid extends ContentInvalid_base {
|
|
32
|
+
}
|
|
33
|
+
declare const TicketInvalid_base: Schema.Class<TicketInvalid, Schema.TaggedStruct<"TicketInvalid", {}>, import("effect/Cause").YieldableError>;
|
|
34
|
+
/** No event ticket matches: it was never issued, has been used, or has expired. */
|
|
35
|
+
export declare class TicketInvalid extends TicketInvalid_base {
|
|
36
|
+
}
|
|
37
|
+
declare const UploadNotFound_base: Schema.Class<UploadNotFound, Schema.TaggedStruct<"UploadNotFound", {}>, import("effect/Cause").YieldableError>;
|
|
38
|
+
/** No upload, or upload ticket, matches. */
|
|
39
|
+
export declare class UploadNotFound extends UploadNotFound_base {
|
|
40
|
+
}
|
|
41
|
+
declare const UploadExpired_base: Schema.Class<UploadExpired, Schema.TaggedStruct<"UploadExpired", {}>, import("effect/Cause").YieldableError>;
|
|
42
|
+
/** The upload ticket has expired or was already used. */
|
|
43
|
+
export declare class UploadExpired extends UploadExpired_base {
|
|
44
|
+
}
|
|
45
|
+
declare const UploadIncomplete_base: Schema.Class<UploadIncomplete, Schema.TaggedStruct<"UploadIncomplete", {}>, import("effect/Cause").YieldableError>;
|
|
46
|
+
/** The upload's body ended before, or ran past, the length it declared. */
|
|
47
|
+
export declare class UploadIncomplete extends UploadIncomplete_base {
|
|
48
|
+
}
|
|
49
|
+
declare const UploadRejected_base: Schema.Class<UploadRejected, Schema.TaggedStruct<"UploadRejected", {
|
|
50
|
+
readonly reason: Schema.String;
|
|
51
|
+
}>, import("effect/Cause").YieldableError>;
|
|
52
|
+
/** The upload's content type is not the one its ticket was issued for. */
|
|
53
|
+
export declare class UploadRejected extends UploadRejected_base {
|
|
54
|
+
}
|
|
55
|
+
declare const Declined_base: Schema.Class<Declined, Schema.TaggedStruct<"Declined", {
|
|
56
|
+
readonly reason: Schema.String;
|
|
57
|
+
}>, import("effect/Cause").YieldableError>;
|
|
58
|
+
/**
|
|
59
|
+
* The deployment declined to open an ask or an upload, as a host's limits can: the space itself never
|
|
60
|
+
* declines. The reason is written for the agent, and says what would let it through.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Declined extends Declined_base {
|
|
63
|
+
}
|
|
64
|
+
declare const Unauthorized_base: Schema.Class<Unauthorized, Schema.TaggedStruct<"Unauthorized", {
|
|
65
|
+
readonly reason: Schema.String;
|
|
66
|
+
}>, import("effect/Cause").YieldableError>;
|
|
67
|
+
/** The request carries no credentials the gate accepts. */
|
|
68
|
+
export declare class Unauthorized extends Unauthorized_base {
|
|
69
|
+
}
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
/** No ask with this id in the space. */
|
|
3
|
+
export class AskNotFound extends Schema.TaggedError()("AskNotFound", {
|
|
4
|
+
askId: Schema.String,
|
|
5
|
+
}) {
|
|
6
|
+
}
|
|
7
|
+
/** The ask has already been closed by a reply. */
|
|
8
|
+
export class AskClosed extends Schema.TaggedError()("AskClosed", {
|
|
9
|
+
askId: Schema.String,
|
|
10
|
+
}) {
|
|
11
|
+
}
|
|
12
|
+
/** The move belongs to the other party: a reply while the agent owes an answer, or the reverse. */
|
|
13
|
+
export class NotYourTurn extends Schema.TaggedError()("NotYourTurn", {
|
|
14
|
+
askId: Schema.String,
|
|
15
|
+
turn: Schema.Literals(["human", "agent"]),
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
18
|
+
/** A reply that does not fit the ask it answers. */
|
|
19
|
+
export class ReplyInvalid extends Schema.TaggedError()("ReplyInvalid", {
|
|
20
|
+
reason: Schema.String,
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
/** Review content that refers to something the space does not hold. */
|
|
24
|
+
export class ContentInvalid extends Schema.TaggedError()("ContentInvalid", {
|
|
25
|
+
reason: Schema.String,
|
|
26
|
+
}) {
|
|
27
|
+
}
|
|
28
|
+
/** No event ticket matches: it was never issued, has been used, or has expired. */
|
|
29
|
+
export class TicketInvalid extends Schema.TaggedError()("TicketInvalid", {}) {
|
|
30
|
+
}
|
|
31
|
+
/** No upload, or upload ticket, matches. */
|
|
32
|
+
export class UploadNotFound extends Schema.TaggedError()("UploadNotFound", {}) {
|
|
33
|
+
}
|
|
34
|
+
/** The upload ticket has expired or was already used. */
|
|
35
|
+
export class UploadExpired extends Schema.TaggedError()("UploadExpired", {}) {
|
|
36
|
+
}
|
|
37
|
+
/** The upload's body ended before, or ran past, the length it declared. */
|
|
38
|
+
export class UploadIncomplete extends Schema.TaggedError()("UploadIncomplete", {}) {
|
|
39
|
+
}
|
|
40
|
+
/** The upload's content type is not the one its ticket was issued for. */
|
|
41
|
+
export class UploadRejected extends Schema.TaggedError()("UploadRejected", {
|
|
42
|
+
reason: Schema.String,
|
|
43
|
+
}) {
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The deployment declined to open an ask or an upload, as a host's limits can: the space itself never
|
|
47
|
+
* declines. The reason is written for the agent, and says what would let it through.
|
|
48
|
+
*/
|
|
49
|
+
export class Declined extends Schema.TaggedError()("Declined", {
|
|
50
|
+
reason: Schema.String,
|
|
51
|
+
}) {
|
|
52
|
+
}
|
|
53
|
+
/** The request carries no credentials the gate accepts. */
|
|
54
|
+
export class Unauthorized extends Schema.TaggedError()("Unauthorized", {
|
|
55
|
+
reason: Schema.String,
|
|
56
|
+
}) {
|
|
57
|
+
}
|