@boxxy/server 0.0.1 → 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 (90) hide show
  1. package/README.md +57 -5
  2. package/dist/auth/authorize-call.d.ts +11 -0
  3. package/dist/auth/is-browser-request.d.ts +1 -0
  4. package/dist/auth/pick-request-scope.d.ts +9 -0
  5. package/dist/config/pick-server-secrets.d.ts +16 -0
  6. package/dist/config/read-caller-proof.d.ts +10 -0
  7. package/dist/config/read-server-config.d.ts +9 -0
  8. package/dist/config/read-trusted-issuers.d.ts +8 -0
  9. package/dist/config/server-config-schema.d.ts +42 -0
  10. package/dist/create-capacity-backoff.d.ts +2 -0
  11. package/dist/handlers/api.d.ts +1683 -0
  12. package/dist/handlers/attempt-handlers.d.ts +130 -0
  13. package/dist/handlers/build-owner-fact.d.ts +3 -0
  14. package/dist/handlers/item-handlers.d.ts +168 -0
  15. package/dist/handlers/outbox-handlers.d.ts +7 -0
  16. package/dist/handlers/plan-handlers.d.ts +62 -0
  17. package/dist/handlers/read-item-ids.d.ts +10 -0
  18. package/dist/handlers/relation-handlers.d.ts +41 -0
  19. package/dist/handlers/report-handlers.d.ts +102 -0
  20. package/dist/handlers/rules-handlers.d.ts +20 -0
  21. package/dist/handlers/runner-handlers.d.ts +65 -0
  22. package/dist/handlers/to-wire-json.d.ts +5 -0
  23. package/dist/handlers/write-levers.d.ts +11 -0
  24. package/dist/index.d.ts +12 -0
  25. package/dist/main.d.ts +1 -0
  26. package/dist/no-caller-proof.d.ts +2 -0
  27. package/dist/outbox/create-off-outbox.d.ts +3 -0
  28. package/dist/outbox/start-outbox-sender.d.ts +10 -0
  29. package/dist/read-limited-request.d.ts +1 -0
  30. package/dist/rules/create-rules-holder.d.ts +20 -0
  31. package/dist/rules/load-server-rules.d.ts +7 -0
  32. package/dist/rules/read-rules-checkout.d.ts +9 -0
  33. package/dist/serve-api.d.ts +50 -0
  34. package/dist/start-server.d.ts +18 -0
  35. package/dist/test-utils/build-mcp-request.d.ts +8 -0
  36. package/dist/test-utils/build-rpc-request.d.ts +7 -0
  37. package/dist/test-utils/build-session-input.d.ts +1 -0
  38. package/dist/test-utils/create-fake-caller-proof.d.ts +5 -0
  39. package/dist/test-utils/create-test-server.d.ts +30 -0
  40. package/dist/test-utils/list-owner-procedures.d.ts +5 -0
  41. package/dist/test-utils/read-mcp-answer.d.ts +11 -0
  42. package/dist/test-utils/wait-for.d.ts +1 -0
  43. package/dist/tick/run-tick.d.ts +24 -0
  44. package/dist/tick/start-tick-loop.d.ts +12 -0
  45. package/dist/types.d.ts +41 -0
  46. package/package.json +48 -4
  47. package/src/auth/authorize-call.ts +50 -0
  48. package/src/auth/is-browser-request.ts +8 -0
  49. package/src/auth/pick-request-scope.ts +37 -0
  50. package/src/config/pick-server-secrets.ts +48 -0
  51. package/src/config/read-caller-proof.ts +36 -0
  52. package/src/config/read-server-config.ts +41 -0
  53. package/src/config/read-trusted-issuers.ts +60 -0
  54. package/src/config/server-config-schema.ts +71 -0
  55. package/src/create-capacity-backoff.ts +18 -0
  56. package/src/handlers/api.ts +23 -0
  57. package/src/handlers/attempt-handlers.ts +244 -0
  58. package/src/handlers/build-owner-fact.ts +20 -0
  59. package/src/handlers/item-handlers.ts +462 -0
  60. package/src/handlers/outbox-handlers.ts +15 -0
  61. package/src/handlers/plan-handlers.ts +75 -0
  62. package/src/handlers/read-item-ids.ts +31 -0
  63. package/src/handlers/relation-handlers.ts +143 -0
  64. package/src/handlers/report-handlers.ts +112 -0
  65. package/src/handlers/rules-handlers.ts +46 -0
  66. package/src/handlers/runner-handlers.ts +117 -0
  67. package/src/handlers/to-wire-json.ts +20 -0
  68. package/src/handlers/write-levers.ts +53 -0
  69. package/src/index.ts +12 -0
  70. package/src/main.ts +31 -0
  71. package/src/no-caller-proof.ts +7 -0
  72. package/src/outbox/create-off-outbox.ts +21 -0
  73. package/src/outbox/start-outbox-sender.ts +89 -0
  74. package/src/read-limited-request.ts +43 -0
  75. package/src/rules/create-rules-holder.ts +107 -0
  76. package/src/rules/load-server-rules.ts +24 -0
  77. package/src/rules/read-rules-checkout.ts +65 -0
  78. package/src/serve-api.ts +178 -0
  79. package/src/start-server.ts +308 -0
  80. package/src/test-utils/build-mcp-request.ts +34 -0
  81. package/src/test-utils/build-rpc-request.ts +23 -0
  82. package/src/test-utils/build-session-input.ts +16 -0
  83. package/src/test-utils/create-fake-caller-proof.ts +18 -0
  84. package/src/test-utils/create-test-server.ts +142 -0
  85. package/src/test-utils/list-owner-procedures.ts +38 -0
  86. package/src/test-utils/read-mcp-answer.ts +26 -0
  87. package/src/test-utils/wait-for.ts +14 -0
  88. package/src/tick/run-tick.ts +212 -0
  89. package/src/tick/start-tick-loop.ts +46 -0
  90. package/src/types.ts +86 -0
