@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +69 -0
- package/server/bin/wild-workspace.mjs +95 -0
- package/server/src/activity.mjs +71 -0
- package/server/src/agent.mjs +335 -0
- package/server/src/config.mjs +236 -0
- package/server/src/daemon-bin.mjs +66 -0
- package/server/src/daemon.mjs +178 -0
- package/server/src/fs.mjs +136 -0
- package/server/src/inbox.mjs +81 -0
- package/server/src/index.mjs +635 -0
- package/server/src/preview.mjs +31 -0
- package/server/src/share.mjs +80 -0
- package/server/src/sync.mjs +176 -0
- package/web/dist/assets/index-DOwej8U4.js +89 -0
- package/web/dist/assets/index-DZkyDo10.css +1 -0
- package/web/dist/index.html +15 -0
|
@@ -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
|
+
}
|