@boxxy/server 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/dist/auth/authorize-call.d.ts +11 -0
  4. package/dist/auth/is-browser-request.d.ts +1 -0
  5. package/dist/auth/pick-request-scope.d.ts +9 -0
  6. package/dist/config/pick-server-secrets.d.ts +16 -0
  7. package/dist/config/read-caller-proof.d.ts +10 -0
  8. package/dist/config/read-server-config.d.ts +9 -0
  9. package/dist/config/read-trusted-issuers.d.ts +8 -0
  10. package/dist/config/server-config-schema.d.ts +42 -0
  11. package/dist/create-capacity-backoff.d.ts +2 -0
  12. package/dist/handlers/api.d.ts +1683 -0
  13. package/dist/handlers/attempt-handlers.d.ts +130 -0
  14. package/dist/handlers/build-owner-fact.d.ts +3 -0
  15. package/dist/handlers/item-handlers.d.ts +168 -0
  16. package/dist/handlers/outbox-handlers.d.ts +7 -0
  17. package/dist/handlers/plan-handlers.d.ts +62 -0
  18. package/dist/handlers/read-item-ids.d.ts +10 -0
  19. package/dist/handlers/relation-handlers.d.ts +41 -0
  20. package/dist/handlers/report-handlers.d.ts +102 -0
  21. package/dist/handlers/rules-handlers.d.ts +20 -0
  22. package/dist/handlers/runner-handlers.d.ts +65 -0
  23. package/dist/handlers/to-wire-json.d.ts +5 -0
  24. package/dist/handlers/write-levers.d.ts +11 -0
  25. package/dist/index.d.ts +12 -0
  26. package/dist/main.d.ts +1 -0
  27. package/dist/no-caller-proof.d.ts +2 -0
  28. package/dist/outbox/create-off-outbox.d.ts +3 -0
  29. package/dist/outbox/start-outbox-sender.d.ts +10 -0
  30. package/dist/read-limited-request.d.ts +1 -0
  31. package/dist/rules/create-rules-holder.d.ts +20 -0
  32. package/dist/rules/load-server-rules.d.ts +7 -0
  33. package/dist/rules/read-rules-checkout.d.ts +9 -0
  34. package/dist/serve-api.d.ts +50 -0
  35. package/dist/start-server.d.ts +18 -0
  36. package/dist/test-utils/build-mcp-request.d.ts +8 -0
  37. package/dist/test-utils/build-rpc-request.d.ts +7 -0
  38. package/dist/test-utils/build-session-input.d.ts +1 -0
  39. package/dist/test-utils/create-fake-caller-proof.d.ts +5 -0
  40. package/dist/test-utils/create-test-server.d.ts +30 -0
  41. package/dist/test-utils/list-owner-procedures.d.ts +5 -0
  42. package/dist/test-utils/read-mcp-answer.d.ts +11 -0
  43. package/dist/test-utils/wait-for.d.ts +1 -0
  44. package/dist/tick/run-tick.d.ts +24 -0
  45. package/dist/tick/start-tick-loop.d.ts +12 -0
  46. package/dist/types.d.ts +41 -0
  47. package/package.json +62 -4
  48. package/src/auth/authorize-call.ts +50 -0
  49. package/src/auth/is-browser-request.ts +8 -0
  50. package/src/auth/pick-request-scope.ts +37 -0
  51. package/src/config/pick-server-secrets.ts +48 -0
  52. package/src/config/read-caller-proof.ts +36 -0
  53. package/src/config/read-server-config.ts +41 -0
  54. package/src/config/read-trusted-issuers.ts +60 -0
  55. package/src/config/server-config-schema.ts +71 -0
  56. package/src/create-capacity-backoff.ts +18 -0
  57. package/src/handlers/api.ts +23 -0
  58. package/src/handlers/attempt-handlers.ts +244 -0
  59. package/src/handlers/build-owner-fact.ts +20 -0
  60. package/src/handlers/item-handlers.ts +462 -0
  61. package/src/handlers/outbox-handlers.ts +15 -0
  62. package/src/handlers/plan-handlers.ts +75 -0
  63. package/src/handlers/read-item-ids.ts +31 -0
  64. package/src/handlers/relation-handlers.ts +143 -0
  65. package/src/handlers/report-handlers.ts +112 -0
  66. package/src/handlers/rules-handlers.ts +46 -0
  67. package/src/handlers/runner-handlers.ts +117 -0
  68. package/src/handlers/to-wire-json.ts +20 -0
  69. package/src/handlers/write-levers.ts +53 -0
  70. package/src/index.ts +12 -0
  71. package/src/main.ts +31 -0
  72. package/src/no-caller-proof.ts +7 -0
  73. package/src/outbox/create-off-outbox.ts +21 -0
  74. package/src/outbox/start-outbox-sender.ts +89 -0
  75. package/src/read-limited-request.ts +43 -0
  76. package/src/rules/create-rules-holder.ts +107 -0
  77. package/src/rules/load-server-rules.ts +24 -0
  78. package/src/rules/read-rules-checkout.ts +65 -0
  79. package/src/serve-api.ts +178 -0
  80. package/src/start-server.ts +308 -0
  81. package/src/test-utils/build-mcp-request.ts +34 -0
  82. package/src/test-utils/build-rpc-request.ts +23 -0
  83. package/src/test-utils/build-session-input.ts +16 -0
  84. package/src/test-utils/create-fake-caller-proof.ts +18 -0
  85. package/src/test-utils/create-test-server.ts +142 -0
  86. package/src/test-utils/list-owner-procedures.ts +38 -0
  87. package/src/test-utils/read-mcp-answer.ts +26 -0
  88. package/src/test-utils/wait-for.ts +14 -0
  89. package/src/tick/run-tick.ts +212 -0
  90. package/src/tick/start-tick-loop.ts +46 -0
  91. package/src/types.ts +86 -0