@@ -0,0 +1,12 @@
1
+ export { serverConfigSchema } from './config/server-config-schema.ts';
2
+ export type { ServerConfig } from './config/server-config-schema.ts';
3
+ export { noCallerProof } from './no-caller-proof.ts';
4
+ export { loadServerRules } from './rules/load-server-rules.ts';
5
+ export type { RulesCheckout } from './rules/load-server-rules.ts';
6
+ export { readRulesCheckout } from './rules/read-rules-checkout.ts';
7
+ export type { CheckoutAnswer } from './rules/read-rules-checkout.ts';
8
+ export { serveApi } from './serve-api.ts';
9
+ export type { ApiListeners } from './serve-api.ts';
10
+ export { startServer } from './start-server.ts';
11
+ export type { RunningServer, StartAnswer, StartServerInput } from './start-server.ts';
12
+ export type { ServerDeps, ServerLog } from './types.ts';
package/dist/main.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { CallerProof } from '@boxxy/model';
2
+ export declare const noCallerProof: CallerProof;
@@ -0,0 +1,3 @@
1
+ import type { ClaimStore, Clock } from '@boxxy/model';
2
+ import type { OutboxControl } from '../types.ts';
3
+ export declare function createOffOutbox(store: ClaimStore, clock: Clock): OutboxControl;
@@ -0,0 +1,10 @@
1
+ import type { SenderDeps } from '@boxxy/dispatch';
2
+ import type { OutboxControl, ServerLog } from '../types.ts';
3
+ export interface OutboxSenderInput extends SenderDeps {
4
+ readonly idleMs: number;
5
+ readonly log: ServerLog;
6
+ }
7
+ export interface OutboxSender extends OutboxControl {
8
+ readonly stop: () => Promise<void>;
9
+ }
10
+ export declare function startOutboxSender(input: OutboxSenderInput): OutboxSender;
@@ -0,0 +1 @@
1
+ export declare function readLimitedRequest(request: Request, maxBytes: number): Promise<Request | null>;
@@ -0,0 +1,20 @@
1
+ import type { LoadRejection, LoadResult } from '@boxxy/model';
2
+ import type { RulesState, ServerLog } from '../types.ts';
3
+ interface RulesHolder {
4
+ readonly state: () => RulesState;
5
+ readonly loadProposed: (text: string) => LoadResult;
6
+ readonly texts: () => ReadonlyMap<string, string>;
7
+ readonly reload: () => Promise<void>;
8
+ }
9
+ export type RulesHolderAnswer = {
10
+ readonly kind: 'loaded';
11
+ readonly holder: RulesHolder;
12
+ } | {
13
+ readonly kind: 'rejected';
14
+ readonly reason: string;
15
+ } | {
16
+ readonly kind: 'refused';
17
+ readonly rejections: readonly LoadRejection[];
18
+ };
19
+ export declare function createRulesHolder(folder: string, log: ServerLog): Promise<RulesHolderAnswer>;
20
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { LoadResult } from '@boxxy/model';
2
+ export interface RulesCheckout {
3
+ readonly commit: string;
4
+ readonly files: ReadonlyMap<string, Uint8Array>;
5
+ }
6
+ export declare function loadServerRules(checkout: RulesCheckout): LoadResult;
7
+ export declare function makeProposedRulesLoader(checkout: RulesCheckout): (text: string) => LoadResult;
@@ -0,0 +1,9 @@
1
+ import type { RulesCheckout } from './load-server-rules.ts';
2
+ export type CheckoutAnswer = {
3
+ readonly kind: 'read';
4
+ readonly checkout: RulesCheckout;
5
+ } | {
6
+ readonly kind: 'rejected';
7
+ readonly reason: string;
8
+ };
9
+ export declare function readRulesCheckout(folder: string): Promise<CheckoutAnswer>;
@@ -0,0 +1,50 @@
1
+ import { Elysia } from 'elysia';
2
+ import type { ServerDeps } from './types.ts';
3
+ export declare const maxRequestBytes: number;
4
+ export type ApiListeners = ReturnType<typeof serveApi>;
5
+ export declare function serveApi(deps: ServerDeps): {
6
+ owner: import("elysia/types").AddRoute<"", "local", import("elysia").DefaultSingleton, {
7
+ typebox: {};
8
+ error: [];
9
+ }, import("elysia").DefaultMetadata, {}, import("elysia").DefaultEphemeral, import("elysia").DefaultEphemeral, "get", "/healthz", import("elysia/types").IntersectIfObjectSchema<import("elysia").UnwrapRoute<import("elysia").InputSchema<never> & {
10
+ beforeHandle?: unknown;
11
+ afterHandle?: unknown;
12
+ error?: unknown;
13
+ }, {}, "/healthz">, import("elysia/types").MergeScopedSchemas<{}, {}, {}>>, {}, (route: {
14
+ server: import("elysia").Server | null;
15
+ redirect: import("elysia").redirect;
16
+ set: {
17
+ headers: import("elysia").HTTPHeaders;
18
+ status?: number | keyof import("elysia").StatusMap;
19
+ cookie?: Record<string, import("elysia").BaseCookie>;
20
+ redirect?: never;
21
+ };
22
+ status: <const Code extends number | keyof import("elysia").StatusMap, const T = import("elysia").StatusResponse<Code>>(code: Code, response?: T) => import("elysia").ElysiaStatus<Code, T, import("elysia").NumericStatus<Code>>;
23
+ problem: typeof import("elysia").problem;
24
+ defer: (fn: import("elysia/types").AfterResponseHandler<NoInfer<import("elysia/types").IntersectIfObjectSchema<import("elysia").UnwrapRoute<import("elysia").InputSchema<never> & {
25
+ beforeHandle?: unknown;
26
+ afterHandle?: unknown;
27
+ error?: unknown;
28
+ }, {}, "/healthz">, import("elysia/types").MergeScopedSchemas<{}, {}, {}>>>, NoInfer<import("elysia").DefaultSingleton & {
29
+ derive: {};
30
+ }>>) => import("elysia/types").MaybePromise<void>;
31
+ readonly path: string;
32
+ route?: string;
33
+ rid?: string;
34
+ request: Request;
35
+ store: {};
36
+ body: unknown;
37
+ query: Record<string, string | undefined>;
38
+ params: {};
39
+ headers: Record<string, string | undefined>;
40
+ cookie: Record<string, import("elysia").Cookie<unknown>>;
41
+ }) => Promise<Response>, import("elysia").InputSchema<never> & {
42
+ beforeHandle?: unknown;
43
+ afterHandle?: unknown;
44
+ error?: unknown;
45
+ }>;
46
+ worker: Elysia<"", "local", import("elysia").DefaultSingleton, {
47
+ typebox: {};
48
+ error: [];
49
+ }, import("elysia").DefaultMetadata, {}, import("elysia").DefaultEphemeral, import("elysia").DefaultEphemeral>;
50
+ };
@@ -0,0 +1,18 @@
1
+ import type { ServerLog } from './types.ts';
2
+ export interface StartServerInput {
3
+ readonly env: Readonly<Record<string, string | undefined>>;
4
+ readonly log: ServerLog;
5
+ }
6
+ export interface RunningServer {
7
+ readonly ownerPort: number;
8
+ readonly workerPort: number;
9
+ readonly stop: () => Promise<void>;
10
+ }
11
+ export type StartAnswer = {
12
+ readonly kind: 'started';
13
+ readonly server: RunningServer;
14
+ } | {
15
+ readonly kind: 'refused';
16
+ readonly message: string;
17
+ };
18
+ export declare function startServer(input: StartServerInput): Promise<StartAnswer>;
@@ -0,0 +1,8 @@
1
+ interface MCPRequestInput {
2
+ readonly path: '/mcp' | '/mcp/session';
3
+ readonly method: string;
4
+ readonly params: Readonly<Record<string, unknown>>;
5
+ readonly token?: string;
6
+ }
7
+ export declare function buildMCPRequest(request: MCPRequestInput): Request;
8
+ export {};
@@ -0,0 +1,7 @@
1
+ interface RPCRequestInput {
2
+ readonly procedure: string;
3
+ readonly input: unknown;
4
+ readonly token?: string;
5
+ }
6
+ export declare function buildRPCRequest(request: RPCRequestInput): Request;
7
+ export {};
@@ -0,0 +1 @@
1
+ export declare function buildSessionInput(procedure: string, attempt: string): unknown;
@@ -0,0 +1,5 @@
1
+ import type { CallerIdentity, CallerProof } from '@boxxy/model';
2
+ export interface FakeCallerProof extends CallerProof {
3
+ setCaller: (caller: CallerIdentity | null) => void;
4
+ }
5
+ export declare function createFakeCallerProof(): FakeCallerProof;
@@ -0,0 +1,30 @@
1
+ import type { FakeForge } from '@boxxy/dispatch/test-utils/create-fake-forge';
2
+ import type { TestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
3
+ import type { AttemptRow, CallerProof, Clock, PhaseName } from '@boxxy/model';
4
+ import type { FakeNotifier } from '@boxxy/model/test-utils/create-fake-notifier';
5
+ import type { FakePepysReader } from '@boxxy/model/test-utils/create-fake-pepys-reader';
6
+ import type { TickReport } from '../tick/run-tick.ts';
7
+ import type { ServerDeps } from '../types.ts';
8
+ import type { FakeCallerProof } from './create-fake-caller-proof.ts';
9
+ export interface TestServerOptions {
10
+ readonly hasPepys?: boolean;
11
+ readonly makeCallerProof?: (clock: Clock) => Promise<CallerProof>;
12
+ }
13
+ export interface TestServer {
14
+ readonly dispatch: TestDispatch;
15
+ readonly callerProof: FakeCallerProof;
16
+ readonly pepys: FakePepysReader;
17
+ readonly notifier: FakeNotifier;
18
+ readonly forge: FakeForge;
19
+ readonly workerToken: string;
20
+ readonly logged: readonly {
21
+ readonly message: string;
22
+ readonly fields: unknown;
23
+ }[];
24
+ readonly deps: ServerDeps;
25
+ readonly owner: (request: Request) => Promise<Response>;
26
+ readonly worker: (request: Request) => Promise<Response>;
27
+ readonly tick: () => Promise<TickReport>;
28
+ readonly seedStartedAttempt: (phase: PhaseName) => Promise<AttemptRow>;
29
+ }
30
+ export declare function createTestServer(options?: TestServerOptions): Promise<TestServer>;
@@ -0,0 +1,5 @@
1
+ import type { ProcedureName } from '@boxxy/contract';
2
+ export declare function listOwnerProcedures(): [ProcedureName][];
3
+ export declare function listOwnerTools(): [string][];
4
+ export declare function listSessionProcedures(): [ProcedureName][];
5
+ export declare function listSessionTools(): [string][];
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ declare const answerSchema: z.ZodObject<{
3
+ result: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
4
+ error: z.ZodOptional<z.ZodObject<{
5
+ code: z.ZodNumber;
6
+ message: z.ZodString;
7
+ }, z.core.$strip>>;
8
+ }, z.core.$loose>;
9
+ export type MCPAnswer = z.infer<typeof answerSchema>;
10
+ export declare function readMCPAnswer(response: Response): Promise<MCPAnswer>;
11
+ export {};
@@ -0,0 +1 @@
1
+ export declare function waitFor(check: () => boolean | Promise<boolean>, timeoutMs?: number): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import type { NoticeReport, SpawnSettings } from '@boxxy/dispatch';
2
+ import type { AttemptId, CapacityBackoff, ClaimStore, Instant, LoadedRules, NewId, Notifier, RunnerId, SessionReading, WorkerKind } from '@boxxy/model';
3
+ import type { ServerLog } from '../types.ts';
4
+ export interface TickDeps {
5
+ readonly store: ClaimStore;
6
+ readonly runner: RunnerId;
7
+ readonly worker: WorkerKind;
8
+ readonly rules: LoadedRules;
9
+ readonly backoff: CapacityBackoff;
10
+ readonly spawn: SpawnSettings;
11
+ readonly notifier: Notifier;
12
+ readonly now: Instant;
13
+ readonly newId: NewId;
14
+ readonly readings: Map<AttemptId, SessionReading>;
15
+ readonly log: ServerLog;
16
+ }
17
+ export interface TickReport {
18
+ readonly claimed: number;
19
+ readonly read: number;
20
+ readonly strays: number;
21
+ readonly forgotten: number;
22
+ readonly notices: NoticeReport | null;
23
+ }
24
+ export declare function runTick(deps: TickDeps): Promise<TickReport>;
@@ -0,0 +1,12 @@
1
+ import type { ServerLog } from '../types.ts';
2
+ export interface TickLoop {
3
+ readonly stop: () => Promise<void>;
4
+ }
5
+ interface TickLoopInput {
6
+ readonly runOnce: () => Promise<void>;
7
+ readonly intervalMs: number;
8
+ readonly failureMessage: string;
9
+ readonly log: ServerLog;
10
+ }
11
+ export declare function startTickLoop(input: TickLoopInput): TickLoop;
12
+ export {};
@@ -0,0 +1,41 @@
1
+ import type { AtcSessionId, AttemptId, CallerIdentity, CallerProof, ClaimStore, Clock, Forge, LoadRejection, LoadResult, LoadedRules, NewId, OutboxStatus, PepysReader, RunnerId, SessionReading } from '@boxxy/model';
2
+ export interface RulesState {
3
+ readonly rules: LoadedRules;
4
+ readonly lastRejected: {
5
+ readonly commit: string;
6
+ readonly rejections: readonly LoadRejection[];
7
+ } | null;
8
+ }
9
+ export interface OutboxControl {
10
+ readonly status: () => Promise<OutboxStatus>;
11
+ readonly nudge: () => void;
12
+ }
13
+ export interface ServerLog {
14
+ readonly warn: (message: string, fields: Readonly<Record<string, unknown>>) => void;
15
+ }
16
+ export interface ServerDeps {
17
+ readonly store: ClaimStore;
18
+ readonly runner: RunnerId;
19
+ readonly rulesState: () => RulesState;
20
+ readonly loadProposedRules: (text: string) => LoadResult;
21
+ readonly clock: Clock;
22
+ readonly newId: NewId;
23
+ readonly callerProof: CallerProof;
24
+ readonly workerTokenDigest: Uint8Array;
25
+ readonly pepys: PepysReader | null;
26
+ readonly forge: Forge | null;
27
+ readonly outbox: OutboxControl;
28
+ readonly readings: ReadonlyMap<AttemptId, SessionReading>;
29
+ readonly log: ServerLog;
30
+ }
31
+ export type Listener = 'owner' | 'worker';
32
+ export type RequestScope = 'owner' | 'worker';
33
+ export interface RequestContext {
34
+ readonly deps: ServerDeps;
35
+ readonly scope: RequestScope;
36
+ readonly request: Request;
37
+ }
38
+ export interface ProvenCall {
39
+ readonly session: AtcSessionId;
40
+ readonly caller: CallerIdentity;
41
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@boxxy/server",
3
- "version": "0.0.1",
4
- "description": "Placeholder that reserves the name; the first real version comes from the boxxy release",
3
+ "version": "0.1.3",
4
+ "description": "The boxxy server: the API behind its three doors, and the runner loop",
5
5
  "homepage": "https://github.com/zgeoff/boxxy/tree/main/apps/server#readme",
6
6
  "bugs": "https://github.com/zgeoff/boxxy/issues",
7
7
  "license": "MIT",
@@ -11,10 +11,54 @@
11
11
  "directory": "apps/server"
12
12
  },
13
13
  "files": [
14
- "README.md",
15
- "LICENSE"
14
+ "src",
15
+ "!src/**/*.test.ts",
16
+ "dist"
16
17
  ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./src/index.ts"
23
+ },
24
+ "./test-utils/*": "./src/test-utils/*.ts"
25
+ },
17
26
  "publishConfig": {
18
27
  "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "rm -rf dist && tsrs -p tsconfig.build.json",
31
+ "start": "bun src/main.ts",
32
+ "typecheck": "tsrs --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@boxxy/caller-proof-imp": "0.1.3",
36
+ "@boxxy/closeout": "0.1.3",
37
+ "@boxxy/content-hash": "0.1.3",
38
+ "@boxxy/contract": "0.1.3",
39
+ "@boxxy/dispatch": "0.1.3",
40
+ "@boxxy/forge-github": "0.1.3",
41
+ "@boxxy/model": "0.1.3",
42
+ "@boxxy/notifier-atc": "0.1.3",
43
+ "@boxxy/pepys": "0.1.3",
44
+ "@boxxy/planning": "0.1.3",
45
+ "@boxxy/rules": "0.1.3",
46
+ "@boxxy/store-sqlite": "0.1.3",
47
+ "@boxxy/worker-atc": "0.1.3",
48
+ "@modelcontextprotocol/server": "2.4.0",
49
+ "@orpc/client": "2.0.0-beta.50",
50
+ "@orpc/mcp": "2.0.0-beta.50",
51
+ "@orpc/server": "2.0.0-beta.50",
52
+ "elysia": "2.0.0-rc.0",
53
+ "exact-mirror": "1.2.6",
54
+ "openapi-types": "12.1.3",
55
+ "tiny-invariant": "1.3.3",
56
+ "ts-pattern": "5.9.2",
57
+ "typebox": "1.4.3",
58
+ "zod": "4.6.7"
59
+ },
60
+ "engines": {
61
+ "bun": ">=1.3",
62
+ "node": ">=24"
19
63
  }
