@nervur-org/dock 0.3.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/GETTING_STARTED.md +128 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +37 -0
- package/api/quo-api.md +196 -0
- package/api/route.ts +227 -0
- package/beings/GLOSSARY.md +118 -0
- package/beings/WORLDS.md +567 -0
- package/beings/avatar.ts +101 -0
- package/beings/carry.ts +159 -0
- package/beings/clock.ts +552 -0
- package/beings/courier.ts +107 -0
- package/beings/desk.ts +128 -0
- package/beings/doorbell.ts +129 -0
- package/beings/envoy.ts +218 -0
- package/beings/index.ts +16 -0
- package/beings/join.ts +51 -0
- package/beings/link.ts +104 -0
- package/beings/look.ts +139 -0
- package/beings/porter.ts +43 -0
- package/beings/presence.ts +39 -0
- package/beings/quo-app-bells.md +280 -0
- package/beings/quo-dock.md +1942 -0
- package/beings/setup.ts +45 -0
- package/beings/side.ts +60 -0
- package/beings/user.ts +459 -0
- package/beings/way.ts +65 -0
- package/cli/bundles.ts +20 -0
- package/cli/client.ts +73 -0
- package/cli/daemon.ts +581 -0
- package/cli/estate/Caddyfile +33 -0
- package/cli/estate/quo.service +35 -0
- package/cli/estate/systems.quo.plist +44 -0
- package/cli/estate.ts +48 -0
- package/cli/folder.ts +27 -0
- package/cli/oauth.ts +32 -0
- package/cli/pilot.ts +38 -0
- package/cli/quo-dock.md +1942 -0
- package/cli/quo.ts +203 -0
- package/cli/stand.ts +160 -0
- package/dist/api/route.d.ts +21 -0
- package/dist/api/route.js +193 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +100 -0
- package/dist/beings/carry.d.ts +12 -0
- package/dist/beings/carry.js +174 -0
- package/dist/beings/clock.d.ts +161 -0
- package/dist/beings/clock.js +528 -0
- package/dist/beings/courier.d.ts +50 -0
- package/dist/beings/courier.js +96 -0
- package/dist/beings/desk.d.ts +53 -0
- package/dist/beings/desk.js +135 -0
- package/dist/beings/doorbell.d.ts +78 -0
- package/dist/beings/doorbell.js +133 -0
- package/dist/beings/envoy.d.ts +54 -0
- package/dist/beings/envoy.js +211 -0
- package/dist/beings/index.d.ts +14 -0
- package/dist/beings/index.js +16 -0
- package/dist/beings/join.d.ts +15 -0
- package/dist/beings/join.js +42 -0
- package/dist/beings/link.d.ts +14 -0
- package/dist/beings/link.js +91 -0
- package/dist/beings/look.d.ts +30 -0
- package/dist/beings/look.js +104 -0
- package/dist/beings/porter.d.ts +14 -0
- package/dist/beings/porter.js +41 -0
- package/dist/beings/presence.d.ts +8 -0
- package/dist/beings/presence.js +21 -0
- package/dist/beings/setup.d.ts +3 -0
- package/dist/beings/setup.js +39 -0
- package/dist/beings/side.d.ts +32 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +315 -0
- package/dist/beings/user.js +462 -0
- package/dist/beings/way.d.ts +19 -0
- package/dist/beings/way.js +28 -0
- package/dist/cli/bundles.d.ts +5 -0
- package/dist/cli/bundles.js +17 -0
- package/dist/cli/client.d.ts +15 -0
- package/dist/cli/client.js +58 -0
- package/dist/cli/daemon.d.ts +50 -0
- package/dist/cli/daemon.js +584 -0
- package/dist/cli/estate/Caddyfile +33 -0
- package/dist/cli/estate/quo.service +35 -0
- package/dist/cli/estate/systems.quo.plist +44 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +50 -0
- package/dist/cli/folder.d.ts +2 -0
- package/dist/cli/folder.js +25 -0
- package/dist/cli/oauth.d.ts +2 -0
- package/dist/cli/oauth.js +32 -0
- package/dist/cli/pilot.d.ts +5 -0
- package/dist/cli/pilot.js +22 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +201 -0
- package/dist/cli/stand.d.ts +37 -0
- package/dist/cli/stand.js +110 -0
- package/dist/dock/agents.d.ts +102 -0
- package/dist/dock/agents.js +105 -0
- package/dist/dock/apns.d.ts +27 -0
- package/dist/dock/apns.js +98 -0
- package/dist/dock/console.d.ts +6 -0
- package/dist/dock/console.js +20 -0
- package/dist/dock/dialers.d.ts +56 -0
- package/dist/dock/dialers.js +75 -0
- package/dist/dock/faculty.d.ts +24 -0
- package/dist/dock/faculty.js +142 -0
- package/dist/dock/fcm.d.ts +13 -0
- package/dist/dock/fcm.js +77 -0
- package/dist/dock/grants.d.ts +41 -0
- package/dist/dock/grants.js +99 -0
- package/dist/dock/http2.d.ts +4 -0
- package/dist/dock/http2.js +68 -0
- package/dist/dock/index.d.ts +41 -0
- package/dist/dock/index.js +113 -0
- package/dist/dock/local.d.ts +13 -0
- package/dist/dock/local.js +44 -0
- package/dist/dock/location.d.ts +19 -0
- package/dist/dock/location.js +11 -0
- package/dist/dock/notifications.d.ts +38 -0
- package/dist/dock/notifications.js +60 -0
- package/dist/dock/push.d.ts +79 -0
- package/dist/dock/push.js +132 -0
- package/dist/dock/service.d.ts +4 -0
- package/dist/dock/service.js +107 -0
- package/dist/dock/timer.d.ts +52 -0
- package/dist/dock/timer.js +149 -0
- package/dist/dock/webpush.d.ts +12 -0
- package/dist/dock/webpush.js +144 -0
- package/dist/harbor/browser.d.ts +15 -0
- package/dist/harbor/browser.js +82 -0
- package/dist/harbor/capacitor.d.ts +28 -0
- package/dist/harbor/capacitor.js +218 -0
- package/dist/harbor/disk.d.ts +27 -0
- package/dist/harbor/disk.js +249 -0
- package/dist/harbor/edge/alarm.d.ts +10 -0
- package/dist/harbor/edge/alarm.js +38 -0
- package/dist/harbor/edge/edge.d.ts +54 -0
- package/dist/harbor/edge/edge.js +370 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +232 -0
- package/dist/harbor/edge/given.d.ts +26 -0
- package/dist/harbor/edge/given.js +42 -0
- package/dist/harbor/edge/platform.d.ts +39 -0
- package/dist/harbor/edge/storage.d.ts +30 -0
- package/dist/harbor/edge/storage.js +180 -0
- package/dist/harbor/edge/worker.d.ts +17 -0
- package/dist/harbor/edge/worker.js +67 -0
- package/dist/harbor/files.d.ts +15 -0
- package/dist/harbor/files.js +220 -0
- package/dist/harbor/http.d.ts +19 -0
- package/dist/harbor/http.js +177 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +0 -0
- package/dist/harbor/quo.d.ts +10 -0
- package/dist/harbor/quo.js +42 -0
- package/dist/harbor/resolve.d.ts +16 -0
- package/dist/harbor/resolve.js +17 -0
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/harbor/sealed.d.ts +35 -0
- package/dist/harbor/sealed.js +162 -0
- package/dist/harbor/tauri.d.ts +16 -0
- package/dist/harbor/tauri.js +59 -0
- package/dist/harbor/ward.d.ts +9 -0
- package/dist/harbor/ward.js +15 -0
- package/dist/human/app.d.ts +32 -0
- package/dist/human/app.js +527 -0
- package/dist/human/apps.d.ts +10 -0
- package/dist/human/apps.js +29 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/door.d.ts +19 -0
- package/dist/human/door.js +92 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +33 -0
- package/dist/human/html.d.ts +55 -0
- package/dist/human/html.js +451 -0
- package/dist/human/local.d.ts +7 -0
- package/dist/human/local.js +10 -0
- package/dist/human/mark.d.ts +8 -0
- package/dist/human/mark.js +47 -0
- package/dist/human/move.d.ts +17 -0
- package/dist/human/move.js +90 -0
- package/dist/human/screen.d.ts +19 -0
- package/dist/human/screen.js +119 -0
- package/dist/human/style.d.ts +2 -0
- package/dist/human/style.js +60 -0
- package/dist/human/tab.bundle.txt +4775 -0
- package/dist/human/tab.d.ts +15 -0
- package/dist/human/tab.js +409 -0
- package/dist/human/tree.d.ts +50 -0
- package/dist/human/tree.js +157 -0
- package/dist/human/wake.d.ts +34 -0
- package/dist/human/wake.js +179 -0
- package/dist/human/web.d.ts +33 -0
- package/dist/human/web.js +184 -0
- package/dist/human/worker.bundle.txt +3868 -0
- package/dist/human/worker.d.ts +1 -0
- package/dist/human/worker.js +81 -0
- package/dist/human/worlds.d.ts +24 -0
- package/dist/human/worlds.js +99 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/allow.d.ts +20 -0
- package/dist/mcp/allow.js +88 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +92 -0
- package/dist/mcp/oauth.d.ts +91 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/route.d.ts +15 -0
- package/dist/mcp/route.js +42 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +99 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +102 -0
- package/dock/agents.ts +121 -0
- package/dock/apns.ts +138 -0
- package/dock/console.ts +23 -0
- package/dock/dialers.ts +85 -0
- package/dock/faculty.ts +148 -0
- package/dock/fcm.ts +103 -0
- package/dock/grants.ts +111 -0
- package/dock/http2.ts +70 -0
- package/dock/index.ts +133 -0
- package/dock/local.ts +48 -0
- package/dock/location.ts +22 -0
- package/dock/notifications.ts +67 -0
- package/dock/push.ts +161 -0
- package/dock/quo-app-bells.md +280 -0
- package/dock/quo-dock.md +1942 -0
- package/dock/service.ts +100 -0
- package/dock/timer.ts +174 -0
- package/dock/webpush.ts +182 -0
- package/harbor/browser.ts +97 -0
- package/harbor/capacitor.ts +223 -0
- package/harbor/disk.ts +237 -0
- package/harbor/edge/alarm.ts +48 -0
- package/harbor/edge/edge.ts +407 -0
- package/harbor/edge/exercise.ts +241 -0
- package/harbor/edge/given.ts +106 -0
- package/harbor/edge/platform.d.ts +39 -0
- package/harbor/edge/storage.ts +179 -0
- package/harbor/edge/text.d.ts +15 -0
- package/harbor/edge/worker.ts +70 -0
- package/harbor/edge/wrangler.toml +30 -0
- package/harbor/files.ts +221 -0
- package/harbor/http.ts +188 -0
- package/harbor/idb.ts +0 -0
- package/harbor/quo-harbor.md +850 -0
- package/harbor/quo.ts +44 -0
- package/harbor/resolve.ts +31 -0
- package/harbor/seal.ts +26 -0
- package/harbor/sealed.ts +183 -0
- package/harbor/tauri.ts +79 -0
- package/harbor/ward.ts +28 -0
- package/human/app.ts +580 -0
- package/human/apps.ts +47 -0
- package/human/dom.ts +21 -0
- package/human/door.ts +113 -0
- package/human/guest.ts +34 -0
- package/human/html.ts +490 -0
- package/human/local.ts +30 -0
- package/human/mark.ts +53 -0
- package/human/move.ts +122 -0
- package/human/quo-human.md +1042 -0
- package/human/screen.ts +136 -0
- package/human/style.ts +63 -0
- package/human/tab.bundle.txt +4775 -0
- package/human/tab.ts +446 -0
- package/human/tree.ts +217 -0
- package/human/wake.ts +191 -0
- package/human/web.ts +259 -0
- package/human/worker.bundle.txt +3868 -0
- package/human/worker.ts +124 -0
- package/human/worlds.ts +127 -0
- package/mcp/agent.ts +94 -0
- package/mcp/allow.ts +131 -0
- package/mcp/http.ts +92 -0
- package/mcp/oauth.ts +270 -0
- package/mcp/quo-mcp.md +343 -0
- package/mcp/route.ts +45 -0
- package/mcp/runner.ts +136 -0
- package/mcp/server.ts +101 -0
- package/package.json +121 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// What a side mounted at the edge is given. `Given` is the one shape: the
|
|
3
|
+
// code a page runs, the design a world wears, the public origins, the apps
|
|
4
|
+
// table and the bell key. On the daemon the same list is what `cli/daemon.ts`
|
|
5
|
+
// reads off a disk and out of `routes.json`; here it has two provenances,
|
|
6
|
+
// and the split is what the value is, not an accident of this file.
|
|
7
|
+
//
|
|
8
|
+
// `Deployed` is the half the deploy carries: the tab's bundle, the service
|
|
9
|
+
// worker's, and whatever being classes the deployment adds. They change only
|
|
10
|
+
// when something is built, so they are text modules the `wrangler.toml`
|
|
11
|
+
// names and the worker module holds them from its first line.
|
|
12
|
+
//
|
|
13
|
+
// `Settings` is the half that is worn and configured while the worker runs:
|
|
14
|
+
// the origins the proxy serves, the apps whose links this world's hostname
|
|
15
|
+
// opens, and the public half of the doorbell key. It is one row in the
|
|
16
|
+
// object's own storage, put there by the root's door, because a root
|
|
17
|
+
// reconfigures a box without redeploying it. The design a world wears is
|
|
18
|
+
// rows beside it, one per file, reached through `Folder` like every other
|
|
19
|
+
// terrain's.
|
|
20
|
+
import type { BeingClass } from '@nervur-org/nervur';
|
|
21
|
+
import { GRANTS } from '../../dock/grants.ts';
|
|
22
|
+
import { TIMER } from '../../dock/index.ts';
|
|
23
|
+
import type { Apps, Folder } from '../../human/web.ts';
|
|
24
|
+
import type { Routes } from '../../mcp/route.ts';
|
|
25
|
+
|
|
26
|
+
// What a worker lends the beings of the wards it holds, by name to the class
|
|
27
|
+
// that stands for it. The box's state has one home on every terrain, the box
|
|
28
|
+
// ward's partition, so the edge stands that ward like every other terrain;
|
|
29
|
+
// what differs is this list, and a faculty the platform has no answer for is
|
|
30
|
+
// not lent at all rather than lent and hollow.
|
|
31
|
+
//
|
|
32
|
+
// `grants` and `timer` are lent, and they are the whole of it. A bearer to an
|
|
33
|
+
// identity in a world is a record in her cells and nothing else, so the
|
|
34
|
+
// grants are the same code here as on a disk and the routes that honour a
|
|
35
|
+
// bearer, `api.` and `mcp.`, work the moment she stands. The timer is the
|
|
36
|
+
// same class too, with the one method that knows a terrain replaced: she arms
|
|
37
|
+
// the object's own storage alarm, which is the clock here, so a home on this
|
|
38
|
+
// terrain wakes for its schedules with nobody asking. `Alarm` in
|
|
39
|
+
// `edge/alarm.ts` is that subclass.
|
|
40
|
+
//
|
|
41
|
+
// The three others every box ships are not lent here, each for its own
|
|
42
|
+
// reason. `location` and `notifications` are a place and a screen, and a
|
|
43
|
+
// worker is at neither: it runs wherever the platform put it and no human is
|
|
44
|
+
// looking at it. `push` rings under this world's own web origin, and a worker
|
|
45
|
+
// learns its origin from the request it is answering and not at boot, so a
|
|
46
|
+
// ring signed at the moment the faculty stood would be signed for nowhere.
|
|
47
|
+
//
|
|
48
|
+
// There is no process on the other side of a worker either, so `local`,
|
|
49
|
+
// `dialers` and `agents`, which the daemon adds, name nothing here: no
|
|
50
|
+
// socket a process was seen on, no line this box holds open, and no command
|
|
51
|
+
// to run an agent in.
|
|
52
|
+
export const LENT: Record<string, string> = { [GRANTS]: 'Grants', [TIMER]: 'Alarm' };
|
|
53
|
+
|
|
54
|
+
// The ceiling on one row in this object's storage, key and value together,
|
|
55
|
+
// in bytes. The platform publishes it for a SQLite-backed Durable Object,
|
|
56
|
+
// which is what `wrangler.toml` declares this class to be, and past it the
|
|
57
|
+
// storage refuses the write. It is written here once, beside the rest of
|
|
58
|
+
// what this terrain is, because a number a store carries in its own body is
|
|
59
|
+
// a number nobody finds when the platform moves it.
|
|
60
|
+
//
|
|
61
|
+
// A being is one row, so this is the ceiling on one being's cells and her
|
|
62
|
+
// bind table. The store measures the JSON it is about to write and refuses
|
|
63
|
+
// over it rather than handing the platform a value it will refuse anyway, so
|
|
64
|
+
// the refusal is the same on the day the platform's own message changes and
|
|
65
|
+
// the same in a suite that never reaches a real object.
|
|
66
|
+
export const ROW_CEILING = 2_000_000;
|
|
67
|
+
|
|
68
|
+
// The tab's bundle and the service worker's, as code. Built once by whoever
|
|
69
|
+
// built this package, never by a route and never here.
|
|
70
|
+
export type Code = { tab: string; worker: string };
|
|
71
|
+
|
|
72
|
+
// The rows. Every field is absent until a root writes it, and a side asked
|
|
73
|
+
// with none of them serves what a box with no configuration can: pages on
|
|
74
|
+
// the worker's own origin, no apps, no doorbell.
|
|
75
|
+
export type Settings = {
|
|
76
|
+
// the public faces of the four routes, as the proxy serves them, the same
|
|
77
|
+
// shape `routes.json` holds on a disk
|
|
78
|
+
routes?: Routes;
|
|
79
|
+
// the apps this world's links open, or none
|
|
80
|
+
apps?: Apps | null;
|
|
81
|
+
// the public half of this world's doorbell key. Without it no service
|
|
82
|
+
// worker is served and no page registers one.
|
|
83
|
+
bell?: string | null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The row, read and written whole: three small fields that a root sets
|
|
87
|
+
// together and a side reads at every request.
|
|
88
|
+
export type SettingsStore = {
|
|
89
|
+
load(): Promise<Settings>;
|
|
90
|
+
save(settings: Settings): Promise<void>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// What the deploy carries, which the worker module is constructed with.
|
|
94
|
+
// A deployment that names no bundles serves no pages: the web side is not
|
|
95
|
+
// mounted at all, because a page carrying no tab code is a page that loads
|
|
96
|
+
// and then does nothing. That is what the conformance exercise inside the
|
|
97
|
+
// platform is, and never a deploy.
|
|
98
|
+
export type Deployed = {
|
|
99
|
+
code?: Code;
|
|
100
|
+
// being classes beyond the built-ins, this deployment's own
|
|
101
|
+
classes?: Record<string, BeingClass>;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// The whole of it, as a side is handed it: the deploy's half, the rows, and
|
|
105
|
+
// the folder those rows make.
|
|
106
|
+
export type Given = Deployed & { settings: Settings; folder: Folder };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The edge platform, as much of it as the harbor touches and no more. The
|
|
3
|
+
// vendor's own type package declares every global a worker has, and it
|
|
4
|
+
// collides with the DOM library this tree compiles against; these few
|
|
5
|
+
// lines are what the edge harbor and worker actually call.
|
|
6
|
+
// The rows: everything the object keeps under a key. Whatever answers these
|
|
7
|
+
// five is a place to keep rows, which is why what is kept in storage is
|
|
8
|
+
// written against this and not against the object.
|
|
9
|
+
export type Rows = {
|
|
10
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
11
|
+
get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
|
|
12
|
+
put(key: string, value: unknown): Promise<void>;
|
|
13
|
+
delete(key: string): Promise<boolean>;
|
|
14
|
+
list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
|
|
15
|
+
};
|
|
16
|
+
// The object's storage: its rows and its clock. One alarm per object,
|
|
17
|
+
// replaced by the next set and gone when it is deleted, and the object's
|
|
18
|
+
// `alarm` handler runs when it goes off, with no request behind it.
|
|
19
|
+
export type Storage = Rows & {
|
|
20
|
+
setAlarm(at: number | Date): Promise<void>;
|
|
21
|
+
getAlarm(): Promise<number | null>;
|
|
22
|
+
deleteAlarm(): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export type State = { id: { toString(): string; name?: string }; storage: Storage };
|
|
25
|
+
export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
|
|
26
|
+
export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
|
|
27
|
+
export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
|
|
28
|
+
// The server end of a WebSocketPair: the standard surface plus accept.
|
|
29
|
+
export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
|
|
30
|
+
declare global {
|
|
31
|
+
// eslint-disable-next-line no-var
|
|
32
|
+
var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
|
|
33
|
+
interface ResponseInit {
|
|
34
|
+
webSocket?: unknown;
|
|
35
|
+
}
|
|
36
|
+
interface Response {
|
|
37
|
+
readonly webSocket?: unknown;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store as Durable Object storage: one object per harbor, its storage
|
|
3
|
+
// holding one row per ward under `ward:<name>`, seed and record, one row per
|
|
4
|
+
// row of its partition under `row:<name> <row>`, and one row per hint under
|
|
5
|
+
// `hint:<pk>`. A being is a row of her own because a being's cells are the
|
|
6
|
+
// one part of a partition that grows without limit, and a ward kept as one
|
|
7
|
+
// value would write every being in it down whenever any one was asked.
|
|
8
|
+
//
|
|
9
|
+
// The seed is kept sealed under a
|
|
10
|
+
// key from the platform's secrets, `QUO_SEED_KEY`, with the seal in
|
|
11
|
+
// `../seal.ts`, so the storage holds ciphertext and the secret store holds
|
|
12
|
+
// the one key, which is what the harbor document's table says the edge
|
|
13
|
+
// does. Values cross as JSON, as they do into a file, because the ward
|
|
14
|
+
// hands the partition out through a guard that structured clone refuses.
|
|
15
|
+
// The same storage answers the mcp route's dance, under `oauth`, the box's
|
|
16
|
+
// own settings, under `settings`, and the design a world wears, one row per
|
|
17
|
+
// file under `design:<file>`, because the object is the whole of what this
|
|
18
|
+
// terrain has to keep anything in.
|
|
19
|
+
import { fromRows, rowsIn, rowsOf, type Kept, type Store, type WardRecord } from '@nervur-org/nervur/harbor';
|
|
20
|
+
import { seal, open } from '../seal.ts';
|
|
21
|
+
import { emptyDance, type Dance, type Store as DanceStore } from '../../mcp/oauth.ts';
|
|
22
|
+
import type { Folder } from '../../human/web.ts';
|
|
23
|
+
import { ROW_CEILING, type Settings, type SettingsStore } from './given.ts';
|
|
24
|
+
import type { Rows, Storage } from './platform.d.ts';
|
|
25
|
+
|
|
26
|
+
// The box's settings: the origins, the apps table and the bell key, one row
|
|
27
|
+
// read whole and written whole. Nothing of Quo's is in it, so nothing here
|
|
28
|
+
// is sealed, and a box a root has never configured reads an empty one.
|
|
29
|
+
export function settingsInStorage(storage: Rows, prefix = ''): SettingsStore {
|
|
30
|
+
const key = `${prefix}settings`;
|
|
31
|
+
return {
|
|
32
|
+
load: async () => (await storage.get<Settings>(key)) ?? {},
|
|
33
|
+
save: (settings: Settings) => storage.put(key, settings),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The design a world wears and the world's own code for the tab, as rows:
|
|
38
|
+
// what a disk answers out of `design/` and `tab/index.js`, answered out of
|
|
39
|
+
// storage instead. A row is text, because a stylesheet is text and so is a
|
|
40
|
+
// bundle; a file the root has not put there answers nothing and the route
|
|
41
|
+
// declines the path.
|
|
42
|
+
export const DESIGN_ROW = 'design:';
|
|
43
|
+
export const BEINGS_ROW = 'beings';
|
|
44
|
+
|
|
45
|
+
export function folderInStorage(storage: Rows, prefix = ''): Folder {
|
|
46
|
+
const design = (file: string) => `${prefix}${DESIGN_ROW}${file}`;
|
|
47
|
+
const held = (file: string) => storage.get<string>(design(file)).then((v) => v ?? null);
|
|
48
|
+
return {
|
|
49
|
+
wearing: async () => ((await held('tokens.css')) === null ? { tokens: false, fonts: false } : { tokens: true, fonts: (await held('fonts.css')) !== null }),
|
|
50
|
+
design: (file) => held(file),
|
|
51
|
+
serving: async () => (await storage.get<string>(`${prefix}${BEINGS_ROW}`)) !== undefined,
|
|
52
|
+
beings: async () => (await storage.get<string>(`${prefix}${BEINGS_ROW}`)) ?? null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The writing half of that folder, which is the root's door and nothing
|
|
57
|
+
// else: a file put there, a file taken away with null, and the names now
|
|
58
|
+
// held so that a root sees what it wrote.
|
|
59
|
+
export function wearInStorage(storage: Rows, prefix = '') {
|
|
60
|
+
const head = `${prefix}${DESIGN_ROW}`;
|
|
61
|
+
return {
|
|
62
|
+
put: async (file: string, text: string | null): Promise<void> => {
|
|
63
|
+
if (text === null) await storage.delete(head + file);
|
|
64
|
+
else await storage.put(head + file, text);
|
|
65
|
+
},
|
|
66
|
+
worn: async (): Promise<string[]> => [...(await storage.list({ prefix: head })).keys()].map((k) => k.slice(head.length)),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The mcp route's dance, kept in the same object's storage under one key.
|
|
71
|
+
// It is one value and not a row apiece: clients, pending requests and codes
|
|
72
|
+
// all die in minutes, and the whole of it is smaller than one ward's seed.
|
|
73
|
+
// Nothing of Quo's is in it, so nothing here is sealed.
|
|
74
|
+
export function danceInStorage(storage: Rows, prefix = ''): DanceStore {
|
|
75
|
+
const key = `${prefix}oauth`;
|
|
76
|
+
return {
|
|
77
|
+
load: async () => ({ ...emptyDance(), ...((await storage.get<Partial<Dance>>(key)) ?? {}) }),
|
|
78
|
+
save: (dance: Dance) => storage.put(key, dance),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Row = { seed: string; record: WardRecord };
|
|
83
|
+
|
|
84
|
+
// `prefix` keeps more than one harbor apart in one object's storage: the
|
|
85
|
+
// exercise does that, a deployment never does.
|
|
86
|
+
// `ceiling` is the platform's, and is a parameter for the one suite that
|
|
87
|
+
// has to see a row refused: a row of the real size crossing a real door
|
|
88
|
+
// costs more than the fault it proves, and the ceiling a deployment runs
|
|
89
|
+
// under is the platform's number and never an argument.
|
|
90
|
+
export class DurableStorage implements Store {
|
|
91
|
+
readonly storage: Storage;
|
|
92
|
+
readonly key: CryptoKey;
|
|
93
|
+
readonly prefix: string;
|
|
94
|
+
readonly ceiling: number;
|
|
95
|
+
constructor(storage: Storage, key: CryptoKey, prefix = '', ceiling = ROW_CEILING) {
|
|
96
|
+
this.storage = storage;
|
|
97
|
+
this.key = key;
|
|
98
|
+
this.prefix = prefix;
|
|
99
|
+
this.ceiling = ceiling;
|
|
100
|
+
}
|
|
101
|
+
#k(kind: 'ward' | 'hint' | 'row', name: string) {
|
|
102
|
+
return `${this.prefix}${kind}:${name}`;
|
|
103
|
+
}
|
|
104
|
+
// A ward's rows sort together under one prefix, and nothing else falls
|
|
105
|
+
// between them: no name carries the separator.
|
|
106
|
+
#rows(name: string) {
|
|
107
|
+
return this.#k('row', `${name} `);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async list(): Promise<string[]> {
|
|
111
|
+
const head = this.#k('ward', '');
|
|
112
|
+
return [...(await this.storage.list({ prefix: head })).keys()].map((k) => k.slice(head.length));
|
|
113
|
+
}
|
|
114
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
115
|
+
const row = await this.storage.get<Row>(this.#k('ward', name));
|
|
116
|
+
if (!row) return undefined;
|
|
117
|
+
const head = this.#rows(name);
|
|
118
|
+
const kept = [...(await this.storage.list<Record<string, unknown>>({ prefix: head }))].map(([k, v]) => [k.slice(head.length), v] as const);
|
|
119
|
+
return { seed: await open(this.key, row.seed), partition: fromRows(Object.fromEntries(kept)), record: row.record };
|
|
120
|
+
}
|
|
121
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
122
|
+
if ((await this.storage.get(this.#k('ward', name))) !== undefined) throw new Error(`ward ${name} already exists here`);
|
|
123
|
+
await this.storage.put(this.#k('ward', name), { seed: await seal(this.key, kept.seed), record: kept.record } satisfies Row);
|
|
124
|
+
await this.#keep(name, kept.partition, rowsIn(kept.partition));
|
|
125
|
+
}
|
|
126
|
+
async save(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
|
|
127
|
+
if ((await this.storage.get(this.#k('ward', name))) === undefined) return; // a name not kept is nothing
|
|
128
|
+
await this.#keep(name, partition, rows);
|
|
129
|
+
}
|
|
130
|
+
// The rows named, written or deleted. A row whose being has left comes back
|
|
131
|
+
// undefined and her value goes with her.
|
|
132
|
+
//
|
|
133
|
+
// A row past the object's ceiling is refused here, before the platform is
|
|
134
|
+
// asked. The platform refuses it too, and the two refusals must be the
|
|
135
|
+
// same one: a store that let the platform decide would keep a row on the
|
|
136
|
+
// day the limit moved and refuse it the day after, with nothing in this
|
|
137
|
+
// tree saying so. The harbor turns the throw into the answer of the ask
|
|
138
|
+
// that wrote the row, and the being is stood back at what is kept.
|
|
139
|
+
//
|
|
140
|
+
// Every row is measured before any is written, so a save that is refused
|
|
141
|
+
// writes nothing at all. A store that wrote half a save and threw would
|
|
142
|
+
// leave one being moved and her counterpart not, and the harbor standing
|
|
143
|
+
// the ward back at what is kept would find a torn state to stand at: a
|
|
144
|
+
// caller's numbers rolled back and the door's mark not, which is a
|
|
145
|
+
// relation that can never speak again.
|
|
146
|
+
async #keep(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
|
|
147
|
+
const head = this.#rows(name);
|
|
148
|
+
const write = rowsOf(partition, rows).map(([row, value]) => {
|
|
149
|
+
const key = head + row;
|
|
150
|
+
if (value === undefined) return { key, value };
|
|
151
|
+
const size = new TextEncoder().encode(JSON.stringify(value)).length + new TextEncoder().encode(key).length;
|
|
152
|
+
if (size > this.ceiling) throw new Error(`the row ${key} is ${size} bytes, past this object's ceiling of ${this.ceiling}`);
|
|
153
|
+
return { key, value };
|
|
154
|
+
});
|
|
155
|
+
for (const { key, value } of write) {
|
|
156
|
+
if (value === undefined) await this.storage.delete(key);
|
|
157
|
+
else await this.storage.put(key, value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
161
|
+
const row = await this.storage.get<Row>(this.#k('ward', name));
|
|
162
|
+
if (row) await this.storage.put(this.#k('ward', name), { ...row, record } satisfies Row);
|
|
163
|
+
}
|
|
164
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
165
|
+
const kept = await this.load(name);
|
|
166
|
+
if (!kept) return undefined;
|
|
167
|
+
const head = this.#rows(name);
|
|
168
|
+
for (const key of (await this.storage.list({ prefix: head })).keys()) await this.storage.delete(key); // her rows go with her, or the next put reads them
|
|
169
|
+
await this.storage.delete(this.#k('ward', name));
|
|
170
|
+
return kept;
|
|
171
|
+
}
|
|
172
|
+
async hints(): Promise<Record<string, string>> {
|
|
173
|
+
const head = this.#k('hint', '');
|
|
174
|
+
return Object.fromEntries([...(await this.storage.list<string>({ prefix: head }))].map(([k, v]) => [k.slice(head.length), v]));
|
|
175
|
+
}
|
|
176
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
177
|
+
await this.storage.put(this.#k('hint', pk), url);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A bundle, imported as text. The platform's build carries a file named by a
|
|
3
|
+
// `type = "Text"` rule in the `wrangler.toml` into the worker as a string,
|
|
4
|
+
// which is how a built artefact of a deploy reaches a side mounted there
|
|
5
|
+
// without any of it being a row a ward keeps. This file says what that
|
|
6
|
+
// import is worth to the typechecker, and nothing else in the tree imports
|
|
7
|
+
// one: the daemon reads the same file off its disk instead.
|
|
8
|
+
//
|
|
9
|
+
// It declares an ambient module, so it holds no import and no export of its
|
|
10
|
+
// own; a file with either would be a module and the declaration would not be
|
|
11
|
+
// ambient.
|
|
12
|
+
declare module '*.bundle.txt' {
|
|
13
|
+
const text: string;
|
|
14
|
+
export default text;
|
|
15
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The worker: the edge harbor deployed. One Durable Object class, `Quo`,
|
|
3
|
+
// one instance per harbor name, and the worker routes by the first path
|
|
4
|
+
// segment: `/h/<name>/...` goes to that harbor. A world's quo. route on the
|
|
5
|
+
// edge is `<origin>/h/<name>/quo`, and its health `<origin>/h/<name>/health`.
|
|
6
|
+
// `wrangler.toml` beside this file names the class and the secrets.
|
|
7
|
+
//
|
|
8
|
+
// One thing lives here for the terrain test alone: with `QUO_EXERCISE` set,
|
|
9
|
+
// which no deployment sets, `/exercise` runs the library's conformance
|
|
10
|
+
// suite inside the platform against two harbors in this one object, over
|
|
11
|
+
// two prefixed views of its storage, reaching each other by request
|
|
12
|
+
// through the worker under `/x/<sub>/quo`. One object cannot touch
|
|
13
|
+
// another's storage, the platform says so, and so the two harbors share an
|
|
14
|
+
// object the way two tabs share a page.
|
|
15
|
+
import { EdgeHarbor } from './edge.ts';
|
|
16
|
+
import { exercise } from './exercise.ts';
|
|
17
|
+
import type { Deployed } from './given.ts';
|
|
18
|
+
import type { State, Env } from './platform.d.ts';
|
|
19
|
+
import tab from '../../human/tab.bundle.txt';
|
|
20
|
+
import serviceWorker from '../../human/worker.bundle.txt';
|
|
21
|
+
|
|
22
|
+
// What the deploy carries, from this module's first line: the tab's bundle
|
|
23
|
+
// and the service worker's, as text modules the `wrangler.toml` names. They
|
|
24
|
+
// are built artefacts and never rows, so they arrive with the code and not
|
|
25
|
+
// out of the object's storage. Everything a root configures while the worker
|
|
26
|
+
// runs is a row, and `given.ts` holds the whole shape.
|
|
27
|
+
export const DEPLOYED: Deployed = { code: { tab, worker: serviceWorker } };
|
|
28
|
+
|
|
29
|
+
export class Quo {
|
|
30
|
+
readonly state: State;
|
|
31
|
+
readonly env: Env;
|
|
32
|
+
#harbor: Promise<EdgeHarbor> | undefined;
|
|
33
|
+
constructor(state: State, env: Env) {
|
|
34
|
+
this.state = state;
|
|
35
|
+
this.env = env;
|
|
36
|
+
}
|
|
37
|
+
harbor(): Promise<EdgeHarbor> {
|
|
38
|
+
return (this.#harbor ??= EdgeHarbor.open(this.state, this.env, DEPLOYED));
|
|
39
|
+
}
|
|
40
|
+
// The platform's one way of waking an object with nobody asking. The
|
|
41
|
+
// harbor opens as it does for a request, every ward boots from storage,
|
|
42
|
+
// and the box's timer runs her round: a home here with a sweep at three in
|
|
43
|
+
// the morning wakes once a night, and no request is made to cause it.
|
|
44
|
+
async alarm(): Promise<void> {
|
|
45
|
+
const harbor = await this.harbor();
|
|
46
|
+
await harbor.ready();
|
|
47
|
+
await harbor.rang();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async fetch(request: Request): Promise<Response> {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
|
|
53
|
+
if (!m) return new Response(JSON.stringify({ error: 'no such route' }), { status: 404, headers: { 'content-type': 'application/json' } });
|
|
54
|
+
const path = m[2] ?? '/';
|
|
55
|
+
// Where this harbor answers, as the world outside sees it: the routes a
|
|
56
|
+
// page hands its tab are these unless a root has named public ones.
|
|
57
|
+
const base = `${url.origin}/h/${m[1]}`;
|
|
58
|
+
if (this.env.QUO_EXERCISE && (path === '/exercise' || path.startsWith('/x/'))) return exercise(request, base, path, this.state, this.env);
|
|
59
|
+
return (await this.harbor()).fetch(request, path, base);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default {
|
|
64
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
65
|
+
const url = new URL(request.url);
|
|
66
|
+
const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
|
|
67
|
+
if (!m) return new Response(JSON.stringify({ error: 'a harbor is /h/<name>/...' }), { status: 404, headers: { 'content-type': 'application/json' } });
|
|
68
|
+
return env.HARBOR.get(env.HARBOR.idFromName(m[1])).fetch(request);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# The edge harbor as a worker. Deploy from `dock/` with
|
|
2
|
+
# npx wrangler deploy --config harbor/edge/wrangler.toml
|
|
3
|
+
# and set the two secrets once, never in this file:
|
|
4
|
+
# npx wrangler secret put QUO_SEED_KEY --config harbor/edge/wrangler.toml 32 bytes as hex
|
|
5
|
+
# npx wrangler secret put QUO_ROOT --config harbor/edge/wrangler.toml the root's bearer
|
|
6
|
+
name = "quo-edge"
|
|
7
|
+
main = "worker.ts"
|
|
8
|
+
compatibility_date = "2026-09-01"
|
|
9
|
+
# A deployed worker answers on the account's workers.dev subdomain; a custom
|
|
10
|
+
# domain wants a token with the zone's Workers Routes permission, which the
|
|
11
|
+
# account's token does not hold. A world's quo. route on it is
|
|
12
|
+
# <origin>/h/main/quo.
|
|
13
|
+
workers_dev = true
|
|
14
|
+
|
|
15
|
+
# The bundles a page runs, carried into the worker as strings. They are built
|
|
16
|
+
# artefacts of a deploy, so the deploy carries them and no row keeps them; the
|
|
17
|
+
# dock's build emits them beside `human/`, and this rule is what makes the
|
|
18
|
+
# worker's import of one a string rather than a module.
|
|
19
|
+
[[rules]]
|
|
20
|
+
type = "Text"
|
|
21
|
+
globs = ["**/*.bundle.txt"]
|
|
22
|
+
fallthrough = false
|
|
23
|
+
|
|
24
|
+
[[durable_objects.bindings]]
|
|
25
|
+
name = "HARBOR"
|
|
26
|
+
class_name = "Quo"
|
|
27
|
+
|
|
28
|
+
[[migrations]]
|
|
29
|
+
tag = "v1"
|
|
30
|
+
new_sqlite_classes = ["Quo"]
|
package/harbor/files.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store as a folder on disk, one folder per ward, in one of two forms.
|
|
3
|
+
//
|
|
4
|
+
// Plain, when the store has no key, what the droplets run:
|
|
5
|
+
//
|
|
6
|
+
// <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
|
|
7
|
+
// <dir>/wards/<name>/rows/head.json the partition's head
|
|
8
|
+
// <dir>/wards/<name>/rows/b-<hex>.json one being, her key as hex utf8
|
|
9
|
+
// <dir>/wards/<name>/ward.json the ward record
|
|
10
|
+
//
|
|
11
|
+
// A being is a file of her own because a being's cells are the one part of a
|
|
12
|
+
// partition that grows without limit, and a ward that wrote one file would
|
|
13
|
+
// write every being in it down whenever any one of them was asked. Her key is
|
|
14
|
+
// a being's own word and can be anything a string can be, so the file is
|
|
15
|
+
// named for its bytes and never for the key itself.
|
|
16
|
+
//
|
|
17
|
+
// Sealed, when the store holds a key, what a device's daemon runs with the
|
|
18
|
+
// key from its Keychain:
|
|
19
|
+
//
|
|
20
|
+
// <dir>/wards/<name>/ward.sealed seed, partition and record as one
|
|
21
|
+
// JSON, sealed under the key, hex
|
|
22
|
+
//
|
|
23
|
+
// and in both:
|
|
24
|
+
//
|
|
25
|
+
// <dir>/reach.json the directory's hints
|
|
26
|
+
//
|
|
27
|
+
// A ward is whole in either form, and a store that meets the other form
|
|
28
|
+
// refuses it by name: a daemon started without its key, or with one against
|
|
29
|
+
// a plain folder, fails loudly instead of booting on what it cannot read.
|
|
30
|
+
// Sealing a plain folder is a deliberate command, never a boot's doing.
|
|
31
|
+
// Every write of a ward goes through a temp file and a rename, and writes
|
|
32
|
+
// are queued per ward so two calls never race on one file.
|
|
33
|
+
import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
|
|
34
|
+
import { existsSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { arithmetic } from '@nervur-org/nervur/ward';
|
|
37
|
+
import { HEAD, fromRows, rowsIn, rowsOf, type Kept, type Store, type WardRecord } from '@nervur-org/nervur/harbor';
|
|
38
|
+
import { sealKey, seal, open } from './seal.ts';
|
|
39
|
+
|
|
40
|
+
const { hex, unhex } = arithmetic;
|
|
41
|
+
type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
42
|
+
|
|
43
|
+
// A row's file. The head has a name of its own so that the empty key it goes
|
|
44
|
+
// by never becomes an empty file name; a being's file is named for the digest
|
|
45
|
+
// of her key, and her key is written inside it.
|
|
46
|
+
//
|
|
47
|
+
// The digest, and not the key itself in some spelling: a being's key is her
|
|
48
|
+
// owner's own word and can be as long as a string can be, and a ward's own
|
|
49
|
+
// key is a hundred and twenty-eight characters before anything is added to
|
|
50
|
+
// it. Every encoding of a key grows it, and a file system stops at a length.
|
|
51
|
+
// A digest is the same size whatever it is given.
|
|
52
|
+
const utf8 = new TextEncoder();
|
|
53
|
+
type Kind = { row: string; value: Record<string, unknown> };
|
|
54
|
+
const fileOf = async (row: string): Promise<string> => (row === HEAD ? 'head.json' : `b-${hex(await arithmetic.sha256(utf8.encode(row)))}.json`);
|
|
55
|
+
|
|
56
|
+
export class Files implements Store {
|
|
57
|
+
readonly dir: string;
|
|
58
|
+
readonly #key?: Promise<CryptoKey>;
|
|
59
|
+
readonly #queues = new Map<string, Promise<void>>();
|
|
60
|
+
constructor(dir: string, key?: string) {
|
|
61
|
+
this.dir = dir;
|
|
62
|
+
if (key) this.#key = sealKey(key);
|
|
63
|
+
}
|
|
64
|
+
#ward(name: string) {
|
|
65
|
+
return join(this.dir, 'wards', name);
|
|
66
|
+
}
|
|
67
|
+
get sealed(): boolean {
|
|
68
|
+
return this.#key !== undefined;
|
|
69
|
+
}
|
|
70
|
+
// The form a folder holds, checked against the form this store speaks.
|
|
71
|
+
#form(name: string): 'plain' | 'sealed' | undefined {
|
|
72
|
+
const wd = this.#ward(name);
|
|
73
|
+
const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
|
|
74
|
+
if (form === 'sealed' && !this.sealed) throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
|
|
75
|
+
if (form === 'plain' && this.sealed) throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
|
|
76
|
+
return form;
|
|
77
|
+
}
|
|
78
|
+
// One write at a time per ward, through a temp file and a rename.
|
|
79
|
+
#write(name: string, file: string, body: string): Promise<void> {
|
|
80
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
81
|
+
const tmp = join(this.#ward(name), `${file}.tmp`);
|
|
82
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
83
|
+
await rename(tmp, join(this.#ward(name), file));
|
|
84
|
+
});
|
|
85
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
async #read(name: string): Promise<Blob> {
|
|
89
|
+
const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
|
|
90
|
+
return JSON.parse(new TextDecoder().decode(await open(await this.#key!, blob))) as Blob;
|
|
91
|
+
}
|
|
92
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
93
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
94
|
+
#keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
|
|
95
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
96
|
+
const wd = this.#ward(name);
|
|
97
|
+
const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
|
|
98
|
+
const sealed = await seal(await this.#key!, new TextEncoder().encode(JSON.stringify(blob)));
|
|
99
|
+
await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
|
|
100
|
+
await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
|
|
101
|
+
});
|
|
102
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async list(): Promise<string[]> {
|
|
107
|
+
const wards = join(this.dir, 'wards');
|
|
108
|
+
return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
112
|
+
const form = this.#form(name);
|
|
113
|
+
if (!form) return undefined;
|
|
114
|
+
if (form === 'sealed') {
|
|
115
|
+
const b = await this.#read(name);
|
|
116
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
117
|
+
}
|
|
118
|
+
const wd = this.#ward(name);
|
|
119
|
+
return {
|
|
120
|
+
seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
|
|
121
|
+
partition: fromRows(await this.#rows(name)),
|
|
122
|
+
record: JSON.parse(await readFile(join(wd, 'ward.json'), 'utf8')) as WardRecord,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Every row a ward's folder holds. A file the naming does not recognise is
|
|
127
|
+
// not a row and is left where it is: a folder is a place a person looks.
|
|
128
|
+
async #rows(name: string): Promise<Record<string, Record<string, unknown>>> {
|
|
129
|
+
const dir = join(this.#ward(name), 'rows');
|
|
130
|
+
if (!existsSync(dir)) return {};
|
|
131
|
+
const out: Record<string, Record<string, unknown>> = {};
|
|
132
|
+
for (const file of await readdir(dir)) {
|
|
133
|
+
if (file === 'head.json') out[HEAD] = JSON.parse(await readFile(join(dir, file), 'utf8')) as Record<string, unknown>;
|
|
134
|
+
else if (/^b-[0-9a-f]{64}\.json$/.test(file)) {
|
|
135
|
+
const kept = JSON.parse(await readFile(join(dir, file), 'utf8')) as Kind;
|
|
136
|
+
out[kept.row] = kept.value;
|
|
137
|
+
}
|
|
138
|
+
// anything else is not a row and is left where it is: a folder is a
|
|
139
|
+
// place a person looks
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The rows named, written or removed. A row whose being has left comes back
|
|
145
|
+
// undefined and her file goes with her.
|
|
146
|
+
//
|
|
147
|
+
// The whole of it is one job in the ward's line, the folder included, so a
|
|
148
|
+
// save cannot lay a file down inside a ward that a take is removing: `take`
|
|
149
|
+
// waits for this line before it removes anything, and a mkdir outside it
|
|
150
|
+
// would build the folder again underneath.
|
|
151
|
+
#keepRows(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
|
|
152
|
+
const written = rowsOf(partition, rows);
|
|
153
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
154
|
+
// Read here and not before: a ward taken while this waited its turn is
|
|
155
|
+
// a ward this must not write, and a mkdir would build its folder again
|
|
156
|
+
// under whoever removed it. A name not kept is nothing, and that is as
|
|
157
|
+
// true at the head of the line as it was at the back of it.
|
|
158
|
+
if (!existsSync(this.#ward(name))) return;
|
|
159
|
+
const dir = join(this.#ward(name), 'rows');
|
|
160
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
161
|
+
for (const [row, value] of written) {
|
|
162
|
+
const file = join(dir, await fileOf(row));
|
|
163
|
+
if (value === undefined) {
|
|
164
|
+
await rm(file, { force: true });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const tmp = `${file}.tmp`;
|
|
168
|
+
await writeFile(tmp, JSON.stringify(row === HEAD ? value : ({ row, value } satisfies Kind)) + '\n', { mode: 0o600 });
|
|
169
|
+
await rename(tmp, file);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
173
|
+
return next;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
177
|
+
if (this.#form(name)) throw new Error(`ward ${name} already exists in ${this.dir}`);
|
|
178
|
+
const wd = this.#ward(name);
|
|
179
|
+
await mkdir(wd, { recursive: true, mode: 0o700 });
|
|
180
|
+
if (this.sealed) return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
181
|
+
await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
|
|
182
|
+
await chmod(join(wd, 'seed'), 0o600);
|
|
183
|
+
await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
|
|
184
|
+
await this.#keepRows(name, kept.partition, rowsIn(kept.partition));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A sealed ward is one sealed value and is written whole; a plain one is
|
|
188
|
+
// rows, and only the rows the ward named are touched.
|
|
189
|
+
async save(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
|
|
190
|
+
const form = this.#form(name);
|
|
191
|
+
if (!form) return; // a name not kept is nothing
|
|
192
|
+
if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, partition }));
|
|
193
|
+
return this.#keepRows(name, partition, rows);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
197
|
+
const form = this.#form(name);
|
|
198
|
+
if (!form) return;
|
|
199
|
+
if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, record }));
|
|
200
|
+
return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
204
|
+
const kept = await this.load(name);
|
|
205
|
+
if (!kept) return undefined;
|
|
206
|
+
await this.#queues.get(name);
|
|
207
|
+
await rm(this.#ward(name), { recursive: true, force: true });
|
|
208
|
+
return kept;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async hints(): Promise<Record<string, string>> {
|
|
212
|
+
const p = join(this.dir, 'reach.json');
|
|
213
|
+
return existsSync(p) ? (JSON.parse(await readFile(p, 'utf8')) as Record<string, string>) : {};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
217
|
+
const all = await this.hints();
|
|
218
|
+
all[pk] = url;
|
|
219
|
+
await writeFile(join(this.dir, 'reach.json'), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
220
|
+
}
|
|
221
|
+
}
|