@volter/teams 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.mjs CHANGED
@@ -53,53 +53,7 @@ export async function openRemoteMachine({ origin, teamId, machineId, token }, {
53
53
  }
54
54
 
55
55
  /** Request/reply over a stream that carries JSON lines with `id`. */
56
- export class StreamRpc {
57
- constructor(stream) {
58
- this.stream = stream;
59
- this.next = 1;
60
- this.pending = new Map();
61
- this.buffer = '';
62
- stream.on('data', (chunk) => {
63
- this.buffer += chunk;
64
- let index;
65
- while ((index = this.buffer.indexOf('\n')) >= 0) {
66
- const line = this.buffer.slice(0, index);
67
- this.buffer = this.buffer.slice(index + 1);
68
- if (!line) continue;
69
- const message = JSON.parse(line);
70
- const waiter = this.pending.get(message.id);
71
- if (!waiter) continue;
72
- this.pending.delete(message.id);
73
- if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? String(message.error)), { code: message.error.code }));
74
- else waiter.resolve(message.result);
75
- }
76
- });
77
- stream.on('close', (reason) => {
78
- for (const waiter of this.pending.values()) waiter.reject(new Error(`stream closed: ${reason ?? 'peer'}`));
79
- this.pending.clear();
80
- });
81
- }
82
- /**
83
- * One request. Bounded: a door that never answers becomes an error naming the
84
- * method (default 60 s; the node's own per-door bound is 30 s), never a hang.
85
- */
86
- call(method, params = {}, { timeoutMs = 60_000 } = {}) {
87
- const id = this.next++;
88
- this.stream.write(JSON.stringify({ id, method, params }) + '\n');
89
- return new Promise((resolve, reject) => {
90
- const timer = setTimeout(() => {
91
- if (!this.pending.has(id)) return;
92
- this.pending.delete(id);
93
- reject(Object.assign(new Error(`${method} did not answer within ${timeoutMs} ms`), { code: 504 }));
94
- }, timeoutMs);
95
- timer.unref?.();
96
- this.pending.set(id, {
97
- resolve: (value) => { clearTimeout(timer); resolve(value); },
98
- reject: (error) => { clearTimeout(timer); reject(error); },
99
- });
100
- });
101
- }
102
- }
56
+ export { StreamRpc } from './stream-rpc.mjs';
103
57
 