@@ -0,0 +1,43 @@
1
+ import invariant from 'tiny-invariant';
2
+
3
+ // The request with its body read whole, or null when the body is over `maxBytes`. It stops
4
+ // reading at the first byte over the limit, so a streamed body with no length header cannot fill
5
+ // the memory: Bun's own body limit lets such a body through.
6
+ export async function readLimitedRequest(
7
+ request: Request,
8
+ maxBytes: number,
9
+ ): Promise<Request | null> {
10
+ if (Number(request.headers.get('content-length') ?? 0) > maxBytes) {
11
+ return null;
12
+ }
13
+
14
+ if (request.body === null) {
15
+ return request;
16
+ }
17
+
18
+ const chunks: Uint8Array[] = [];
19
+ let total = 0;
20
+ const reader = request.body.getReader();
21
+
22
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
23
+ const chunk: unknown = read.value;
24
+
25
+ invariant(chunk instanceof Uint8Array, 'a request body streams bytes');
26
+
27
+ total += chunk.byteLength;
28
+
29
+ if (total > maxBytes) {
30
+ await reader.cancel();
31
+
32
+ return null;
33
+ }
34
+
35
+ chunks.push(chunk);
36
+ }
37
+
38
+ return new Request(request.url, {
39
+ method: request.method,
40
+ headers: request.headers,
41
+ body: Buffer.concat(chunks),
42
+ });
43
+ }
@@ -0,0 +1,107 @@
1
+ import { hashTree } from '@boxxy/content-hash';
2
+ import type { LoadRejection, LoadResult } from '@boxxy/model';
3
+ import type { RulesState, ServerLog } from '../types.ts';
4
+ import type { RulesCheckout } from './load-server-rules.ts';
5
+ import { loadServerRules, makeProposedRulesLoader } from './load-server-rules.ts';
6
+ import { readRulesCheckout } from './read-rules-checkout.ts';
7
+
8
+ interface RulesHolder {
9
+ readonly state: () => RulesState;
10
+
11
+ // the dry run's load, beside the checkout the live rules came from
12
+ readonly loadProposed: (text: string) => LoadResult;
13
+
14
+ // the text of each file of the checkout the live rules came from, by its path, for the prompts
15
+ readonly texts: () => ReadonlyMap<string, string>;
16
+
17
+ // reads the checkout again, and loads it when it changed; a refused load keeps the rules
18
+ readonly reload: () => Promise<void>;
19
+ }
20
+
21
+ export type RulesHolderAnswer =
22
+ | { readonly kind: 'loaded'; readonly holder: RulesHolder }
23
+ | { readonly kind: 'rejected'; readonly reason: string }
24
+ | { readonly kind: 'refused'; readonly rejections: readonly LoadRejection[] };
25
+
26
+ interface Held {
27
+ readonly state: RulesState;
28
+ readonly checkout: RulesCheckout;
29
+ readonly signature: string;
30
+ }
31
+
32
+ // Loads the rules folder once, and swaps the rules, their checkout and the last refusal together
33
+ // on each reload, so a dry run never pairs new rules with an old checkout.
34
+ export async function createRulesHolder(
35
+ folder: string,
36
+ log: ServerLog,
37
+ ): Promise<RulesHolderAnswer> {
38
+ const first = await readRulesCheckout(folder);
39
+
40
+ if (first.kind === 'rejected') {
41
+ return { kind: 'rejected', reason: first.reason };
42
+ }
43
+
44
+ const loaded = loadServerRules(first.checkout);
45
+
46
+ if (loaded.kind === 'rejected') {
47
+ return { kind: 'refused', rejections: loaded.rejections };
48
+ }
49
+
50
+ let held: Held = {
51
+ state: { rules: loaded.rules, lastRejected: null },
52
+ checkout: first.checkout,
53
+ signature: buildSignature(first.checkout),
54
+ };
55
+
56
+ const reload = async () => {
57
+ const read = await readRulesCheckout(folder);
58
+
59
+ if (read.kind === 'rejected') {
60
+ log.warn('the rules checkout could not be read; the rules stay', { reason: read.reason });
61
+
62
+ return;
63
+ }
64
+
65
+ const signature = buildSignature(read.checkout);
66
+
67
+ if (signature === held.signature) {
68
+ return;
69
+ }
70
+
71
+ const next = loadServerRules(read.checkout);
72
+
73
+ if (next.kind === 'rejected') {
74
+ log.warn('the rules load was refused; the rules stay', { commit: read.checkout.commit });
75
+
76
+ const lastRejected = { commit: read.checkout.commit, rejections: next.rejections };
77
+
78
+ held = { ...held, signature, state: { ...held.state, lastRejected } };
79
+
80
+ return;
81
+ }
82
+
83
+ held = { state: { rules: next.rules, lastRejected: null }, checkout: read.checkout, signature };
84
+ };
85
+
86
+ return {
87
+ kind: 'loaded',
88
+ holder: {
89
+ state: () => held.state,
90
+ loadProposed: (text) => makeProposedRulesLoader(held.checkout)(text),
91
+ texts: () => toTexts(held.checkout),
92
+ reload,
93
+ },
94
+ };
95
+ }
96
+
97
+ function buildSignature(checkout: RulesCheckout): string {
98
+ const files = [...checkout.files].map(([path, bytes]) => ({ path, bytes, executable: false }));
99
+
100
+ return `${checkout.commit}:${hashTree(files)}`;
101
+ }
102
+
103
+ function toTexts(checkout: RulesCheckout): ReadonlyMap<string, string> {
104
+ const decoder = new TextDecoder();
105
+
106
+ return new Map([...checkout.files].map(([path, bytes]) => [path, decoder.decode(bytes)]));
107
+ }
@@ -0,0 +1,24 @@
1
+ import { phaseKinds } from '@boxxy/closeout';
2
+ import type { LoadResult } from '@boxxy/model';
3
+ import { conceptPins, concepts } from '@boxxy/planning';
4
+ import { loadRules, rulesFileName } from '@boxxy/rules';
5
+
6
+ export interface RulesCheckout {
7
+ // the commit of a git checkout, or `folder` for a plain folder
8
+ readonly commit: string;
9
+ readonly files: ReadonlyMap<string, Uint8Array>;
10
+ }
11
+
12
+ // Loads a checkout with the concepts and the phase kinds that this server registers.
13
+ export function loadServerRules(checkout: RulesCheckout): LoadResult {
14
+ return loadRules({ checkout, concepts, pins: conceptPins, phases: phaseKinds });
15
+ }
16
+
17
+ // The dry run's load: the server's own checkout with the given text as its rules file.
18
+ export function makeProposedRulesLoader(checkout: RulesCheckout): (text: string) => LoadResult {
19
+ return (text) =>
20
+ loadServerRules({
21
+ commit: checkout.commit,
22
+ files: new Map([...checkout.files, [rulesFileName, new TextEncoder().encode(text)]]),
23
+ });
24
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { $ } from 'bun';
4
+ import type { RulesCheckout } from './load-server-rules.ts';
5
+
6
+ export type CheckoutAnswer =
7
+ | { readonly kind: 'read'; readonly checkout: RulesCheckout }
8
+ | { readonly kind: 'rejected'; readonly reason: string };
9
+
10
+ // A git checkout gives its committed files at HEAD, and a checkout with uncommitted changes is
11
+ // refused. A folder with no `.git` gives every file that is not hidden.
12
+ export function readRulesCheckout(folder: string): Promise<CheckoutAnswer> {
13
+ return existsSync(path.join(folder, '.git')) ? readGitCheckout(folder) : readFolder(folder);
14
+ }
15
+
16
+ async function readGitCheckout(folder: string): Promise<CheckoutAnswer> {
17
+ const status = await $`git -C ${folder} status --porcelain`.quiet().nothrow();
18
+
19
+ if (status.exitCode !== 0) {
20
+ return { kind: 'rejected', reason: `git could not read ${folder}` };
21
+ }
22
+
23
+ if (status.text().trim() !== '') {
24
+ return { kind: 'rejected', reason: 'the rules checkout has uncommitted changes' };
25
+ }
26
+
27
+ const commit = await $`git -C ${folder} rev-parse HEAD`.quiet().text();
28
+ const listed = await $`git -C ${folder} ls-files -z`.quiet().text();
29
+
30
+ const paths = listed.split('\0').filter((entry) => entry !== '');
31
+
32
+ const files = await readFiles(folder, paths);
33
+
34
+ return { kind: 'read', checkout: { commit: commit.trim(), files } };
35
+ }
36
+
37
+ async function readFiles(
38
+ folder: string,
39
+ paths: readonly string[],
40
+ ): Promise<ReadonlyMap<string, Uint8Array>> {
41
+ const entries = await Promise.all(
42
+ paths.toSorted().map(async (file): Promise<[string, Uint8Array]> => {
43
+ const bytes = await Bun.file(path.join(folder, file)).bytes();
44
+
45
+ return [file, bytes];
46
+ }),
47
+ );
48
+
49
+ return new Map(entries);
50
+ }
51
+
52
+ // The commit of a checkout that is a plain folder. Its rules version is still the content hash
53
+ // of its files.
54
+ const folderCommit = 'folder';
55
+
56
+ async function readFolder(folder: string): Promise<CheckoutAnswer> {
57
+ if (!existsSync(folder)) {
58
+ return { kind: 'rejected', reason: `no rules folder at ${folder}` };
59
+ }
60
+
61
+ const paths = await Array.fromAsync(new Bun.Glob('**/*').scan({ cwd: folder, dot: false }));
62
+ const files = await readFiles(folder, paths);
63
+
64
+ return { kind: 'read', checkout: { commit: folderCommit, files } };
65
+ }
@@ -0,0 +1,178 @@
1
+ import type { ProcedureName } from '@boxxy/contract';
2
+ import { access } from '@boxxy/contract';
3
+ import { McpServer, createMcpHandler } from '@modelcontextprotocol/server';
4
+ import { McpRegistrar } from '@orpc/mcp';
5
+ import { COMMON_ERROR_STATUS_MAP } from '@orpc/server';
6
+ import { RPCHandler } from '@orpc/server/fetch';
7
+ import { Elysia } from 'elysia';
8
+ import invariant from 'tiny-invariant';
9
+ import { isBrowserRequest } from './auth/is-browser-request.ts';
10
+ import { pickRequestScope } from './auth/pick-request-scope.ts';
11
+ import { api } from './handlers/api.ts';
12
+ import { attemptHandlers } from './handlers/attempt-handlers.ts';
13
+ import { itemHandlers } from './handlers/item-handlers.ts';
14
+ import { outboxHandlers } from './handlers/outbox-handlers.ts';
15
+ import { planHandlers } from './handlers/plan-handlers.ts';
16
+ import { relationHandlers } from './handlers/relation-handlers.ts';
17
+ import { reportHandlers } from './handlers/report-handlers.ts';
18
+ import { rulesHandlers } from './handlers/rules-handlers.ts';
19
+ import { runnerHandlers } from './handlers/runner-handlers.ts';
20
+ import { readLimitedRequest } from './read-limited-request.ts';
21
+ import type { Listener, RequestContext, ServerDeps } from './types.ts';
22
+
23
+ // the route refuses a larger request body (rule 5 of the contract)
24
+ export const maxRequestBytes = 256 * 1024;
25
+
26
+ // `owner`: the owner's private network, with `/rpc`, `/mcp` and `/healthz`. `worker`: the
27
+ // broker's target, with `/rpc` and `/mcp/session`, and the worker token on every request.
28
+ export type ApiListeners = ReturnType<typeof serveApi>;
29
+
30
+ const router = api.router({
31
+ item: itemHandlers,
32
+ relation: relationHandlers,
33
+ attempt: attemptHandlers,
34
+ plan: planHandlers,
35
+ runner: runnerHandlers,
36
+ outbox: outboxHandlers,
37
+ rules: rulesHandlers,
38
+ report: reportHandlers,
39
+ });
40
+
41
+ // In oRPC v2 a typed error's HTTP status comes from this map alone; a code not in it answers 400.
42
+ const rpc = new RPCHandler(router, {
43
+ errorStatusMap: {
44
+ ...COMMON_ERROR_STATUS_MAP,
45
+ PEPYS_UNAVAILABLE: 503,
46
+ PEPYS_NOT_CONFIGURED: 501,
47
+ UNKNOWN_PHASE: 422,
48
+ },
49
+ });
50
+
51
+ const ownerTools = new McpRegistrar(router, {
52
+ filter: (_procedure, path) => getAccess(path).tool === 'owner',
53
+ });
54
+
55
+ const sessionTools = new McpRegistrar(router, {
56
+ filter: (_procedure, path) => getAccess(path).tool === 'session',
57
+ });
58
+
59
+ // One router behind three doors on two listeners. Every request picks its scope from its
60
+ // listener and its bearer token before any procedure runs; the root middleware of the router
61
+ // then checks the access table for each call.
62
+ export function serveApi(deps: ServerDeps) {
63
+ const ownerMcp = makeMcpDoor(deps, 'owner', ownerTools, 'boxxy');
64
+ const sessionMcp = makeMcpDoor(deps, 'worker', sessionTools, 'boxxy-session');
65
+
66
+ const owner = new Elysia()
67
+ .get('/healthz', { parse: 'none' }, (route) =>
68
+ handleDoor(deps, 'owner', route.request, () => Promise.resolve(new Response('ok'))),
69
+ )
70
+ .all('/rpc/*', { parse: 'none' }, (route) =>
71
+ handleDoor(deps, 'owner', route.request, (request) => handleRPC(deps, 'owner', request)),
72
+ )
73
+ .all('/mcp', { parse: 'none' }, (route) => handleDoor(deps, 'owner', route.request, ownerMcp));
74
+
75
+ const worker = new Elysia()
76
+ .all('/rpc/*', { parse: 'none' }, (route) =>
77
+ handleDoor(deps, 'worker', route.request, (request) => handleRPC(deps, 'worker', request)),
78
+ )
79
+ .all('/mcp/session', { parse: 'none' }, (route) =>
80
+ handleDoor(deps, 'worker', route.request, sessionMcp),
81
+ )
82
+
83
+ // the token check also guards a path the listener does not serve, so a caller with no token
84
+ // learns nothing of the routes
85
+ .all('*', { parse: 'none' }, (route) =>
86
+ handleDoor(deps, 'worker', route.request, () => Promise.resolve(answerNotFound())),
87
+ );
88
+
89
+ return { owner, worker };
90
+ }
91
+
92
+ function makeMcpDoor(
93
+ deps: ServerDeps,
94
+ listener: Listener,
95
+ registrar: McpRegistrar<typeof router>,
96
+ name: string,
97
+ ) {
98
+ // one MCP server per request, so the endpoint keeps no session and each call has its request
99
+ const handler = createMcpHandler(async (extra) => {
100
+ const requestInfo = extra.requestInfo;
101
+
102
+ invariant(requestInfo !== undefined, 'an HTTP request reaches the MCP door');
103
+
104
+ const server = new McpServer({ name, version: '1.0.0' });
105
+
106
+ await registrar.register(server, { context: buildContext(deps, listener, requestInfo) });
107
+
108
+ return server;
109
+ });
110
+
111
+ return (request: Request) => handler.fetch(request);
112
+ }
113
+
114
+ // The door checked the token already; this reads the same scope for the router's context.
115
+ function buildContext(deps: ServerDeps, listener: Listener, request: Request): RequestContext {
116
+ const scope = pickRequestScope({
117
+ listener,
118
+ authorization: request.headers.get('authorization'),
119
+ workerTokenDigest: deps.workerTokenDigest,
120
+ });
121
+
122
+ invariant(scope !== null, 'the door refuses a request with no scope');
123
+
124
+ return { deps, scope, request };
125
+ }
126
+
127
+ function getAccess(path: readonly string[]) {
128
+ const name = path.join('.');
129
+
130
+ invariant(isProcedureName(name), `the router serves only contract procedures: ${name}`);
131
+
132
+ return access[name];
133
+ }
134
+
135
+ function isProcedureName(name: string): name is ProcedureName {
136
+ return Object.hasOwn(access, name);
137
+ }
138
+
139
+ // The checks of the HTTP request itself: its bearer token, that no browser page sent it, then its
140
+ // size. A refusal names nothing of the token.
141
+ async function handleDoor(
142
+ deps: ServerDeps,
143
+ listener: Listener,
144
+ request: Request,
145
+ serve: (request: Request) => Promise<Response>,
146
+ ): Promise<Response> {
147
+ const scope = pickRequestScope({
148
+ listener,
149
+ authorization: request.headers.get('authorization'),
150
+ workerTokenDigest: deps.workerTokenDigest,
151
+ });
152
+
153
+ if (scope === null) {
154
+ return Response.json({ error: 'unauthorized' }, { status: 401 });
155
+ }
156
+
157
+ if (isBrowserRequest(request.headers)) {
158
+ return Response.json({ error: 'forbidden' }, { status: 403 });
159
+ }
160
+
161
+ const limited = await readLimitedRequest(request, maxRequestBytes);
162
+
163
+ return limited === null
164
+ ? Response.json({ error: 'request body too large' }, { status: 413 })
165
+ : serve(limited);
166
+ }
167
+
168
+ async function handleRPC(deps: ServerDeps, listener: Listener, request: Request) {
169
+ const context = buildContext(deps, listener, request);
170
+
171
+ const handled = await rpc.handle(request, { prefix: '/rpc', context });
172
+
173
+ return handled.response ?? answerNotFound();
174
+ }
175
+
176
+ function answerNotFound(): Response {
177
+ return Response.json({ error: 'not found' }, { status: 404 });
178
+ }