@volter/teams 0.1.32 → 0.1.34
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/package.json +1 -1
- package/server/http.mjs +4 -2
- package/viewer/viewer.mjs +23 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
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",
|
package/server/http.mjs
CHANGED
|
@@ -257,9 +257,11 @@ async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
|
|
|
257
257
|
async function revokeMachine(api,req,res,auth,machineId,requestId){
|
|
258
258
|
const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine)fail(404,'not_found','not found');if(machine.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');requireRevision(req,machine.revision,'machine');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revision=? AND revoked_at IS NULL AND (custodian_id=? OR ?=1)',params:[auth.team_id,machineId,machine.revision,auth.principal_id,isAdmin(auth)?1:0],code:'revision_mismatch',status:409,message:'machine changed'}],statements=[{sql:'UPDATE machines SET revoked_at=?,revision=revision+1 WHERE team_id=? AND id=?',params:[now,auth.team_id,machineId]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND machine_id=? AND revoked_at IS NULL',params:[now,auth.team_id,machineId]},{sql:"UPDATE sources SET state='revoked',revision=revision+1 WHERE team_id=? AND machine_id=?",params:[auth.team_id,machineId]},{sql:"UPDATE sync_rules SET state='removed',revision=revision+1 WHERE team_id=? AND machine_id=? AND state!='removed'",params:[auth.team_id,machineId]},{sql:"UPDATE attachments SET state='revoked',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:machineId});api.events.publish(auth.team_id,{type:'authorization_changed',machine_id:machineId});closeMachineChannels(api,auth.team_id,machineId,{reason:'machine_revoked'});send(res,204,null,requestId);
|
|
259
259
|
}
|
|
260
|
-
|
|
260
|
+
/** This machine's sessions someone is watching live (an open follow stream), as {harness, native_id}. */
|
|
261
|
+
async function watchedFor(api,teamId,machineId){const ids=[...(api.watchers?.keys()??[])].filter(key=>key.startsWith(`${teamId}:`)).map(key=>key.slice(teamId.length+1)).slice(0,90);if(!ids.length)return [];return (await api.store.query(`SELECT o.harness,o.native_id FROM origins o JOIN sources s ON s.team_id=o.team_id AND s.id=o.source_id WHERE o.team_id=? AND s.machine_id=? AND o.session_id IN (${ids.map(()=>'?').join(',')})`,[teamId,machineId,...ids])).map(({harness,native_id})=>({harness,native_id}));}
|
|
262
|
+
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,{...machineJson(machine,api.clock.nowMs(),api),watched:await watchedFor(api,auth.team_id,machineId)}),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]});// What each synced session's harness is doing now: this heartbeat's report replaces the source's last one.
|
|
261
263
|
if(Array.isArray(b.activity)){const source=await api.store.one("SELECT id FROM sources WHERE team_id=? AND machine_id=? AND kind='connector'",[auth.team_id,machineId]);if(source){statements.push({sql:'DELETE FROM session_activity WHERE team_id=? AND source_id=?',params:[auth.team_id,source.id]});for(const item of b.activity){exact(item,['harness','native_id','turn','since'],'activity');if(typeof item.harness!=='string'||!item.harness||item.harness.length>64||typeof item.native_id!=='string'||!item.native_id||item.native_id.length>256||typeof item.turn!=='string'||!item.turn||item.turn.length>32||(item.since!=null&&!Number.isSafeInteger(item.since)))fail(400,'invalid_request','invalid activity');statements.push({sql:'INSERT OR REPLACE INTO session_activity(team_id,source_id,harness,native_id,turn,since,observed_at) VALUES (?,?,?,?,?,?,?)',params:[auth.team_id,source.id,item.harness,item.native_id,item.turn,item.since??null,now]});}}}
|
|
262
|
-
for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}for(const binding of b.bindings)statements.push({sql:'INSERT INTO bindings(team_id,id,machine_id,session_id,incarnation_id,descriptor_json,observed_at) SELECT ?,?,?,o.session_id,?,?,? FROM origins o WHERE o.team_id=? AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL ON CONFLICT(team_id,id) DO UPDATE SET machine_id=excluded.machine_id,session_id=excluded.session_id,incarnation_id=excluded.incarnation_id,descriptor_json=excluded.descriptor_json,observed_at=excluded.observed_at',params:[auth.team_id,binding.id,machineId,b.incarnation_id,JSON.stringify(binding),now,auth.team_id,auth.source_id,binding.harness,binding.native_session_id]});const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now,api)),guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});send(res,200,response,requestId);}
|
|
264
|
+
for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}for(const binding of b.bindings)statements.push({sql:'INSERT INTO bindings(team_id,id,machine_id,session_id,incarnation_id,descriptor_json,observed_at) SELECT ?,?,?,o.session_id,?,?,? FROM origins o WHERE o.team_id=? AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL ON CONFLICT(team_id,id) DO UPDATE SET machine_id=excluded.machine_id,session_id=excluded.session_id,incarnation_id=excluded.incarnation_id,descriptor_json=excluded.descriptor_json,observed_at=excluded.observed_at',params:[auth.team_id,binding.id,machineId,b.incarnation_id,JSON.stringify(binding),now,auth.team_id,auth.source_id,binding.harness,binding.native_session_id]});const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now,api)),guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});send(res,200,response?.data?{...response,data:{...response.data,watched:await watchedFor(api,auth.team_id,machineId)}}:response,requestId);}
|
|
263
265
|
async function machineConfiguration(api,res,auth,machineId,requestId){
|
|
264
266
|
if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
|
|
265
267
|
const [machine,rules]=await api.store.snapshot([
|
package/viewer/viewer.mjs
CHANGED
|
@@ -17,6 +17,20 @@ let waiting = null;
|
|
|
17
17
|
|
|
18
18
|
const embedded = window.parent !== window;
|
|
19
19
|
const ask = (type) => { if (embedded) window.parent.postMessage({ type }, '*'); };
|
|
20
|
+
// Where a failure is reported: the session this page shows, once it knows it (the server logs it under the token's member).
|
|
21
|
+
let reportTo = null;
|
|
22
|
+
/**
|
|
23
|
+
* A failure this page cannot recover from by itself (a read, the follow, the page's own code): shown as a plain line,
|
|
24
|
+
* posted to the host as `viewer-error` { stage, status, message }, and sent to the server's log.
|
|
25
|
+
*/
|
|
26
|
+
function failed(stage, error, statusCode = null) {
|
|
27
|
+
const message = String(error?.message ?? error ?? 'unknown error').slice(0, 400);
|
|
28
|
+
status(message);
|
|
29
|
+
if (embedded) window.parent.postMessage({ type: 'viewer-error', stage, status: statusCode, message }, '*');
|
|
30
|
+
if (reportTo && token) fetch(reportTo, { method: 'POST', keepalive: true, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ stage, status: statusCode, message }) }).catch(() => {});
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener('error', (event) => failed('page', event.error ?? event.message));
|
|
33
|
+
window.addEventListener('unhandledrejection', (event) => failed('page', event.reason));
|
|
20
34
|
// The host's style, as supercode-ui is themed: its `--volter-*` brand tokens and `--scui-*` component tokens, set on
|
|
21
35
|
// the session's root (every component reads them from an ancestor). Nothing else is taken from the host.
|
|
22
36
|
function applyTheme(properties) {
|
|
@@ -120,8 +134,9 @@ async function showSession(session, control) {
|
|
|
120
134
|
stylesheet.rel = 'stylesheet';
|
|
121
135
|
stylesheet.href = '/view/session-view.css';
|
|
122
136
|
document.head.append(stylesheet);
|
|
123
|
-
const { mountSessionView } = await import('/view/session-view.mjs');
|
|
124
137
|
const base = `${location.origin}/api/v1/teams/${encodeURIComponent(session.team)}/sessions/${encodeURIComponent(session.id)}`;
|
|
138
|
+
reportTo = `${base}/viewer-errors`;
|
|
139
|
+
const { mountSessionView } = await import('/view/session-view.mjs').catch((error) => { throw Object.assign(error, { stage: 'load' }); });
|
|
125
140
|
const element = $('session');
|
|
126
141
|
element.hidden = false;
|
|
127
142
|
const view = mountSessionView(element, {
|
|
@@ -159,7 +174,7 @@ async function showSession(session, control) {
|
|
|
159
174
|
const read = () => (reading ??= (async () => {
|
|
160
175
|
try {
|
|
161
176
|
const response = await fetch(`${base}/messages?tail=300`, { headers: await headers() });
|
|
162
|
-
if (!response.ok) throw new Error(`reading the session failed (${response.status})`);
|
|
177
|
+
if (!response.ok) throw Object.assign(new Error(`reading the session failed (${response.status})`), { stage: 'read', status: response.status });
|
|
163
178
|
const body = await response.json();
|
|
164
179
|
apply(body);
|
|
165
180
|
return body;
|
|
@@ -173,7 +188,7 @@ async function showSession(session, control) {
|
|
|
173
188
|
try {
|
|
174
189
|
if (!cursor) cursor = String((await read()).event_cursor ?? '') || null;
|
|
175
190
|
const response = await fetch(`${base}/events?${cursor ? `after=${encodeURIComponent(cursor)}` : 'tail=1'}`, { headers: await headers() });
|
|
176
|
-
if (!response.ok) throw new Error(`following the session failed (${response.status})`);
|
|
191
|
+
if (!response.ok) throw Object.assign(new Error(`following the session failed (${response.status})`), { stage: 'follow', status: response.status });
|
|
177
192
|
view.update({ live: true });
|
|
178
193
|
status('');
|
|
179
194
|
const reader = response.body.getReader();
|
|
@@ -193,12 +208,14 @@ async function showSession(session, control) {
|
|
|
193
208
|
// The server ends a stream it can no longer resume (a slow reader, a retired cursor): start from a snapshot.
|
|
194
209
|
if (event.terminal && event.type === 'resync_required') cursor = null;
|
|
195
210
|
if (event.type === 'snapshot') apply(event.data);
|
|
196
|
-
else if (event.type === 'session_changed') read().catch((error) =>
|
|
211
|
+
else if (event.type === 'session_changed') read().catch((error) => failed('read', error, error.status ?? null));
|
|
197
212
|
else if (event.type === 'session_deleted') { view.update({ live: false, status: 'error', error: 'This session was deleted.' }); return; }
|
|
198
213
|
}
|
|
199
214
|
}
|
|
200
215
|
} catch (error) {
|
|
201
|
-
|
|
216
|
+
failed(error.stage ?? 'follow', error, error.status ?? null);
|
|
217
|
+
// Nothing drawn yet: the transcript says why instead of loading forever.
|
|
218
|
+
if (!cursor) view.update({ status: 'error', error: error.message });
|
|
202
219
|
}
|
|
203
220
|
view.update({ live: false });
|
|
204
221
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
@@ -320,4 +337,4 @@ async function showFolder(connect, root, control) {
|
|
|
320
337
|
await list(base);
|
|
321
338
|
}
|
|
322
339
|
|
|
323
|
-
main().catch((error) =>
|
|
340
|
+
main().catch((error) => failed(error?.stage ?? 'start', error, error?.status ?? null));
|