@venturewild/workspace 0.1.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,80 @@
1
+ // Share-by-URL token issuance + verification (AR-20).
2
+ // Reuses bmo-sync invite shape: tokens are signed claims with role + workspace + expiry.
3
+
4
+ import { SignJWT, jwtVerify } from 'jose';
5
+ import { nanoid } from 'nanoid';
6
+
7
+ const SHARE_ISSUER = 'wild-workspace';
8
+
9
+ function secretToKey(secret) {
10
+ return new TextEncoder().encode(secret);
11
+ }
12
+
13
+ export async function mintShareToken({
14
+ secret,
15
+ workspaceId,
16
+ role,
17
+ ttlSeconds = 60 * 60 * 24, // 24h default per R17 mitigation
18
+ audience = 'wild-workspace-viewer',
19
+ subject = nanoid(12),
20
+ }) {
21
+ if (!['viewer', 'client'].includes(role)) {
22
+ throw new Error(`shareable role must be 'viewer' or 'client'; got ${role}`);
23
+ }
24
+ const key = secretToKey(secret);
25
+ const iat = Math.floor(Date.now() / 1000);
26
+ const exp = iat + ttlSeconds;
27
+ const jwt = await new SignJWT({
28
+ role,
29
+ workspaceId,
30
+ jti: nanoid(16),
31
+ })
32
+ .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
33
+ .setIssuer(SHARE_ISSUER)
34
+ .setAudience(audience)
35
+ .setSubject(subject)
36
+ .setIssuedAt(iat)
37
+ .setExpirationTime(exp)
38
+ .sign(key);
39
+ return { token: jwt, exp, role, workspaceId, sub: subject };
40
+ }
41
+
42
+ export async function verifyShareToken(token, secret) {
43
+ if (!token) return null;
44
+ try {
45
+ const { payload } = await jwtVerify(token, secretToKey(secret), {
46
+ issuer: SHARE_ISSUER,
47
+ });
48
+ return payload;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
55
+ const base = shareBaseUrl.replace(/\/$/, '');
56
+ return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
57
+ }
58
+
59
+ // Simple in-memory token registry so the partner can list + revoke.
60
+ // Reset on process restart — acceptable for v1; persisted in bmo-sync in v1.x.
61
+ export class TokenRegistry {
62
+ constructor() {
63
+ this.tokens = new Map(); // jti -> { sub, role, workspaceId, exp, label, createdAt }
64
+ this.revoked = new Set();
65
+ }
66
+ add(record) {
67
+ this.tokens.set(record.sub, record);
68
+ }
69
+ list() {
70
+ const now = Math.floor(Date.now() / 1000);
71
+ return [...this.tokens.values()].filter((r) => r.exp > now && !this.revoked.has(r.sub));
72
+ }
73
+ revoke(sub) {
74
+ this.revoked.add(sub);
75
+ this.tokens.delete(sub);
76
+ }
77
+ isRevoked(sub) {
78
+ return this.revoked.has(sub);
79
+ }
80
+ }
@@ -0,0 +1,176 @@
1
+ // bmo-sync folder sharing — wild-workspace's control plane for the daemon.
2
+ //
3
+ // wild-workspace does not run the sync engine; the bmo-sync daemon does
4
+ // (a separate local process — see bmo-sync/daemon/README.md). This module is
5
+ // the thin HTTP client wild-workspace uses to drive it:
6
+ //
7
+ // - pair / detach / list a workspace folder against the local daemon
8
+ // (http://127.0.0.1:8320);
9
+ // - mint an invite against the central server's admin API — but only when
10
+ // an admin key is configured. Most installs only ever *redeem* invites,
11
+ // and that path runs entirely through the daemon and needs no secret.
12
+ //
13
+ // The daemon may not be running. Read paths (health / listWorkspaces /
14
+ // status) degrade to an "offline" result instead of throwing; explicit
15
+ // actions (pair / detach / createInvite) throw a readable error for the UI.
16
+
17
+ const DAEMON_TIMEOUT_MS = 4000;
18
+ // Pairing and invite creation reach the central server on Fly.io, which can
19
+ // cold-start (~1s+) when idle — give those calls a longer leash.
20
+ const SERVER_TIMEOUT_MS = 12000;
21
+
22
+ export class SyncControl {
23
+ /**
24
+ * @param {object} opts
25
+ * @param {string} opts.daemonHttpUrl daemon HTTP origin, e.g. http://127.0.0.1:8320
26
+ * @param {string} opts.bmoSyncServerUrl central server, e.g. https://sync.venturewild.llc
27
+ * @param {string|null} [opts.adminKey] central-server X-Admin-Key; null = redeem-only
28
+ * @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests)
29
+ */
30
+ constructor({ daemonHttpUrl, bmoSyncServerUrl, adminKey = null, fetchImpl } = {}) {
31
+ this.daemonBase = trimSlash(daemonHttpUrl) || 'http://127.0.0.1:8320';
32
+ this.serverBase = trimSlash(bmoSyncServerUrl) || 'https://sync.venturewild.llc';
33
+ this.adminKey = adminKey || null;
34
+ this._fetch = fetchImpl || globalThis.fetch;
35
+ }
36
+
37
+ /** True when this install can mint invites (an admin key is configured). */
38
+ get canInvite() {
39
+ return Boolean(this.adminKey);
40
+ }
41
+
42
+ /** Liveness of the local daemon. Never throws. */
43
+ async health() {
44
+ try {
45
+ const res = await this._fetch(`${this.daemonBase}/health`, {
46
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
47
+ });
48
+ if (!res.ok) return { running: false };
49
+ const body = await res.json().catch(() => ({}));
50
+ return { running: body?.status === 'ok' };
51
+ } catch {
52
+ return { running: false };
53
+ }
54
+ }
55
+
56
+ /** Paired workspaces the daemon is syncing. [] when the daemon is down. */
57
+ async listWorkspaces() {
58
+ try {
59
+ const res = await this._fetch(`${this.daemonBase}/api/workspaces`, {
60
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
61
+ });
62
+ if (!res.ok) return [];
63
+ const body = await res.json().catch(() => ({}));
64
+ return Array.isArray(body?.workspaces) ? body.workspaces : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Snapshot for the Sync panel: daemon liveness, paired workspaces, and
72
+ * whether this install can mint invites. Never throws — a missing daemon
73
+ * is a normal, displayable state.
74
+ */
75
+ async status() {
76
+ const [health, workspaces] = await Promise.all([
77
+ this.health(),
78
+ this.listWorkspaces(),
79
+ ]);
80
+ return {
81
+ daemon: health,
82
+ workspaces,
83
+ paired: workspaces.length > 0,
84
+ canInvite: this.canInvite,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Pair this workspace folder with a shared project by redeeming an invite.
90
+ * The daemon redeems against the central server, persists the pairing, and
91
+ * starts syncing `rootPath`. Throws a readable error on a bad invite or an
92
+ * unreachable daemon.
93
+ */
94
+ async pair(inviteCode, rootPath) {
95
+ const code = String(inviteCode || '').trim();
96
+ if (!code) throw new Error('An invite code is required.');
97
+ if (!rootPath) throw new Error('No workspace folder to pair.');
98
+ const res = await this._daemonFetch(`${this.daemonBase}/api/pair`, {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify({ inviteCode: code, rootPath }),
102
+ // Pairing redeems against the central server — allow for a cold start.
103
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
104
+ });
105
+ const body = await res.json().catch(() => ({}));
106
+ if (!res.ok) {
107
+ throw new Error(body?.error || `Pairing failed (HTTP ${res.status}).`);
108
+ }
109
+ return body.workspace || body;
110
+ }
111
+
112
+ /** Stop syncing a workspace and forget the pairing. */
113
+ async detach(workspaceId) {
114
+ const id = String(workspaceId || '').trim();
115
+ if (!id) throw new Error('A workspace id is required.');
116
+ const res = await this._daemonFetch(
117
+ `${this.daemonBase}/api/workspaces/${encodeURIComponent(id)}`,
118
+ { method: 'DELETE', signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS) },
119
+ );
120
+ const body = await res.json().catch(() => ({}));
121
+ if (!res.ok) {
122
+ throw new Error(body?.error || `Disconnect failed (HTTP ${res.status}).`);
123
+ }
124
+ return { detached: Boolean(body.detached) };
125
+ }
126
+
127
+ /**
128
+ * Mint an invite code for a project on the central server. Requires an
129
+ * admin key; callers should check `canInvite` first and fall back to the
130
+ * paste-a-code flow when it is false.
131
+ */
132
+ async createInvite({ projectCode, displayName, expiresHours = 168 } = {}) {
133
+ if (!this.adminKey) {
134
+ throw new Error(
135
+ 'Creating invites needs a bmo-sync admin key (set BMO_SYNC_ADMIN_KEY). ' +
136
+ 'Without one, ask whoever owns the shared folder to send you a code.',
137
+ );
138
+ }
139
+ if (!projectCode) throw new Error('A paired project is required to invite into.');
140
+ let res;
141
+ try {
142
+ res = await this._fetch(`${this.serverBase}/api/admin/invites`, {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json', 'x-admin-key': this.adminKey },
145
+ body: JSON.stringify({
146
+ project_code: projectCode,
147
+ display_name: displayName || 'Collaborator',
148
+ expires_hours: Number(expiresHours) || 168,
149
+ }),
150
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
151
+ });
152
+ } catch {
153
+ throw new Error('Could not reach the bmo-sync server. Check your connection.');
154
+ }
155
+ const body = await res.json().catch(() => ({}));
156
+ if (!res.ok) {
157
+ throw new Error(
158
+ body?.message || body?.error || `Invite creation failed (HTTP ${res.status}).`,
159
+ );
160
+ }
161
+ return { code: body.code, projectCode: body.project_code, expiresAt: body.expires_at };
162
+ }
163
+
164
+ /** A daemon fetch that turns a connection failure into a readable error. */
165
+ async _daemonFetch(url, init) {
166
+ try {
167
+ return await this._fetch(url, init);
168
+ } catch {
169
+ throw new Error("The bmo-sync daemon isn't running. Start it, then try again.");
170
+ }
171
+ }
172
+ }
173
+
174
+ function trimSlash(u) {
175
+ return typeof u === 'string' ? u.replace(/\/+$/, '') : '';
176
+ }