@venturewild/workspace 0.1.11 → 0.1.13
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 -21
- package/README.md +112 -112
- package/package.json +76 -76
- package/server/bin/wild-workspace.mjs +763 -763
- package/server/src/agent.mjs +386 -386
- package/server/src/config.mjs +365 -325
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1726 -1566
- package/server/src/logpaths.mjs +98 -98
- package/server/src/pairing.mjs +146 -0
- package/server/src/service.mjs +419 -419
- package/server/src/share.mjs +148 -115
- package/server/src/sync.mjs +248 -248
- package/web/dist/assets/{index-n0-hsCzL.js → index-CAzFAt7W.js} +19 -19
- package/web/dist/index.html +1 -1
package/server/src/share.mjs
CHANGED
|
@@ -1,115 +1,148 @@
|
|
|
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
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
-
|
|
8
|
-
const SHARE_ISSUER = 'wild-workspace';
|
|
9
|
-
|
|
10
|
-
function secretToKey(secret) {
|
|
11
|
-
return new TextEncoder().encode(secret);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function mintShareToken({
|
|
15
|
-
secret,
|
|
16
|
-
workspaceId,
|
|
17
|
-
role,
|
|
18
|
-
ttlSeconds = 60 * 60 * 24, // 24h default per R17 mitigation
|
|
19
|
-
audience = 'wild-workspace-viewer',
|
|
20
|
-
subject = nanoid(12),
|
|
21
|
-
}) {
|
|
22
|
-
if (!['viewer', 'client'].includes(role)) {
|
|
23
|
-
throw new Error(`shareable role must be 'viewer' or 'client'; got ${role}`);
|
|
24
|
-
}
|
|
25
|
-
const key = secretToKey(secret);
|
|
26
|
-
const iat = Math.floor(Date.now() / 1000);
|
|
27
|
-
const exp = iat + ttlSeconds;
|
|
28
|
-
const jwt = await new SignJWT({
|
|
29
|
-
role,
|
|
30
|
-
workspaceId,
|
|
31
|
-
jti: nanoid(16),
|
|
32
|
-
})
|
|
33
|
-
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
34
|
-
.setIssuer(SHARE_ISSUER)
|
|
35
|
-
.setAudience(audience)
|
|
36
|
-
.setSubject(subject)
|
|
37
|
-
.setIssuedAt(iat)
|
|
38
|
-
.setExpirationTime(exp)
|
|
39
|
-
.sign(key);
|
|
40
|
-
return { token: jwt, exp, role, workspaceId, sub: subject };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
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
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const SHARE_ISSUER = 'wild-workspace';
|
|
9
|
+
|
|
10
|
+
function secretToKey(secret) {
|
|
11
|
+
return new TextEncoder().encode(secret);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function mintShareToken({
|
|
15
|
+
secret,
|
|
16
|
+
workspaceId,
|
|
17
|
+
role,
|
|
18
|
+
ttlSeconds = 60 * 60 * 24, // 24h default per R17 mitigation
|
|
19
|
+
audience = 'wild-workspace-viewer',
|
|
20
|
+
subject = nanoid(12),
|
|
21
|
+
}) {
|
|
22
|
+
if (!['viewer', 'client'].includes(role)) {
|
|
23
|
+
throw new Error(`shareable role must be 'viewer' or 'client'; got ${role}`);
|
|
24
|
+
}
|
|
25
|
+
const key = secretToKey(secret);
|
|
26
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
27
|
+
const exp = iat + ttlSeconds;
|
|
28
|
+
const jwt = await new SignJWT({
|
|
29
|
+
role,
|
|
30
|
+
workspaceId,
|
|
31
|
+
jti: nanoid(16),
|
|
32
|
+
})
|
|
33
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
34
|
+
.setIssuer(SHARE_ISSUER)
|
|
35
|
+
.setAudience(audience)
|
|
36
|
+
.setSubject(subject)
|
|
37
|
+
.setIssuedAt(iat)
|
|
38
|
+
.setExpirationTime(exp)
|
|
39
|
+
.sign(key);
|
|
40
|
+
return { token: jwt, exp, role, workspaceId, sub: subject };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mint a PARTNER-role token for an approved device (Phase 2 device sign-in).
|
|
44
|
+
// Separate from `mintShareToken` on purpose: that path is least-privilege and
|
|
45
|
+
// must NEVER mint partner (its viewer/client guard is load-bearing). A device
|
|
46
|
+
// token is full owner access, so it's bounded (90d default) and individually
|
|
47
|
+
// revocable via `TokenRegistry` (its `device-` subject is registered at approve
|
|
48
|
+
// time). It verifies through the same `verifyShareToken` path — `classifyToken`
|
|
49
|
+
// then resolves `role:'partner'` exactly like the env partner token, but with a
|
|
50
|
+
// revocable `sub`.
|
|
51
|
+
export async function mintDeviceToken({
|
|
52
|
+
secret,
|
|
53
|
+
workspaceId,
|
|
54
|
+
ttlSeconds = 90 * 24 * 60 * 60, // 90 days — bounded RCE-grade token
|
|
55
|
+
audience = 'wild-workspace-device',
|
|
56
|
+
subject = `device-${nanoid(12)}`,
|
|
57
|
+
}) {
|
|
58
|
+
const key = secretToKey(secret);
|
|
59
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
60
|
+
const exp = iat + ttlSeconds;
|
|
61
|
+
const jwt = await new SignJWT({
|
|
62
|
+
role: 'partner',
|
|
63
|
+
workspaceId,
|
|
64
|
+
jti: nanoid(16),
|
|
65
|
+
})
|
|
66
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
67
|
+
.setIssuer(SHARE_ISSUER)
|
|
68
|
+
.setAudience(audience)
|
|
69
|
+
.setSubject(subject)
|
|
70
|
+
.setIssuedAt(iat)
|
|
71
|
+
.setExpirationTime(exp)
|
|
72
|
+
.sign(key);
|
|
73
|
+
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function verifyShareToken(token, secret) {
|
|
77
|
+
if (!token) return null;
|
|
78
|
+
try {
|
|
79
|
+
const { payload } = await jwtVerify(token, secretToKey(secret), {
|
|
80
|
+
issuer: SHARE_ISSUER,
|
|
81
|
+
});
|
|
82
|
+
return payload;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
|
|
89
|
+
const base = shareBaseUrl.replace(/\/$/, '');
|
|
90
|
+
return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Token registry so the partner can list + revoke. The revocation set (and the
|
|
94
|
+
// active-token list) is persisted to `<dataDir>/revoked.json` so a "revoked"
|
|
95
|
+
// share token stays revoked across a server restart (concern C8) — without it,
|
|
96
|
+
// the in-memory set reset on restart and a previously-revoked-but-unexpired JWT
|
|
97
|
+
// validated again by signature. No `persistPath` → in-memory only (tests).
|
|
98
|
+
export class TokenRegistry {
|
|
99
|
+
constructor({ persistPath = null } = {}) {
|
|
100
|
+
this.persistPath = persistPath;
|
|
101
|
+
this.tokens = new Map(); // sub -> { sub, role, workspaceId, exp, label, createdAt }
|
|
102
|
+
this.revoked = new Set();
|
|
103
|
+
this._load();
|
|
104
|
+
}
|
|
105
|
+
_load() {
|
|
106
|
+
if (!this.persistPath) return;
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(readFileSync(this.persistPath, 'utf8'));
|
|
109
|
+
if (Array.isArray(data.revoked)) this.revoked = new Set(data.revoked);
|
|
110
|
+
if (Array.isArray(data.tokens)) {
|
|
111
|
+
for (const t of data.tokens) if (t?.sub) this.tokens.set(t.sub, t);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* missing / corrupt — start empty */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
_persist() {
|
|
118
|
+
if (!this.persistPath) return;
|
|
119
|
+
try {
|
|
120
|
+
const now = Math.floor(Date.now() / 1000);
|
|
121
|
+
// Don't carry expired tokens forward; the revoked set stays (small).
|
|
122
|
+
const tokens = [...this.tokens.values()].filter((r) => r.exp > now);
|
|
123
|
+
writeFileSync(
|
|
124
|
+
this.persistPath,
|
|
125
|
+
JSON.stringify({ revoked: [...this.revoked], tokens }, null, 2),
|
|
126
|
+
{ mode: 0o600 },
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
/* read-only fs — revocation degrades to in-memory for this run */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
add(record) {
|
|
133
|
+
this.tokens.set(record.sub, record);
|
|
134
|
+
this._persist();
|
|
135
|
+
}
|
|
136
|
+
list() {
|
|
137
|
+
const now = Math.floor(Date.now() / 1000);
|
|
138
|
+
return [...this.tokens.values()].filter((r) => r.exp > now && !this.revoked.has(r.sub));
|
|
139
|
+
}
|
|
140
|
+
revoke(sub) {
|
|
141
|
+
this.revoked.add(sub);
|
|
142
|
+
this.tokens.delete(sub);
|
|
143
|
+
this._persist();
|
|
144
|
+
}
|
|
145
|
+
isRevoked(sub) {
|
|
146
|
+
return this.revoked.has(sub);
|
|
147
|
+
}
|
|
148
|
+
}
|