@volter/supercode-teams 0.2.28 → 0.2.29

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/README.md CHANGED
@@ -49,15 +49,28 @@ A workplace can open one pane or one folder for someone who is not a member of t
49
49
  server started with `--delegated-issuer ORIGIN` (the Worker's `DELEGATED_ISSUER`) trusts short-lived
50
50
  tokens that issuer signs (Ed25519, keys at `ORIGIN/.well-known/jwks.json`, audience this server's
51
51
  public origin), each naming a pane (`pane:<key>`) or a folder (`root:/path`) on one machine of one
52
- team, and `view` or `control`. The hosted viewer at `/view#token=…` opens it; the server turns the
53
- token into a machine grant on exactly that target (a pane: `observe`, and `control` to type; a
54
- folder: `observe` to read, `files` to change) that expires with the token, and closes the channel
55
- then. A page embedding the viewer sends it a fresh token by `postMessage({ type: 'delegated-token',
56
- token })`, which the viewer asks for with `delegated-token-needed`.
57
-
58
- `teams publish` puts a pane or a folder of the connected machine (or `--machine NAME`) into a
59
- workplace Room as a Surface, through the workplace's own door with a session token of yours;
60
- `teams unpublish SURFACE` retires it:
52
+ team, and `view` or `control`. A token opens only a surface the machine's custodian **published** on
53
+ this server (the machine's publications, `/machines/{id}/publications`): the same team, machine and
54
+ exact target, published to that workplace and to the Room the token names, by the machine's current
55
+ custodian while still an active member of the team. A token naming no Room, or one for
56
+ anything else, for a wildcard (`pane:*`) or for the whole filesystem (`root:/`) opens nothing, and a
57
+ token must carry `iat` and live at most 15 minutes (`exp - iat` and `exp - now`). The hosted viewer
58
+ at `/view#token=…` opens it; the server turns the token into a machine grant on exactly that target
59
+ (a pane: `observe`, and `control` to type; a folder: `observe` to read, `files` to change) that
60
+ expires with the token, and closes the channel then. A page embedding the viewer sends it a fresh
61
+ token by `postMessage({ type: 'delegated-token', token })`, which the viewer asks for with
62
+ `delegated-token-needed`.
63
+
64
+ `teams publish` records the surface on this server (only the machine's custodian can), then puts
65
+ the pane or folder of the connected machine (or `--machine NAME`) into a workplace Room as a
66
+ Surface, through the workplace's own door with a session token of yours; if the workplace refuses,
67
+ the record is withdrawn. `teams unpublish SURFACE` (the surface key or the publication id)
68
+ deletes this server's record first, on whichever of your machines holds it, which deletes the
69
+ grants minted on it and closes their channels at once, then retires it in the workplace. It fails
70
+ when this server holds no such record, unless `--workplace-only`; `--record-only` deletes the
71
+ record here without a workplace call or token. Removing a member likewise ends every delegated
72
+ grant on the surfaces they published. A surface published before this server kept records opens nothing until its custodian
73
+ records it with `teams publish … --record-only` (no workplace call, no workplace token):
61
74
 
62
75
  ```sh
63
76
  supercode teams publish pane <ctx> --room room_… --workplace https://pilot.runhuman.com --organization acme --token-stdin
@@ -1,7 +1,7 @@
1
1
  const BOOLEANS = new Set([
2
2
  'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
3
3
  'invite-stdin', 'include-children', 'rotate', 'install', 'force',
4
- 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect',
4
+ 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect', 'record-only', 'workplace-only',
5
5
  ]);
6
6
 
7
7
  const VALUES = new Set([
package/bin/workspace.mjs CHANGED
@@ -575,8 +575,10 @@ async function machinesCommand(sub, { flags, rest, output, client, context, team
575
575
  const machine = reference.startsWith('mach_') ? reference : await resolveName((await team.machines.list()).items, reference, item => item.name, item => item.id, 'machine');
576
576
  const machineBase = teamPath(context, `/machines/${machine}/access`), liveBase = teamPath(context, `/machines/${machine}/grants`);
577
577
  if (action === 'list') {
578
- const [machineGrants, liveGrants] = await Promise.all([client.request('GET', machineBase), client.request('GET', liveBase)]);
579
- return output({ version: 1, context: scope(context), data: { machine_grants: machineGrants.items, live_grants: liveGrants.items } });
578
+ // A team owner or admin manages a machine's grants; its live session grants are its custodian's alone, so
579
+ // for anyone else that half is not found and the machine grants still answer.
580
+ const [machineGrants, liveGrants] = await Promise.all([client.request('GET', machineBase), client.request('GET', liveBase).catch(error => { if (error.code === 'not_found') return null; throw error; })]);
581
+ return output({ version: 1, context: scope(context), data: { machine_grants: machineGrants.items, live_grants: liveGrants?.items ?? null } });
580
582
  }
581
583
  if (action === 'revoke') {
582
584
  if (!flags.grant) usage('--grant is required');
@@ -745,40 +747,77 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
745
747
  /**
746
748
  * PUBLISH: one pane or folder of a machine of this team, put into a workplace Room as a Surface (RH2's Surface
747
749
  * door). The workplace decides who in the Room may see or drive it and signs them delegated tokens; this server
748
- * admits those if it was started with that workplace as its delegated issuer (server/delegated.mjs). The machine is
749
- * the one `teams connect` enrolled here, or `--machine NAME`. The workplace credential is a session token, from
750
- * --token-stdin or WORKPLACE_TOKEN. `unpublish SURFACE` retires it.
750
+ * admits those if it was started with that workplace as its delegated issuer (server/delegated.mjs), and only for a
751
+ * surface its custodian published: so `publish` first records the surface on this server (the machine's
752
+ * publications, server/publications.mjs), then puts it into the workplace, and withdraws the record if the workplace
753
+ * refuses. `--record-only` records it here without touching the workplace (a surface put there before this server kept
754
+ * records). The machine is the one `teams connect` enrolled here, or `--machine NAME`. The workplace credential is a
755
+ * session token, from --token-stdin or WORKPLACE_TOKEN. `unpublish SURFACE` (the surface key, or the publication id)
756
+ * deletes this server's record first, on whichever of the caller's machines holds it (every token for it stops working
757
+ * at once), then retires it in the workplace; it fails when this server holds no such record, unless
758
+ * `--workplace-only`. `unpublish --record-only` deletes the record here and leaves the workplace alone.
751
759
  */
752
- async function publishCommand(verb, sub, { flags, rest, output, context, team, home, env }) {
753
- only(flags, ['json', 'help', 'context', 'room', 'workplace', 'organization', 'name', 'key', 'machine', 'token_stdin']);
754
- if (!flags.room || !flags.workplace || !flags.organization) usage(`teams ${verb} needs --room ROOM --workplace ORIGIN --organization ORG`);
760
+ async function publishCommand(verb, sub, { flags, rest, output, context, team, client, home, env }) {
761
+ only(flags, ['json', 'help', 'context', 'room', 'workplace', 'organization', 'name', 'key', 'machine', 'token_stdin', 'record_only', 'workplace_only']);
762
+ const recordOnly = Boolean(flags.record_only);
763
+ if (!flags.room || !flags.workplace || (!recordOnly && !flags.organization)) usage(`teams ${verb} needs --room ROOM --workplace ORIGIN --organization ORG`);
755
764
  const workplace = new URL(flags.workplace).origin;
756
- const token = flags.token_stdin ? readFileSync(0, 'utf8').trim() : env.WORKPLACE_TOKEN;
757
- if (!token) usage('the workplace credential comes from --token-stdin or WORKPLACE_TOKEN');
765
+ const token = recordOnly ? null : flags.token_stdin ? readFileSync(0, 'utf8').trim() : env.WORKPLACE_TOKEN;
766
+ if (!recordOnly && !token) usage('the workplace credential comes from --token-stdin or WORKPLACE_TOKEN');
767
+ const machine = flags.machine
768
+ ? resolveName((await team.machines.list()).items ?? [], flags.machine, item => item.name, item => item.id, 'machine')
769
+ : new WorkspaceContexts(home).connector(context.server_id, context.team_id).read()?.machine?.id;
770
+ if (!machine && verb === 'publish') usage('this machine is not connected to the team: run teams connect, or name --machine');
771
+ const publicationsOf = (id) => teamPath(context, `/machines/${encodeURIComponent(id)}/publications`);
772
+ const publications = machine ? publicationsOf(machine) : null;
773
+ const recorded = async () => (await client.request('GET', publications)).items ?? [];
758
774
  const roomPath = `${workplace}/api/v3/rooms/${encodeURIComponent(flags.room)}/surfaces`;
759
- let url, body;
775
+ const workplaceCall = async (url, body) => {
776
+ const response = await fetch(url, { body: JSON.stringify(body), headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', origin: workplace, 'x-rh2-organization': String(flags.organization) }, method: 'POST' });
777
+ const payload = await response.json().catch(() => null);
778
+ if (!response.ok) throw Object.assign(new Error(payload?.error?.message ?? `the workplace answered ${response.status}`), { code: response.status === 403 || response.status === 404 ? 'forbidden' : response.status === 401 ? 'authentication_required' : 'invalid_request' });
779
+ return payload;
780
+ };
760
781
  if (verb === 'unpublish') {
761
- requireArgs([sub, ...rest].filter(Boolean), 1, 1, 'teams unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG');
762
- url = `${roomPath}/${encodeURIComponent(sub)}/retire`; body = {};
763
- } else {
764
- if (sub !== 'pane' && sub !== 'root') usage('teams publish pane CONTEXT_KEY|root PATH --room ROOM --workplace ORIGIN --organization ORG [--name NAME] [--key KEY] [--machine NAME]');
765
- requireArgs(rest, 1, 1, `teams publish ${sub}`);
766
- const machine = flags.machine
767
- ? resolveName((await team.machines.list()).items ?? [], flags.machine, item => item.name, item => item.id, 'machine')
768
- : new WorkspaceContexts(home).connector(context.server_id, context.team_id).read()?.machine?.id;
769
- if (!machine) usage('this machine is not connected to the team: run teams connect, or name --machine');
770
- // A folder on this machine is its real path; on another machine, the path as given there.
771
- const target = sub === 'pane' ? `pane:${rest[0]}` : `root:${flags.machine ? rest[0] : realpathSync(resolve(rest[0]))}`;
772
- if (sub === 'root' && !target.startsWith('root:/')) usage('a folder on another machine is named by its absolute path');
773
- const label = sub === 'pane' ? rest[0] : basename(target.slice(5)) || 'root';
774
- const key = flags.key ?? `${sub === 'pane' ? 'pane' : 'folder'}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || createHash('sha256').update(target).digest('hex').slice(0, 8)}`;
775
- url = roomPath;
776
- body = { config: { machine, target, team: context.team_id, teamsUrl: new URL(context.origin).origin }, key, kind: sub === 'pane' ? 'pane' : 'folder', name: flags.name ?? label };
777
- }
778
- const response = await fetch(url, { body: JSON.stringify(body), headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', origin: workplace, 'x-rh2-organization': String(flags.organization) }, method: 'POST' });
779
- const payload = await response.json().catch(() => null);
780
- if (!response.ok) throw Object.assign(new Error(payload?.error?.message ?? `the workplace answered ${response.status}`), { code: response.status === 403 || response.status === 404 ? 'forbidden' : response.status === 401 ? 'authentication_required' : 'invalid_request' });
781
- return output({ version: 1, context: scope(context), data: payload?.data ?? null });
782
+ requireArgs([sub, ...rest].filter(Boolean), 1, 1, 'teams unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--machine NAME] [--record-only|--workplace-only]');
783
+ // This server's record goes first: the moment it does, no delegated token opens the surface, whatever the workplace
784
+ // does next. Named --machine, only that machine; otherwise every machine of the team this caller may read the
785
+ // publications of (its own; all of them for an admin).
786
+ const machineIds = flags.machine ? [machine] : ((await team.machines.list()).items ?? []).map(item => item.id);
787
+ const matching = [];
788
+ for (const id of machineIds) {
789
+ let items;
790
+ try { items = (await client.request('GET', publicationsOf(id))).items ?? []; } catch (error) { if (flags.machine || !['forbidden', 'not_found'].includes(error?.code)) throw error; continue; }
791
+ for (const item of items) if (item.workplace === workplace && item.room === flags.room && (item.surface_key === sub || item.id === sub)) matching.push(item);
792
+ }
793
+ if (new Set(matching.map(item => item.machine_id)).size > 1) usage(`${sub} is published from more than one machine: name the publication id, or --machine`);
794
+ if (!matching.length && !flags.workplace_only) throw Object.assign(new Error(`this server holds no publication ${sub} for that Room and workplace (name the publication id, or --machine; --workplace-only retires it in the workplace alone)`), { code: 'not_found' });
795
+ for (const item of matching) await client.request('DELETE', `${publicationsOf(item.machine_id)}/${encodeURIComponent(item.id)}`);
796
+ if (recordOnly) return output({ version: 1, context: scope(context), data: { revoked: matching.map(item => item.id) } });
797
+ const payload = await workplaceCall(`${roomPath}/${encodeURIComponent(sub)}/retire`, {});
798
+ return output({ version: 1, context: scope(context), data: { ...(payload?.data && typeof payload.data === 'object' ? payload.data : {}), revoked: matching.map(item => item.id) } });
799
+ }
800
+ if (sub !== 'pane' && sub !== 'root') usage('teams publish pane CONTEXT_KEY|root PATH --room ROOM --workplace ORIGIN --organization ORG [--name NAME] [--key KEY] [--machine NAME] [--record-only]');
801
+ requireArgs(rest, 1, 1, `teams publish ${sub}`);
802
+ // A folder on this machine is its real path; on another machine, the path as given there.
803
+ const target = sub === 'pane' ? `pane:${rest[0]}` : `root:${flags.machine ? rest[0] : realpathSync(resolve(rest[0]))}`;
804
+ if (sub === 'root' && !target.startsWith('root:/')) usage('a folder on another machine is named by its absolute path');
805
+ if (sub === 'root' && target === 'root:/') usage('publish a folder, not the whole filesystem');
806
+ const label = sub === 'pane' ? rest[0] : basename(target.slice(5)) || 'root';
807
+ const key = flags.key ?? `${sub === 'pane' ? 'pane' : 'folder'}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || createHash('sha256').update(target).digest('hex').slice(0, 8)}`;
808
+ const kind = sub === 'pane' ? 'pane' : 'folder';
809
+ const existed = (await recorded()).some(item => item.target === target && item.workplace === workplace && item.room === flags.room);
810
+ const publication = (await client.request('POST', publications, { body: { kind, target, workplace, room: flags.room, surface_key: key } })).data;
811
+ if (recordOnly) return output({ version: 1, context: scope(context), data: { publication } });
812
+ let payload;
813
+ try {
814
+ payload = await workplaceCall(roomPath, { config: { machine, target, team: context.team_id, teamsUrl: new URL(context.origin).origin }, key, kind, name: flags.name ?? label });
815
+ } catch (error) {
816
+ // Nothing is left published here that the workplace does not show.
817
+ if (!existed) await client.request('DELETE', `${publications}/${encodeURIComponent(publication.id)}`).catch(() => {});
818
+ throw error;
819
+ }
820
+ return output({ version: 1, context: scope(context), data: payload?.data && typeof payload.data === 'object' ? { ...payload.data, publication } : { surface: payload?.data ?? null, publication } });
782
821
  }
783
822
 
784
823
  async function listQuery(flags, { client, context, team }) {
@@ -874,8 +913,8 @@ function printHelp() {
874
913
  login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
875
914
  identity link
876
915
  server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN]
877
- publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--name] [--key] [--machine] [--token-stdin]
878
- unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--token-stdin]
916
+ publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--name] [--key] [--machine] [--token-stdin] [--record-only]
917
+ unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--machine] [--token-stdin] [--record-only|--workplace-only]
879
918
  connect|disconnect|status
880
919
  members invite|list|remove|role
881
920
  groups list|create|remove|add-member|remove-member
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -53,9 +53,7 @@
53
53
  "engines": {
54
54
  "node": ">=20"
55
55
  },
56
- "scripts": {
57
- "typecheck:teams": "tsc --noEmit -p test/tsconfig.json"
58
- },
56
+ "scripts": {},
59
57
  "dependencies": {
60
58
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
61
59
  "@volter/supercode-frontend": "^0.2.2",