104
58
  /**
105
59
  * A harness's own execution server on the channel's machine, as the local address its client takes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
6
  "license": "MIT",
@@ -22,11 +22,15 @@
22
22
  "./server/events": "./server/events.mjs",
23
23
  "./server/wire": "./server/wire.mjs",
24
24
  "./server/migrations": "./server/migrations/index.mjs",
25
- "./client": "./client.mjs"
25
+ "./client": "./client.mjs",
26
+ "./stream-rpc": "./stream-rpc.mjs",
27
+ "./server/delegated": "./server/delegated.mjs",
28
+ "./server/viewer": "./server/viewer.mjs"
26
29
  },
27
30
  "files": [
28
31
  "*.mjs",
29
32
  "server",
33
+ "viewer",
30
34
  "README.md"
31
35
  ],
32
36
  "engines": {
package/server/core.mjs CHANGED
@@ -13,15 +13,18 @@ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).
13
13
  const productionClock={nowMs:()=>Date.now()};
14
14
  export { productionIds, productionClock };
15
15
 
16
- export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
16
+ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,delegatedIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
17
17
  // An identity service people may sign in with (protocol P02 federation); its tokens name this server's public origin.
18
18
  identityIssuer=normalizePublicOrigin(identityIssuer);
19
19
  if(identityIssuer&&!publicOrigin)throw new TypeError('an identity issuer needs the public origin its tokens name');
20
+ // A workplace whose delegated tokens admit a caller to one pane or folder (server/delegated.mjs); they name this origin too.
21
+ delegatedIssuer=normalizePublicOrigin(delegatedIssuer);
22
+ if(delegatedIssuer&&!publicOrigin)throw new TypeError('a delegated issuer needs the public origin its tokens name');
20
23
  if(typeof clock?.nowMs!=='function'||typeof ids?.next!=='function')throw new TypeError('clock.nowMs and ids.next are required');
21
24
  if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
22
25
  let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
23
26
  const events=new TeamEvents();
24
- return {clock,ids,events,publicOrigin,identityIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
27
+ return {clock,ids,events,publicOrigin,identityIssuer,delegatedIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
25
28
  get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
26
29
  attach(next){store=next.store??store;if(next.serverId!==undefined)serverId=next.serverId;if(next.localOrigin!==undefined)localOrigin=next.localOrigin;if(next.harnessRoutes!==undefined)harnessRoutes=next.harnessRoutes;},
27
30
  };
@@ -0,0 +1,80 @@
1
+ // Delegated access (RH2 RFC 0013, panes and folders): a workplace that decides who may see or drive a pane or a
2
+ // folder signs a short-lived token saying so, and this server admits exactly that, as it admits a machine grant.
3
+ // The workplace is an issuer this server is configured to trust (`delegatedIssuer`, its origin); its keys are at
4
+ // `<issuer>/.well-known/jwks.json` (Ed25519, EdDSA). A token names the principal, the Surface (its kind, and in its
5
+ // config this server's team, the machine and the target) and `view` or `control`; its audience is this server's
6
+ // public origin. Verified with WebCrypto, so the same code runs in Node and in a Worker.
7
+ //
8
+ // What a Surface grants on the machine, in the daemon's own caps (sdk/volter-teams/admission.mjs):
9
+ // pane view: observe the pane control: observe it and type into it
10
+ // folder view: list and read under the root control: every file door under the root
11
+ const CAPS = {
12
+ folder: { control: ['files'], view: ['observe'] },
13
+ pane: { control: ['observe', 'control'], view: ['observe'] },
14
+ };
15
+ const KEYS_TTL_MS = 5 * 60 * 1000;
16
+ const SKEW_S = 30;
17
+
18
+ const decoder = new TextDecoder();
19
+ const fromBase64Url = (text) => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - text.length % 4) % 4)), (c) => c.charCodeAt(0));
20
+ const json = (part) => JSON.parse(decoder.decode(fromBase64Url(part)));
21
+
22
+ /** A token shaped like a JWT (three base64url parts) — a Teams credential never is. */
23
+ export function looksDelegated(token) {
24
+ return typeof token === 'string' && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
25
+ }
26
+
27
+ async function issuerKey(api, kid) {
28
+ const cache = api.delegatedKeys ??= { at: -Infinity, keys: new Map() };
29
+ const age = api.clock.nowMs() - cache.at;
30
+ // The set is re-read when it is old, or when it lacks the kid and was last read over a minute ago (a key rotated
31
+ // in); so a forged kid drives at most one fetch a minute.
32
+ if (age >= KEYS_TTL_MS || (!cache.keys.has(kid) && age > 60 * 1000)) {
33
+ const response = await fetch(`${api.delegatedIssuer}/.well-known/jwks.json`, { headers: { accept: 'application/json' } });
34
+ if (!response.ok) throw new Error(`the issuer's keys did not load (${response.status})`);
35
+ const { keys } = await response.json();
36
+ const loaded = new Map();
37
+ for (const jwk of Array.isArray(keys) ? keys : []) {
38
+ if (jwk?.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.kid !== 'string' || typeof jwk.x !== 'string') continue;
39
+ loaded.set(jwk.kid, await crypto.subtle.importKey('jwk', { crv: 'Ed25519', kty: 'OKP', x: jwk.x }, { name: 'Ed25519' }, false, ['verify']));
40
+ }
41
+ cache.keys = loaded;
42
+ cache.at = api.clock.nowMs();
43
+ }
44
+ return cache.keys.get(kid) ?? null;
45
+ }
46
+
47
+ /**
48
+ * What a delegated token admits, or null when it is not one this server trusts: { subject, issuer, teamId, machineId,
49
+ * target, caps, expiresAt, scope, kind }. Anything unverifiable, expired, for another audience or naming no machine
50
+ * target is null, and the caller refuses it exactly as an unknown credential.
51
+ */
52
+ export async function delegatedAccessOf(api, token) {
53
+ if (!api.delegatedIssuer || !api.publicOrigin || !looksDelegated(token)) return null;
54
+ try {
55
+ const [head, body, signature] = token.split('.');
56
+ const header = json(head);
57
+ if (header.alg !== 'EdDSA' || typeof header.kid !== 'string') return null;
58
+ const key = await issuerKey(api, header.kid);
59
+ if (!key) return null;
60
+ const signed = new TextEncoder().encode(`${head}.${body}`);
61
+ if (!await crypto.subtle.verify({ name: 'Ed25519' }, key, fromBase64Url(signature), signed)) return null;
62
+ const claims = json(body);
63
+ const now = Math.floor(api.clock.nowMs() / 1000);
64
+ if (claims.iss !== api.delegatedIssuer || claims.aud !== api.publicOrigin) return null;
65
+ if (typeof claims.exp !== 'number' || claims.exp <= now - SKEW_S || (typeof claims.nbf === 'number' && claims.nbf > now + SKEW_S)) return null;
66
+ if (typeof claims.sub !== 'string' || !claims.sub) return null;
67
+ const surface = claims.surface;
68
+ const caps = CAPS[surface?.kind]?.[claims.scope];
69
+ const config = surface?.config ?? {};
70
+ if (!caps || typeof config.team !== 'string' || typeof config.machine !== 'string' || typeof config.target !== 'string') return null;
71
+ const target = surface.kind === 'pane' ? (config.target.startsWith('pane:') ? config.target : null) : (config.target.startsWith('root:/') ? config.target : null);
72
+ if (!target) return null;
73
+ return { caps, expiresAt: claims.exp * 1000, issuer: claims.iss, kind: surface.kind, machineId: config.machine, scope: claims.scope, subject: claims.sub, target, teamId: config.team };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** The delegated caller's principal on this server: the issuer's subject, never a member of any team. */
80
+ export const delegatedPrincipal = (access) => `delegated:${new URL(access.issuer).host}:${access.subject}`;
package/server/http.mjs CHANGED
@@ -4,6 +4,7 @@ import { ContractError, canonicalJson, validationFailure, cursorHash, cursorToke
4
4
  import { machineGrantRoute, machineGrantRows, closeMachineChannels, closeChannelsFor } from './machine-link.mjs';
5
5
  import { exact, parseJson as parseRequestJson } from './request-json.mjs';
6
6
  import { identityOf } from './identity.mjs';
7
+ import { serveViewer } from './viewer.mjs';
7
8
 
8
9
  class HttpError extends Error { constructor(status,code,message,details=null){super(message);this.status=status;this.code=code;this.details=details;} }
9
10
  const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
@@ -24,6 +25,8 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
24
25
  if(req.method==='OPTIONS'){res.writeHead(204,{'Access-Control-Allow-Methods':'GET, POST, PATCH, DELETE','Access-Control-Allow-Headers':'Authorization, Content-Type, Idempotency-Key, If-Match, Last-Event-ID, Range','Access-Control-Max-Age':'86400'});res.end();return;}
25
26
  if(api.publicOrigin){const expected=new URL(api.publicOrigin);if(req.headers.host!==expected.host)fail(400,'invalid_request','request Host does not match configured public origin');}
26
27
  const url=new URL(req.url,'http://local'); const path=url.pathname;
28
+ // The hosted viewer's files (viewer.mjs): public; the page's delegated token is checked at every door it opens.
29
+ if(req.method==='GET'&&(path==='/view'||path.startsWith('/view/'))&&await serveViewer(res,path))return;
27
30
  if(api.harnessRoutes&&await api.harnessRoutes.preAuth({api,req,res,path,requestId}))return;
28
31
  const auth=await authenticate(api,bearer(req.headers.authorization));res._authenticated=Boolean(auth);
29
32
  if(api.harnessRoutes&&await api.harnessRoutes.authed({api,req,res,auth,path,requestId}))return;
@@ -10,8 +10,12 @@
10
10
  // that moment; the machine re-derives the admission from its own synced copy
11
11
  // of the grants and checks each door against it. A caller with no grant on a
12
12
  // machine is refused exactly as a machine that does not exist is.
13
+ //
14
+ // A delegated caller (./delegated.mjs) holds no Teams credential: its verified token becomes one machine grant on
15
+ // exactly the target and caps it names, expiring with the token, and its channel closes then.
13
16
  import { Channel } from '../channel.mjs';
14
17
  import { bearer } from './auth.mjs';
18
+ import { delegatedAccessOf, delegatedPrincipal, looksDelegated } from './delegated.mjs';
15
19
 
16
20
  const LINK_PROTOCOL = 'supercode-machine-link.v1';
17
21
  const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
@@ -37,6 +41,10 @@ export async function machineGrantRows(api, teamId, machineId) {
37
41
 
38
42
  /** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
39
43
  async function claimFor(api, auth, machineId) {
44
+ if (auth.kind === 'delegated') {
45
+ const row = await api.store.one('SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND id=? AND expires_at>?', [auth.team_id, machineId, auth.grant_id, api.clock.nowMs()]);
46
+ return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision } } : null;
47
+ }
40
48
  const machine = await api.store.one('SELECT custodian_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
41
49
  if (!machine) return null;
42
50
  const membership = await api.store.one('SELECT active FROM memberships WHERE team_id=? AND principal_id=?', [auth.team_id, auth.principal_id]);
@@ -133,7 +141,9 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
133
141
  const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(new URL(url, 'http://local').pathname);
134
142
  if (!match) return { status: 404, code: 'not_found', message: 'not found' };
135
143
  const [, teamId, machineId, door] = match;
136
- const auth = await authenticate(api, bearerFrom(headers));
144
+ const presented = bearerFrom(headers);
145
+ if (door === 'channel' && api.delegatedIssuer && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
146
+ const auth = await authenticate(api, presented);
137
147
  if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
138
148
  if (door === 'link') {
139
149
  if (auth.kind !== 'machine' || auth.team_id !== teamId || auth.machine_id !== machineId) return { status: 403, code: 'forbidden', message: 'exact machine credential required' };
@@ -151,6 +161,22 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
151
161
  return { door, teamId, machineId, auth };
152
162
  }
153
163
 
164
+ /** A delegated token's channel: its grant, minted now on exactly what the token names, or the same refusal as no machine. */
165
+ async function delegatedChannel(api, teamId, machineId, token) {
166
+ const access = await delegatedAccessOf(api, token);
167
+ if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
168
+ if (access.teamId !== teamId || access.machineId !== machineId) return { status: 404, code: 'not_found', message: 'machine not found' };
169
+ const machine = await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
170
+ if (!machine) return { status: 404, code: 'not_found', message: 'machine not found' };
171
+ const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
172
+ await api.store.transaction([
173
+ // Delegated grants live as long as their tokens; the expired ones go as new ones come.
174
+ { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
175
+ { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [teamId, id, machineId, principal, JSON.stringify(access.caps), JSON.stringify([access.target]), `delegated:${new URL(access.issuer).host}`, now, access.expiresAt] },
176
+ ]);
177
+ return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, expires_at: access.expiresAt } };
178
+ }
179
+
154
180
  /**
155
181
  * A caller's bearer: the Authorization header, or, for a browser (which cannot set headers on
156
182
  * a WebSocket), a `bearer.<token>` entry in Sec-WebSocket-Protocol beside the door's protocol.
@@ -203,8 +229,10 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
203
229
  dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
204
230
  };
205
231
  channels(api).add(entry);
232
+ // A delegated channel ends when its token does; the viewer reopens it with a fresh one.
233
+ const expiry = auth.kind === 'delegated' ? setTimeout(() => entry.close('token_expired'), Math.max(0, auth.expires_at - api.clock.nowMs())) : null;
206
234
  channel.on('error', () => {});
207
- channel.on('close', () => { channels(api).delete(entry); entry.dropStreams('caller closed'); });
235
+ channel.on('close', () => { if (expiry) clearTimeout(expiry); channels(api).delete(entry); entry.dropStreams('caller closed'); });
208
236
  channel.on('stream', async (stream, accept, refuse) => {
209
237
  const claim = await claimFor(api, auth, machineId).catch(() => null);
210
238
  if (!claim) return refuse('no machine grant for this caller');
@@ -0,0 +1,37 @@
1
+ // The hosted viewer's files (../viewer): the page, its script, and the two browser modules it imports, served at
2
+ // /view without a credential — the page holds its delegated token in memory and every door it opens checks it. The
3
+ // Node host reads them from this package; a Worker, which has no files, bundles them as text and sets them here
4
+ // (worker/index.mjs), as it does the OTLP schemas.
5
+ const FILES = {
6
+ '/view': ['viewer/index.html', 'text/html; charset=utf-8'],
7
+ '/view/': ['viewer/index.html', 'text/html; charset=utf-8'],
8
+ '/view/viewer.mjs': ['viewer/viewer.mjs', 'text/javascript; charset=utf-8'],
9
+ '/view/channel.mjs': ['browser-channel.mjs', 'text/javascript; charset=utf-8'],
10
+ '/view/stream-rpc.mjs': ['stream-rpc.mjs', 'text/javascript; charset=utf-8'],
11
+ };
12
+ let bundled = null;
13
+
14
+ /** The Worker's copy of the files, keyed by their path in this package. */
15
+ export function setViewerSources(sources) { bundled = sources; }
16
+
17
+ async function source(file) {
18
+ if (bundled) return bundled[file] ?? null;
19
+ const { readFile } = await import('node:fs/promises');
20
+ return readFile(new URL(`../${file}`, import.meta.url), 'utf8');
21
+ }
22
+
23
+ /** Answer a viewer path, or return false for any other. */
24
+ export async function serveViewer(res, path) {
25
+ const entry = FILES[path];
26
+ if (!entry) return false;
27
+ const body = await source(entry[0]);
28
+ if (body === null) return false;
29
+ res.writeHead(200, {
30
+ 'Content-Type': entry[1],
31
+ 'Cache-Control': 'no-cache',
32
+ 'Referrer-Policy': 'no-referrer',
33
+ 'X-Content-Type-Options': 'nosniff',
34
+ });
35
+ res.end(body);
36
+ return true;
37
+ }
package/stream-rpc.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // Request and reply over one stream: a line of JSON { id, method, params } out, a line { id, result | error } back.
2
+ // No Node built-ins, so a browser page (the hosted viewer) uses it on a browser channel's stream as the CLI does on
3
+ // a Node one.
4
+ export class StreamRpc {
5
+ constructor(stream) {
6
+ this.stream = stream;
7
+ this.next = 1;
8
+ this.pending = new Map();
9
+ this.buffer = '';
10
+ stream.on('data', (chunk) => {
11
+ this.buffer += chunk;
12
+ let index;
13
+ while ((index = this.buffer.indexOf('\n')) >= 0) {
14
+ const line = this.buffer.slice(0, index);
15
+ this.buffer = this.buffer.slice(index + 1);
16
+ if (!line) continue;
17
+ const message = JSON.parse(line);
18
+ const waiter = this.pending.get(message.id);
19
+ if (!waiter) continue;
20
+ this.pending.delete(message.id);
21
+ if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? String(message.error)), { code: message.error.code }));
22
+ else waiter.resolve(message.result);
23
+ }
24
+ });
25
+ stream.on('close', (reason) => {
26
+ for (const waiter of this.pending.values()) waiter.reject(new Error(`stream closed: ${reason ?? 'peer'}`));
27
+ this.pending.clear();
28
+ });
29
+ }
30
+ /**
31
+ * One request. Bounded: a door that never answers becomes an error naming the
32
+ * method (default 60 s; the node's own per-door bound is 30 s), never a hang.
33
+ */
34
+ call(method, params = {}, { timeoutMs = 60_000 } = {}) {
35
+ const id = this.next++;
36
+ this.stream.write(JSON.stringify({ id, method, params }) + '\n');
37
+ return new Promise((resolve, reject) => {
38
+ const timer = setTimeout(() => {
39
+ if (!this.pending.has(id)) return;
40
+ this.pending.delete(id);
41
+ reject(Object.assign(new Error(`${method} did not answer within ${timeoutMs} ms`), { code: 504 }));
42
+ }, timeoutMs);
43
+ timer.unref?.();
44
+ this.pending.set(id, {
45
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
46
+ reject: (error) => { clearTimeout(timer); reject(error); },
47
+ });
48
+ });
49
+ }
50
+ }
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>Viewer</title>
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
9
+ <style>
10
+ :root { color-scheme: dark; --bg: #0b0d10; --panel: #13161b; --line: #262b33; --text: #e6e8eb; --muted: #8b939e; --accent: #7aa2f7; }
11
+ * { box-sizing: border-box; }
12
+ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, sans-serif; }
13
+ body { display: flex; flex-direction: column; }
14
+ header { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); background: var(--panel); min-height: 32px; }
15
+ header .target { font-family: ui-monospace, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
16
+ header .scope { margin-left: auto; font-size: 11px; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; color: var(--muted); }
17
+ #status { padding: 4px 10px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--line); }
18
+ #status:empty { display: none; }
19
+ main { flex: 1; min-height: 0; display: flex; }
20
+ #terminal { flex: 1; min-width: 0; padding: 4px; }
21
+ #folder { flex: 1; min-width: 0; display: none; }
22
+ #folder.open { display: flex; }
23
+ #entries { width: 280px; flex: none; border-right: 1px solid var(--line); overflow: auto; }
24
+ #entries .crumbs { padding: 6px 10px; border-bottom: 1px solid var(--line); font-family: ui-monospace, Menlo, monospace; color: var(--muted); word-break: break-all; }
25
+ #entries button.entry { display: block; width: 100%; text-align: left; padding: 4px 10px; border: 0; background: none; color: var(--text); font: inherit; cursor: pointer; }
26
+ #entries button.entry:hover, #entries button.entry.selected { background: #1b2028; }
27
+ #entries button.entry.directory { color: var(--accent); }
28
+ #file { flex: 1; min-width: 0; display: flex; flex-direction: column; }
29
+ #file .bar { display: flex; gap: 8px; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--line); min-height: 36px; }
30
+ #file .bar .name { font-family: ui-monospace, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
31
+ #file .bar .actions { margin-left: auto; display: flex; gap: 6px; }
32
+ #file textarea { flex: 1; resize: none; border: 0; outline: none; padding: 10px; background: var(--bg); color: var(--text); font: 12px/1.5 ui-monospace, Menlo, monospace; }
33
+ button.action, label.action { font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 4px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; }
34
+ button.action:disabled { opacity: .5; cursor: default; }
35
+ label.action input { display: none; }
36
+ [hidden] { display: none !important; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <header><span class="target" id="target"></span><span class="scope" id="scope"></span></header>
41
+ <div id="status"></div>
42
+ <main>
43
+ <div id="terminal" hidden></div>
44
+ <div id="folder">
45
+ <nav id="entries"><div class="crumbs" id="crumbs"></div><div id="list"></div></nav>
46
+ <section id="file">
47
+ <div class="bar"><span class="name" id="file-name"></span>
48
+ <span class="actions"><label class="action" id="upload" hidden>Upload<input type="file" id="upload-input" multiple></label><button class="action" id="save" hidden disabled>Save</button></span>
49
+ </div>
50
+ <textarea id="content" spellcheck="false" readonly></textarea>
51
+ </section>
52
+ </div>
53
+ </main>
54
+ <script type="module" src="/view/viewer.mjs"></script>
55
+ </body>
56
+ </html>
@@ -0,0 +1,171 @@
1
+ // THE HOSTED VIEWER: one pane or one folder of an enrolled machine, opened with a delegated token a workplace signed
2
+ // (server/delegated.mjs). The token arrives in the URL fragment (never sent to a server as a query) or by
3
+ // postMessage from the page embedding this one, which sends a fresh one before the last expires; it is held in memory
4
+ // only. The server verifies it and admits exactly its target and scope; what this page reads from it is only for
5
+ // showing where it is.
6
+ import { attachBrowserPane, openBrowserMachine } from './channel.mjs';
7
+ import { StreamRpc } from './stream-rpc.mjs';
8
+
9
+ const $ = (id) => document.getElementById(id);
10
+ const decoder = new TextDecoder();
11
+ const status = (text) => { $('status').textContent = text; };
12
+
13
+ let token = new URLSearchParams(location.hash.slice(1)).get('token');
14
+ history.replaceState(null, '', location.pathname);
15
+ let waiting = null;
16
+
17
+ const embedded = window.parent !== window;
18
+ const ask = (type) => { if (embedded) window.parent.postMessage({ type }, '*'); };
19
+ window.addEventListener('message', (event) => {
20
+ if (event.data?.type !== 'delegated-token' || typeof event.data.token !== 'string') return;
21
+ token = event.data.token;
22
+ if (waiting) { const resume = waiting; waiting = null; resume(); }
23
+ });
24
+
25
+ function claimsOf(value) {
26
+ const part = value.split('.')[1] ?? '';
27
+ return JSON.parse(decoder.decode(Uint8Array.from(atob(part.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - part.length % 4) % 4)), (c) => c.charCodeAt(0))));
28
+ }
29
+
30
+ /** The next token: the one held if it has a minute left, otherwise one the embedding page sends when asked. */
31
+ async function freshToken() {
32
+ const left = token ? claimsOf(token).exp * 1000 - Date.now() : 0;
33
+ if (left > 60_000) return token;
34
+ if (!embedded) throw new Error('this link has expired; open it again from the workplace');
35
+ status('Waiting for access…');
36
+ await new Promise((resolve) => { waiting = resolve; ask('delegated-token-needed'); });
37
+ return token;
38
+ }
39
+
40
+ async function main() {
41
+ if (!token) { status('No access token. Open this from the workplace.'); return; }
42
+ const claims = claimsOf(token);
43
+ const surface = claims.surface ?? {};
44
+ const config = surface.config ?? {};
45
+ const control = claims.scope === 'control';
46
+ $('target').textContent = `${surface.key ?? ''} · ${config.target ?? ''}`;
47
+ $('scope').textContent = control ? 'control' : 'view only';
48
+ document.title = `${surface.key ?? 'Viewer'} · ${surface.kind ?? ''}`;
49
+ const connect = async () => openBrowserMachine({ origin: location.origin, teamId: config.team, machineId: config.machine, token: await freshToken() });
50
+ if (surface.kind === 'pane') await showPane(connect, String(config.target).slice('pane:'.length), control);
51
+ else if (surface.kind === 'folder') await showFolder(connect, String(config.target).slice('root:'.length), control);
52
+ else status(`This viewer does not show ${surface.kind ?? 'that'}.`);
53
+ ask('viewer-ready');
54
+ }
55
+
56
+ async function showPane(connect, key, control) {
57
+ const [{ Terminal }, { FitAddon }] = await Promise.all([
58
+ import('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/+esm'),
59
+ import('https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/+esm'),
60
+ ]);
61
+ const element = $('terminal');
62
+ element.hidden = false;
63
+ const terminal = new Terminal({ cursorBlink: control, disableStdin: !control, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 13, scrollback: 5000, theme: { background: '#0b0d10' } });
64
+ const fit = new FitAddon();
65
+ terminal.loadAddon(fit);
66
+ terminal.open(element);
67
+ fit.fit();
68
+ let pane = null;
69
+ // Only a controller sizes the pane; an observer sees it at the size its controller keeps.
70
+ new ResizeObserver(() => { fit.fit(); if (control) pane?.resize(terminal.cols, terminal.rows); }).observe(element);
71
+ if (control) terminal.onData((data) => pane?.write(data));
72
+ const attach = async () => {
73
+ status('Connecting…');
74
+ const channel = await connect();
75
+ pane = await attachBrowserPane(channel, { pane: key, mode: control ? 'control' : 'observe', cols: terminal.cols, rows: terminal.rows });
76
+ status('');
77
+ pane.on('data', (data) => terminal.write(data));
78
+ pane.on('notice', (message) => status(message));
79
+ pane.on('error-message', (message) => status(message));
80
+ pane.on('close', (reason) => {
81
+ pane = null;
82
+ channel.close();
83
+ // The server ends a channel when its token does: reopen with a fresh one. Any other close is shown and stays.
84
+ if (!/token_expired/.test(String(reason))) { status(`Disconnected: ${reason ?? 'closed'}`); return; }
85
+ status('Reconnecting…');
86
+ attach().catch((error) => status(error.message));
87
+ });
88
+ };
89
+ await attach();
90
+ }
91
+
92
+ async function showFolder(connect, root, control) {
93
+ $('folder').classList.add('open');
94
+ const base = root.replace(/\/+$/, '') || '/';
95
+ let rpc = null;
96
+ let path = base;
97
+ let open = null;
98
+ // One node stream, reopened (with a fresh token) when the server has closed it, as it does when a token expires.
99
+ const call = async (method, params) => {
100
+ for (let attempt = 0; ; attempt += 1) {
101
+ if (!rpc) {
102
+ const channel = await connect();
103
+ rpc = new StreamRpc(await channel.open('node'));
104
+ }
105
+ try {
106
+ return await rpc.call(method, params, { timeoutMs: 30_000 });
107
+ } catch (error) {
108
+ if (!rpc.stream.closed || attempt > 0) throw error;
109
+ rpc = null;
110
+ }
111
+ }
112
+ };
113
+ const join = (dir, name) => `${dir.replace(/\/+$/, '')}/${name}`;
114
+ const list = async (dir) => {
115
+ status('');
116
+ const entries = await call('files.list', { path: dir });
117
+ path = dir;
118
+ $('crumbs').textContent = dir;
119
+ const listing = $('list');
120
+ listing.replaceChildren();
121
+ if (dir !== base) listing.append(entryButton('..', 'directory', () => list(dir.replace(/\/[^/]+\/?$/, '') || base)));
122
+ entries.sort((a, b) => (a.kind === 'directory') === (b.kind === 'directory') ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1);
123
+ for (const entry of entries) {
124
+ const target = join(dir, entry.name);
125
+ listing.append(entryButton(entry.name, entry.kind, () => (entry.kind === 'directory' ? list(target) : read(target))));
126
+ }
127
+ };
128
+ const read = async (file) => {
129
+ const { bytes, read: size } = await call('files.read', { path: file, length: 1024 * 1024 });
130
+ const raw = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
131
+ const text = decoder.decode(raw);
132
+ const binary = /\u0000/.test(text);
133
+ open = file;
134
+ $('file-name').textContent = file;
135
+ $('content').value = binary ? `(${size} bytes, not text)` : text;
136
+ $('content').readOnly = !control || binary;
137
+ $('save').hidden = !control || binary;
138
+ $('save').disabled = true;
139
+ for (const button of document.querySelectorAll('#list button.entry')) button.classList.toggle('selected', button.dataset.path === file);
140
+ };
141
+ const entryButton = (name, kind, onClick) => {
142
+ const button = document.createElement('button');
143
+ button.className = `entry ${kind}`;
144
+ button.textContent = kind === 'directory' ? `${name}/` : name;
145
+ button.dataset.path = join(path, name);
146
+ button.addEventListener('click', () => onClick().catch((error) => status(error.message)));
147
+ return button;
148
+ };
149
+ const toBase64 = (bytes) => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return btoa(text); };
150
+ if (control) {
151
+ $('upload').hidden = false;
152
+ $('content').addEventListener('input', () => { $('save').disabled = false; });
153
+ $('save').addEventListener('click', async () => {
154
+ if (!open) return;
155
+ await call('files.write', { path: open, bytes: toBase64(new TextEncoder().encode($('content').value)), truncate: true }).catch((error) => status(error.message));
156
+ $('save').disabled = true;
157
+ status(`Saved ${open}`);
158
+ });
159
+ $('upload-input').addEventListener('change', async (event) => {
160
+ for (const file of event.target.files) {
161
+ await call('files.write', { path: join(path, file.name), bytes: toBase64(new Uint8Array(await file.arrayBuffer())), truncate: true }).catch((error) => status(error.message));
162
+ }
163
+ event.target.value = '';
164
+ await list(path);
165
+ status('Uploaded');
166
+ });
167
+ }
168
+ await list(base);
169
+ }
170
+
171
+ main().catch((error) => status(error.message));