@dreamteamer/web 0.1.0 → 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.
@@ -0,0 +1,37 @@
1
+ // Sessions (cr-modules-017 §2, as the security sign-off revised it): a random sid in an HttpOnly SameSite=Strict
2
+ // browser-session cookie (no Max-Age). The server holds the sessions in its MEMORY only — never in the state file — so
3
+ // a restart, an idle stop or a reboot signs the pane out, and the next `dt web open` pairs it again. A session is
4
+ // STATIC — the same sid for its whole life, never rotated — and ends after 7 idle days; each use renews `seen`.
5
+ import crypto from 'node:crypto';
6
+
7
+ export const SESSION_IDLE_MS = 7 * 86_400_000;
8
+ const hash = (sid) => crypto.createHash('sha256').update(sid).digest('hex');
9
+
10
+ /** @param {{ now?: () => number }} [o] */
11
+ export function createSessions({ now = Date.now } = {}) {
12
+ /** @type {Map<string, { created: number, seen: number, cap: string | null }>} keyed by the sid's SHA-256 */
13
+ const live = new Map();
14
+ const prune = (t) => { for (const [h, v] of live) if (!(t - v.seen < SESSION_IDLE_MS)) live.delete(h); };
15
+ return {
16
+ mint(cap = null) {
17
+ const t = now();
18
+ prune(t);
19
+ const sid = crypto.randomBytes(32).toString('base64url');
20
+ live.set(hash(sid), { created: t, seen: t, cap: cap ?? null });
21
+ return sid;
22
+ },
23
+ check(sid) {
24
+ if (typeof sid !== 'string' || !sid) return null;
25
+ const h = hash(sid);
26
+ const entry = live.get(h);
27
+ if (!entry) return null;
28
+ const t = now();
29
+ if (!(t - entry.seen < SESSION_IDLE_MS)) { live.delete(h); return null; }
30
+ entry.seen = t;
31
+ return { cap: entry.cap };
32
+ },
33
+ /** `dt web revoke`: every session ends. → how many did. */
34
+ revokeAll() { const n = live.size; live.clear(); return n; },
35
+ count: () => live.size,
36
+ };
37
+ }
package/src/state.js ADDED
@@ -0,0 +1,143 @@
1
+ // The per-user STATE of dt web (cr-modules-017 §2): what outlives a server — its port, the workspace's level, the
2
+ // hashed client tokens, the pane's place, selection and queue — one file per workspace, named like the runtime file
3
+ // (the realpath hash), plus registry.json, the workspaces `dt web open` has seen. The dir is 0700 and every file 0600,
4
+ // written through a temp file and a rename; nothing here holds a sid, a code or a token in clear. The pane's sessions
5
+ // are NOT here: they live in the server's memory only (sessions.js), so a restart signs the pane out. The runtime
6
+ // dir (runtime.js) keeps only what one process owns: pid, control token, start lock.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { alive, privateDir, workspaceKey, writingInto } from './runtime.js';
11
+
12
+ export { workspaceKey };
13
+
14
+ /** $DT_WEB_STATE_DIR, else ~/Library/Application Support/dreamteamer/web/state on macOS, $XDG_STATE_HOME/dreamteamer/web
15
+ * on Linux (default ~/.local/state/dreamteamer/web), else ~/.local/state/dreamteamer/web. */
16
+ export function stateDir(env = process.env, platform = process.platform, home = os.homedir()) {
17
+ if (env.DT_WEB_STATE_DIR) return env.DT_WEB_STATE_DIR;
18
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'dreamteamer', 'web', 'state');
19
+ if (platform === 'linux' && env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, 'dreamteamer', 'web');
20
+ return path.join(home, '.local', 'state', 'dreamteamer', 'web');
21
+ }
22
+
23
+ const LEVELS = new Set(['read', 'edit', 'admin']);
24
+ const PANE = { view: null, refs: [], place: null, at: null, queue: [], connectedAt: null, movedTo: null };
25
+ export const DEFAULT_STATE = Object.freeze({
26
+ port: null, level: 'edit', raisedAt: null, hookSeenAt: null, clientTokens: Object.freeze([]),
27
+ pane: Object.freeze({ ...PANE, refs: Object.freeze([]), queue: Object.freeze([]) }),
28
+ });
29
+ const defaults = () => ({ ...DEFAULT_STATE, clientTokens: [], pane: { ...PANE, refs: [], queue: [] } });
30
+ const HEX64 = /^[0-9a-f]{64}$/;
31
+ const isPlain = (o) => o !== null && typeof o === 'object' && !Array.isArray(o);
32
+ const validPort = (p) => Number.isInteger(p) && p > 0 && p < 65536;
33
+
34
+ // the state dir is this user's (runtime.js privateDir): tightened to 0700 when others can read it, never widened when
35
+ // the person made it read-only — a write there fails naming the dir, chmod 700 and DT_WEB_STATE_DIR
36
+ const ensureDir = (dir) => privateDir(dir, 'DT_WEB_STATE_DIR');
37
+ function writeFileAtomic(file, text) {
38
+ const dir = path.dirname(file);
39
+ ensureDir(dir);
40
+ const tmp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
41
+ writingInto(dir, 'DT_WEB_STATE_DIR', () => {
42
+ fs.writeFileSync(tmp, text, { mode: 0o600 });
43
+ fs.renameSync(tmp, file);
44
+ });
45
+ }
46
+
47
+ export const workspaceStateFile = (root, dir = stateDir()) => path.join(dir, `${workspaceKey(root)}.json`);
48
+
49
+ /** The workspace's state; the defaults when absent or unreadable. A file that parses keeps each part that has the
50
+ * right shape (a port, a level, …); a half-written one reads as the defaults, and the next write replaces it. */
51
+ export function readWorkspaceState(root, dir = stateDir()) {
52
+ const out = defaults();
53
+ let text;
54
+ try { text = fs.readFileSync(workspaceStateFile(root, dir), 'utf8'); } catch { return out; }
55
+ let raw;
56
+ try { raw = JSON.parse(text); } catch {
57
+ const port = /^\s*\{\s*"port"\s*:\s*(\d{1,5})\s*[,}]/.exec(text);
58
+ if (port && validPort(Number(port[1]))) out.port = Number(port[1]);
59
+ return out;
60
+ }
61
+ if (!isPlain(raw)) return out;
62
+ if (validPort(raw.port)) out.port = raw.port;
63
+ if (LEVELS.has(raw.level)) out.level = raw.level;
64
+ for (const k of ['raisedAt', 'hookSeenAt']) if (typeof raw[k] === 'string') out[k] = raw[k];
65
+ // a `sessions` key (an earlier candidate's) is never read, and the next write drops it
66
+ if (Array.isArray(raw.clientTokens)) out.clientTokens = raw.clientTokens.filter((t) => isPlain(t) && HEX64.test(String(t.sha256)) && LEVELS.has(t.level));
67
+ if (isPlain(raw.pane)) {
68
+ const p = raw.pane;
69
+ if (isPlain(p.view)) out.pane.view = p.view;
70
+ if (Array.isArray(p.refs)) out.pane.refs = p.refs.filter((r) => typeof r === 'string');
71
+ for (const k of ['place', 'at', 'connectedAt']) if (typeof p[k] === 'string') out.pane[k] = p[k];
72
+ if (Array.isArray(p.queue)) out.pane.queue = p.queue.filter(isPlain);
73
+ if (isPlain(p.movedTo) || typeof p.movedTo === 'string') out.pane.movedTo = p.movedTo;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Written whole, atomically: dir 0700, file 0600. Refuses a state that carries sessions: they live in memory only. */
79
+ export function writeWorkspaceState(root, state, dir = stateDir()) {
80
+ if (state && Object.hasOwn(state, 'sessions')) throw new Error('the pane\'s sessions live in the server\'s memory only, never in the state file');
81
+ writeFileAtomic(workspaceStateFile(root, dir), JSON.stringify(state, null, '\t') + '\n');
82
+ }
83
+
84
+ const registryFile = (dir) => path.join(dir, 'registry.json');
85
+
86
+ /** The registered workspaces, `[{ id, root, name, lastOpened }]`; `[]` when absent or unreadable. */
87
+ export function readRegistry(dir = stateDir()) {
88
+ try {
89
+ const raw = JSON.parse(fs.readFileSync(registryFile(dir), 'utf8'));
90
+ return Array.isArray(raw) ? raw.filter((e) => isPlain(e) && typeof e.id === 'string' && typeof e.root === 'string') : [];
91
+ } catch { return []; }
92
+ }
93
+
94
+ export function lookup(id, dir = stateDir()) {
95
+ return readRegistry(dir).find((e) => e.id === id) ?? null;
96
+ }
97
+
98
+ /** A registered workspace's name as its root holds it NOW: the folder may hold another workspace since `dt web open`
99
+ * registered it (same path, so the same id). The registered name when the root or its package.json is gone. */
100
+ export function nameNow(entry) {
101
+ try {
102
+ const name = JSON.parse(fs.readFileSync(path.join(entry.root, 'package.json'), 'utf8')).name;
103
+ if (typeof name === 'string' && name) return name;
104
+ } catch { /* gone, or not JSON */ }
105
+ return entry.name;
106
+ }
107
+
108
+ const LOCK_TRIES = 20;
109
+ const LOCK_WAIT_MS = 50;
110
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
111
+
112
+ /** Upsert a workspace by id under registry.json.lock (exclusive create, 20 tries × 50 ms). A lock whose
113
+ * process is gone is taken over; a live one past the last try is an error naming the registry. */
114
+ export async function register({ root, name }, dir = stateDir(), now = Date.now) {
115
+ ensureDir(dir);
116
+ const lock = `${registryFile(dir)}.lock`;
117
+ for (let attempt = 0; attempt < LOCK_TRIES; attempt++) {
118
+ try {
119
+ writingInto(dir, 'DT_WEB_STATE_DIR', () => fs.writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 }));
120
+ } catch (e) {
121
+ if (e.code !== 'EEXIST') throw e;
122
+ let holder = null;
123
+ try { holder = Number(fs.readFileSync(lock, 'utf8')); } catch { /* released meanwhile */ }
124
+ if (holder !== null && Number.isInteger(holder) && holder > 0 && !alive(holder)) {
125
+ try { if (Number(fs.readFileSync(lock, 'utf8')) === holder) fs.rmSync(lock, { force: true }); } catch { /* gone */ }
126
+ continue;
127
+ }
128
+ await sleep(LOCK_WAIT_MS);
129
+ continue;
130
+ }
131
+ try {
132
+ const real = fs.realpathSync(root);
133
+ const entry = { id: workspaceKey(real), root: real, name: String(name ?? path.basename(real)), lastOpened: new Date(now()).toISOString() };
134
+ const list = readRegistry(dir).filter((e) => e.id !== entry.id);
135
+ list.push(entry);
136
+ writeFileAtomic(registryFile(dir), JSON.stringify(list, null, '\t') + '\n');
137
+ return entry;
138
+ } finally {
139
+ fs.rmSync(lock, { force: true });
140
+ }
141
+ }
142
+ throw new Error(`the workspace registry (${registryFile(dir)}) is locked by another dt web — try again`);
143
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,24 @@
1
+ // Client tokens (cr-modules-017 §3): a bearer a script holds for /api/ at its own level — read by default, edit when
2
+ // asked, never admin. The state keeps the SHA-256 and the name; the token itself is printed once and never stored.
3
+ import crypto from 'node:crypto';
4
+
5
+ const hash = (s) => crypto.createHash('sha256').update(s).digest();
6
+
7
+ export function mintClientToken({ name, level = 'read', now = Date.now } = {}) {
8
+ if (typeof name !== 'string' || !name.trim()) throw new Error('a client token needs a name');
9
+ if (level !== 'read' && level !== 'edit') throw new Error(`a client token is read or edit, not ${level}`);
10
+ const token = `dtw_${crypto.randomBytes(32).toString('base64url')}`;
11
+ return { token, record: { name: name.trim(), level, sha256: hash(token).toString('hex'), created: new Date(now()).toISOString() } };
12
+ }
13
+
14
+ /** The record whose hash the bearer has, compared in constant time; null for anything else. */
15
+ export function matchClientToken(records, bearer) {
16
+ if (typeof bearer !== 'string' || !bearer) return null;
17
+ const h = hash(bearer);
18
+ let found = null;
19
+ for (const r of records ?? []) {
20
+ const want = /^[0-9a-f]{64}$/.test(String(r?.sha256)) ? Buffer.from(r.sha256, 'hex') : null;
21
+ if (want && crypto.timingSafeEqual(want, h) && !found) found = r;
22
+ }
23
+ return found;
24
+ }