20
64
  }
@@ -0,0 +1,50 @@
1
+ import type { ProcedureName } from '@boxxy/contract';
2
+ import { access } from '@boxxy/contract';
3
+ import invariant from 'tiny-invariant';
4
+ import type { ProvenCall, RequestContext } from '../types.ts';
5
+
6
+ export type CallAuthorization =
7
+ | { readonly kind: 'owner' }
8
+
9
+ // a session or hook call with a proven caller; `call` is null when no session holds the caller
10
+ | { readonly kind: 'session'; readonly call: ProvenCall | null }
11
+ | { readonly kind: 'refused'; readonly code: 'UNAUTHORIZED' | 'FORBIDDEN' };
12
+
13
+ // The one check of who may call a procedure, from the access table. Every door runs it before any
14
+ // handler, and no handler repeats it.
15
+ export async function authorizeCall(
16
+ ctx: RequestContext,
17
+ path: readonly string[],
18
+ ): Promise<CallAuthorization> {
19
+ const name = path.join('.');
20
+
21
+ invariant(isProcedureName(name), `the router serves only contract procedures: ${name}`);
22
+
23
+ if (access[name].caller === 'owner') {
24
+ return ctx.scope === 'owner' ? { kind: 'owner' } : { kind: 'refused', code: 'FORBIDDEN' };
25
+ }
26
+
27
+ if (ctx.scope !== 'worker') {
28
+ return { kind: 'refused', code: 'UNAUTHORIZED' };
29
+ }
30
+
31
+ const caller = await ctx.deps.callerProof.prove(ctx.request);
32
+
33
+ if (caller === null) {
34
+ return { kind: 'refused', code: 'UNAUTHORIZED' };
35
+ }
36
+
37
+ const session = await ctx.deps.store.read((tx) => tx.findSessionByCaller(caller));
38
+
39
+ if (session === 'ambiguous') {
40
+ ctx.deps.log.warn('two sessions hold one caller', { procedure: name, ...caller });
41
+
42
+ return { kind: 'refused', code: 'FORBIDDEN' };
43
+ }
44
+
45
+ return { kind: 'session', call: session === null ? null : { session, caller } };
46
+ }
47
+
48
+ function isProcedureName(name: string): name is ProcedureName {
49
+ return Object.hasOwn(access, name);
50
+ }
@@ -0,0 +1,8 @@
1
+ // A browser marks each request a page makes: `Origin` on every POST, `Sec-Fetch-Site` on every
2
+ // request. No caller of the server is a page, and a page on a site that resolves to a listener can
3
+ // send a write with no CORS preflight. `Sec-Fetch-Site: none` is an address typed by a person.
4
+ export function isBrowserRequest(headers: Headers): boolean {
5
+ const site = headers.get('sec-fetch-site');
6
+
7
+ return headers.has('origin') || (site !== null && site !== 'none');
8
+ }
@@ -0,0 +1,37 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import invariant from 'tiny-invariant';
3
+ import type { Listener, RequestScope } from '../types.ts';
4
+
5
+ interface ScopeInput {
6
+ readonly listener: Listener;
7
+
8
+ // the `Authorization` header, or null
9
+ readonly authorization: string | null;
10
+ readonly workerTokenDigest: Uint8Array;
11
+ }
12
+
13
+ const bearerPattern = /^Bearer (?<token>\S+)$/u;
14
+
15
+ // Which scope a request runs in, from its listener and its bearer token alone. `null`: refuse it
16
+ // with 401. On the worker listener no token is never the owner: a call that went around the
17
+ // broker carries none.
18
+ export function pickRequestScope(input: ScopeInput): RequestScope | null {
19
+ if (input.authorization === null) {
20
+ return input.listener === 'owner' ? 'owner' : null;
21
+ }
22
+
23
+ const token = bearerPattern.exec(input.authorization)?.groups?.['token'];
24
+
25
+ if (token === undefined) {
26
+ return null;
27
+ }
28
+
29
+ // equal-length digests, so the comparison takes the same time for every wrong token
30
+ return timingSafeEqual(buildTokenDigest(token), input.workerTokenDigest) ? 'worker' : null;
31
+ }
32
+
33
+ export function buildTokenDigest(token: string): Uint8Array {
34
+ invariant(token.length > 0, 'the worker token is never empty');
35
+
36
+ return createHash('sha256').update(token).digest();
37
+ }
@@ -0,0 +1,48 @@
1
+ import type { ServerConfig } from './server-config-schema.ts';
2
+
3
+ interface ServerSecrets {
4
+ readonly workerToken: string;
5
+ readonly atcToken: string;
6
+
7
+ // null: the configuration has no pepys section
8
+ readonly pepysSigningSecret: string | null;
9
+
10
+ // null: unset or empty, for a broker that adds the GitHub credential on the way out
11
+ readonly githubToken: string | null;
12
+ }
13
+
14
+ export type SecretsAnswer =
15
+ | { readonly kind: 'found'; readonly secrets: ServerSecrets }
16
+
17
+ // the name of each variable that is unset or empty, never a value
18
+ | { readonly kind: 'missing'; readonly names: readonly string[] };
19
+
20
+ // The secrets come from the environment only. An empty variable counts as missing.
21
+ export function pickServerSecrets(
22
+ config: ServerConfig,
23
+ env: Readonly<Record<string, string | undefined>>,
24
+ ): SecretsAnswer {
25
+ const needed = [
26
+ 'BOXXY_WORKER_TOKEN',
27
+ 'BOXXY_ATC_TOKEN',
28
+ ...(config.pepys === undefined ? [] : ['BOXXY_PEPYS_SIGNING_SECRET']),
29
+ ];
30
+
31
+ const names = needed.filter((name) => (env[name] ?? '') === '');
32
+
33
+ if (names.length > 0) {
34
+ return { kind: 'missing', names };
35
+ }
36
+
37
+ return {
38
+ kind: 'found',
39
+ secrets: {
40
+ workerToken: env['BOXXY_WORKER_TOKEN'] ?? '',
41
+ atcToken: env['BOXXY_ATC_TOKEN'] ?? '',
42
+ pepysSigningSecret:
43
+ config.pepys === undefined ? null : (env['BOXXY_PEPYS_SIGNING_SECRET'] ?? ''),
44
+ githubToken:
45
+ (env['BOXXY_GITHUB_TOKEN'] ?? '') === '' ? null : (env['BOXXY_GITHUB_TOKEN'] ?? null),
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,36 @@
1
+ import { impIdentityCallerProof } from '@boxxy/caller-proof-imp';
2
+ import type { CallerProof, Clock } from '@boxxy/model';
3
+ import { noCallerProof } from '../no-caller-proof.ts';
4
+ import { readTrustedIssuers } from './read-trusted-issuers.ts';
5
+ import type { ServerConfig } from './server-config-schema.ts';
6
+
7
+ export type ProofAnswer =
8
+ | { readonly kind: 'read'; readonly callerProof: CallerProof }
9
+ | { readonly kind: 'refused'; readonly message: string };
10
+
11
+ // The proof of who sent a session or hook call, from the trust documents in the configured
12
+ // folders. With no `trustedIssuers` section, the proof proves nobody, so every such call is
13
+ // refused. The adapter throws on a malformed trust document, so such a server never starts.
14
+ export async function readCallerProof(
15
+ trustedIssuers: ServerConfig['trustedIssuers'],
16
+ clock: Clock,
17
+ ): Promise<ProofAnswer> {
18
+ if (trustedIssuers === undefined) {
19
+ return { kind: 'read', callerProof: noCallerProof };
20
+ }
21
+
22
+ const read = await readTrustedIssuers(trustedIssuers.folders);
23
+
24
+ if (read.kind === 'invalid') {
25
+ return { kind: 'refused', message: read.message };
26
+ }
27
+
28
+ return {
29
+ kind: 'read',
30
+ callerProof: impIdentityCallerProof({
31
+ issuers: read.issuers,
32
+ audience: trustedIssuers.audience,
33
+ clock,
34
+ }),
35
+ };
36
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import type { ServerConfig } from './server-config-schema.ts';
3
+ import { serverConfigSchema } from './server-config-schema.ts';
4
+
5
+ export type ConfigAnswer =
6
+ | { readonly kind: 'read'; readonly config: ServerConfig }
7
+ | { readonly kind: 'invalid'; readonly message: string };
8
+
9
+ // The configuration file, as JSON. A file that is missing, is no JSON, or fails the schema is
10
+ // `invalid`, with a message that names the file and the field.
11
+ export async function readServerConfig(path: string): Promise<ConfigAnswer> {
12
+ const file = Bun.file(path);
13
+
14
+ if (!(await file.exists())) {
15
+ return { kind: 'invalid', message: `no configuration file at ${path}` };
16
+ }
17
+
18
+ const text = await file.text();
19
+
20
+ const json = parseJSON(text);
21
+
22
+ if (json === undefined) {
23
+ return { kind: 'invalid', message: `the configuration file at ${path} is not JSON` };
24
+ }
25
+
26
+ const parsed = serverConfigSchema.safeParse(json);
27
+
28
+ return parsed.success
29
+ ? { kind: 'read', config: parsed.data }
30
+ : { kind: 'invalid', message: `${path}: ${z.prettifyError(parsed.error)}` };
31
+ }
32
+
33
+ function parseJSON(text: string): unknown {
34
+ try {
35
+ const value: unknown = JSON.parse(text);
36
+
37
+ return value;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }