@venturewild/workspace 0.6.3 → 0.6.5
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 +85 -85
- package/server/bin/wild-workspace.mjs +1096 -1096
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +974 -974
- package/server/src/bazaar/index.mjs +88 -88
- package/server/src/bazaar/mcp-server.mjs +429 -429
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
- package/server/src/canvas/core.mjs +446 -446
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -108
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +3332 -3332
- package/server/src/listings-rails.mjs +156 -156
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +295 -295
- package/server/src/workspaces.mjs +145 -145
- package/web/dist/assets/index-nEl9swiQ.js +131 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DVflHhYJ.js +0 -131
package/server/src/settings.mjs
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
// Settings store + the autonomy-dial resolver (§8 "Surface B"). Two things live here:
|
|
2
|
-
// 1. resolveAutonomyMode() — the SAFETY-CRITICAL mapping from the user-facing
|
|
3
|
-
// autonomy level to the agent's permission mode. This is the guardrail.
|
|
4
|
-
// 2. createSettings() — a file-backed store under ~/.wild-workspace/settings/ for
|
|
5
|
-
// the autonomy level + the conversation title (/rename override of the derived
|
|
6
|
-
// presence label). CLAUDE.md #1: never the synced repo, never localStorage.
|
|
7
|
-
//
|
|
8
|
-
// THE AUTONOMY DIAL — and why the middle tier is "coming soon", not built (read this):
|
|
9
|
-
//
|
|
10
|
-
// The §8 build-risk is explicit: "Work, then show me" is NOT one native flag. It is
|
|
11
|
-
// acceptEdits PLUS our own confirm-gate on dangerous non-edit ops (deploy/delete/
|
|
12
|
-
// send/spend). A TRUE inline confirm-gate needs to intercept a tool call mid-turn,
|
|
13
|
-
// round-trip a confirmation to the browser, and resume — which our architecture
|
|
14
|
-
// can't do cleanly tonight: we wrap `claude -p` per turn (AR-17: no Agent SDK, so no
|
|
15
|
-
// `canUseTool` callback), and headless `-p` has no interactive permission prompt, so
|
|
16
|
-
// `default`/`acceptEdits` modes can't "ask" — they just deny. Building that
|
|
17
|
-
// round-trip is a real piece of infra, not a flag.
|
|
18
|
-
//
|
|
19
|
-
// So per the pre-approved fallback we ship only the TWO UNAMBIGUOUS tiers and leave
|
|
20
|
-
// the middle DISABLED ("coming soon"):
|
|
21
|
-
// • 'check' (Check with me) → 'plan' — read-only; the agent proposes, you approve.
|
|
22
|
-
// • 'full' (Full autonomy) → honors the Build/Plan toggle (Build → bypass).
|
|
23
|
-
// The middle ('work') and ANY unknown value SAFELY fall back to 'plan' — NEVER
|
|
24
|
-
// bypassPermissions. That is the guardrail the build-risk warns about: an unbuilt or
|
|
25
|
-
// unrecognized autonomy level must never silently behave like Full autonomy.
|
|
26
|
-
//
|
|
27
|
-
// DEFAULT = UNSET. An install with no chosen level keeps today's behavior exactly
|
|
28
|
-
// (the per-message Build/Plan toggle drives the mode — Build is already bypass). The
|
|
29
|
-
// dial is purely ADDITIVE: it lets a nervous user LOCK to read-only, which they
|
|
30
|
-
// couldn't before. We did NOT change the product's existing default, and we did NOT
|
|
31
|
-
// map the middle tier to Full. (Whether the default SHOULD become a guarded
|
|
32
|
-
// auto-build once the confirm-gate ships is a product call flagged for Tuan.)
|
|
33
|
-
|
|
34
|
-
import fs from 'node:fs';
|
|
35
|
-
import path from 'node:path';
|
|
36
|
-
import os from 'node:os';
|
|
37
|
-
|
|
38
|
-
// The three user-facing tiers. 'work' is DEFINED (so the UI can render it) but NOT
|
|
39
|
-
// BUILD-BACKED — see resolveAutonomyMode (it falls back to the safe 'plan').
|
|
40
|
-
export const AUTONOMY_LEVELS = ['check', 'work', 'full'];
|
|
41
|
-
// Which tiers a user may actually SELECT today. 'work' is excluded — "coming soon".
|
|
42
|
-
export const SELECTABLE_AUTONOMY = ['check', 'full'];
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Map an autonomy level + the per-message Build/Plan toggle to the agent permission
|
|
46
|
-
* mode buildClaudeArgs understands ('plan' | 'build'). THE GUARDRAIL lives here:
|
|
47
|
-
* - unknown / 'work' (middle, not built) → 'plan' (read-only — NEVER bypass)
|
|
48
|
-
* - 'check' → 'plan' (hard read-only lock)
|
|
49
|
-
* - 'full' → honors the toggle (Build → bypass)
|
|
50
|
-
* - null/undefined (unset) → honors the toggle (today's behavior)
|
|
51
|
-
* The invariant tested in settings.test.mjs: NOTHING except 'full'/unset+Build ever
|
|
52
|
-
* yields 'build' (which is the only path to bypassPermissions).
|
|
53
|
-
*/
|
|
54
|
-
export function resolveAutonomyMode(level, requestedMode) {
|
|
55
|
-
const toggle = requestedMode === 'plan' ? 'plan' : 'build';
|
|
56
|
-
switch (level) {
|
|
57
|
-
case 'full':
|
|
58
|
-
return toggle; // Full autonomy → honor the toggle (Build = bypass)
|
|
59
|
-
case 'check':
|
|
60
|
-
return 'plan'; // Check with me → always read-only propose
|
|
61
|
-
case null:
|
|
62
|
-
case undefined:
|
|
63
|
-
case '':
|
|
64
|
-
return toggle; // unset → unchanged product behavior
|
|
65
|
-
default:
|
|
66
|
-
// 'work' (middle, not built) AND any unrecognized value → SAFE read-only.
|
|
67
|
-
// This is the anti-regression: an unbuilt guardrail never becomes Full.
|
|
68
|
-
return 'plan';
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function defaultSettingsDir(env = process.env, home = os.homedir()) {
|
|
73
|
-
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
|
|
74
|
-
return path.join(base, 'settings');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function readJsonSafe(file, fallback) {
|
|
78
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const TITLE_CAP = 120;
|
|
82
|
-
|
|
83
|
-
export function createSettings({ baseDir, env = process.env, home = os.homedir() } = {}) {
|
|
84
|
-
const dir = baseDir || defaultSettingsDir(env, home);
|
|
85
|
-
const file = path.join(dir, 'settings.json');
|
|
86
|
-
|
|
87
|
-
function read() {
|
|
88
|
-
const v = readJsonSafe(file, null);
|
|
89
|
-
return v && typeof v === 'object' ? v : {};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function write(patch) {
|
|
93
|
-
const next = { ...read(), ...patch, ts: Date.now() };
|
|
94
|
-
try {
|
|
95
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
97
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
98
|
-
fs.renameSync(tmp, file);
|
|
99
|
-
} catch { /* read-only fs — degrade to not-persisted */ }
|
|
100
|
-
return next;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// autonomyLevel: 'check' | 'full' | null. We REFUSE to persist 'work' (not
|
|
104
|
-
// selectable) or any unknown value — storing it would be a latent foot-gun. null
|
|
105
|
-
// clears back to the unset/default behavior.
|
|
106
|
-
function getAutonomyLevel() {
|
|
107
|
-
const lvl = read().autonomyLevel;
|
|
108
|
-
return SELECTABLE_AUTONOMY.includes(lvl) ? lvl : null;
|
|
109
|
-
}
|
|
110
|
-
function setAutonomyLevel(level) {
|
|
111
|
-
const clean = SELECTABLE_AUTONOMY.includes(level) ? level : null;
|
|
112
|
-
write({ autonomyLevel: clean });
|
|
113
|
-
return clean;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function getConversationTitle() {
|
|
117
|
-
const t = read().conversationTitle;
|
|
118
|
-
return typeof t === 'string' && t.trim() ? t.trim() : null;
|
|
119
|
-
}
|
|
120
|
-
function setConversationTitle(title) {
|
|
121
|
-
const clean = typeof title === 'string' ? title.trim().slice(0, TITLE_CAP) : '';
|
|
122
|
-
write({ conversationTitle: clean || null });
|
|
123
|
-
return clean || null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function getAll() {
|
|
127
|
-
return {
|
|
128
|
-
autonomyLevel: getAutonomyLevel(),
|
|
129
|
-
conversationTitle: getConversationTitle(),
|
|
130
|
-
// Surface what's selectable + which is build-backed, so the UI doesn't hardcode it.
|
|
131
|
-
autonomy: {
|
|
132
|
-
levels: AUTONOMY_LEVELS,
|
|
133
|
-
selectable: SELECTABLE_AUTONOMY,
|
|
134
|
-
comingSoon: AUTONOMY_LEVELS.filter((l) => !SELECTABLE_AUTONOMY.includes(l)),
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
dir, file,
|
|
141
|
-
getAutonomyLevel, setAutonomyLevel,
|
|
142
|
-
getConversationTitle, setConversationTitle,
|
|
143
|
-
getAll,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
1
|
+
// Settings store + the autonomy-dial resolver (§8 "Surface B"). Two things live here:
|
|
2
|
+
// 1. resolveAutonomyMode() — the SAFETY-CRITICAL mapping from the user-facing
|
|
3
|
+
// autonomy level to the agent's permission mode. This is the guardrail.
|
|
4
|
+
// 2. createSettings() — a file-backed store under ~/.wild-workspace/settings/ for
|
|
5
|
+
// the autonomy level + the conversation title (/rename override of the derived
|
|
6
|
+
// presence label). CLAUDE.md #1: never the synced repo, never localStorage.
|
|
7
|
+
//
|
|
8
|
+
// THE AUTONOMY DIAL — and why the middle tier is "coming soon", not built (read this):
|
|
9
|
+
//
|
|
10
|
+
// The §8 build-risk is explicit: "Work, then show me" is NOT one native flag. It is
|
|
11
|
+
// acceptEdits PLUS our own confirm-gate on dangerous non-edit ops (deploy/delete/
|
|
12
|
+
// send/spend). A TRUE inline confirm-gate needs to intercept a tool call mid-turn,
|
|
13
|
+
// round-trip a confirmation to the browser, and resume — which our architecture
|
|
14
|
+
// can't do cleanly tonight: we wrap `claude -p` per turn (AR-17: no Agent SDK, so no
|
|
15
|
+
// `canUseTool` callback), and headless `-p` has no interactive permission prompt, so
|
|
16
|
+
// `default`/`acceptEdits` modes can't "ask" — they just deny. Building that
|
|
17
|
+
// round-trip is a real piece of infra, not a flag.
|
|
18
|
+
//
|
|
19
|
+
// So per the pre-approved fallback we ship only the TWO UNAMBIGUOUS tiers and leave
|
|
20
|
+
// the middle DISABLED ("coming soon"):
|
|
21
|
+
// • 'check' (Check with me) → 'plan' — read-only; the agent proposes, you approve.
|
|
22
|
+
// • 'full' (Full autonomy) → honors the Build/Plan toggle (Build → bypass).
|
|
23
|
+
// The middle ('work') and ANY unknown value SAFELY fall back to 'plan' — NEVER
|
|
24
|
+
// bypassPermissions. That is the guardrail the build-risk warns about: an unbuilt or
|
|
25
|
+
// unrecognized autonomy level must never silently behave like Full autonomy.
|
|
26
|
+
//
|
|
27
|
+
// DEFAULT = UNSET. An install with no chosen level keeps today's behavior exactly
|
|
28
|
+
// (the per-message Build/Plan toggle drives the mode — Build is already bypass). The
|
|
29
|
+
// dial is purely ADDITIVE: it lets a nervous user LOCK to read-only, which they
|
|
30
|
+
// couldn't before. We did NOT change the product's existing default, and we did NOT
|
|
31
|
+
// map the middle tier to Full. (Whether the default SHOULD become a guarded
|
|
32
|
+
// auto-build once the confirm-gate ships is a product call flagged for Tuan.)
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
|
|
38
|
+
// The three user-facing tiers. 'work' is DEFINED (so the UI can render it) but NOT
|
|
39
|
+
// BUILD-BACKED — see resolveAutonomyMode (it falls back to the safe 'plan').
|
|
40
|
+
export const AUTONOMY_LEVELS = ['check', 'work', 'full'];
|
|
41
|
+
// Which tiers a user may actually SELECT today. 'work' is excluded — "coming soon".
|
|
42
|
+
export const SELECTABLE_AUTONOMY = ['check', 'full'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Map an autonomy level + the per-message Build/Plan toggle to the agent permission
|
|
46
|
+
* mode buildClaudeArgs understands ('plan' | 'build'). THE GUARDRAIL lives here:
|
|
47
|
+
* - unknown / 'work' (middle, not built) → 'plan' (read-only — NEVER bypass)
|
|
48
|
+
* - 'check' → 'plan' (hard read-only lock)
|
|
49
|
+
* - 'full' → honors the toggle (Build → bypass)
|
|
50
|
+
* - null/undefined (unset) → honors the toggle (today's behavior)
|
|
51
|
+
* The invariant tested in settings.test.mjs: NOTHING except 'full'/unset+Build ever
|
|
52
|
+
* yields 'build' (which is the only path to bypassPermissions).
|
|
53
|
+
*/
|
|
54
|
+
export function resolveAutonomyMode(level, requestedMode) {
|
|
55
|
+
const toggle = requestedMode === 'plan' ? 'plan' : 'build';
|
|
56
|
+
switch (level) {
|
|
57
|
+
case 'full':
|
|
58
|
+
return toggle; // Full autonomy → honor the toggle (Build = bypass)
|
|
59
|
+
case 'check':
|
|
60
|
+
return 'plan'; // Check with me → always read-only propose
|
|
61
|
+
case null:
|
|
62
|
+
case undefined:
|
|
63
|
+
case '':
|
|
64
|
+
return toggle; // unset → unchanged product behavior
|
|
65
|
+
default:
|
|
66
|
+
// 'work' (middle, not built) AND any unrecognized value → SAFE read-only.
|
|
67
|
+
// This is the anti-regression: an unbuilt guardrail never becomes Full.
|
|
68
|
+
return 'plan';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function defaultSettingsDir(env = process.env, home = os.homedir()) {
|
|
73
|
+
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
|
|
74
|
+
return path.join(base, 'settings');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJsonSafe(file, fallback) {
|
|
78
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const TITLE_CAP = 120;
|
|
82
|
+
|
|
83
|
+
export function createSettings({ baseDir, env = process.env, home = os.homedir() } = {}) {
|
|
84
|
+
const dir = baseDir || defaultSettingsDir(env, home);
|
|
85
|
+
const file = path.join(dir, 'settings.json');
|
|
86
|
+
|
|
87
|
+
function read() {
|
|
88
|
+
const v = readJsonSafe(file, null);
|
|
89
|
+
return v && typeof v === 'object' ? v : {};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function write(patch) {
|
|
93
|
+
const next = { ...read(), ...patch, ts: Date.now() };
|
|
94
|
+
try {
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
97
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
98
|
+
fs.renameSync(tmp, file);
|
|
99
|
+
} catch { /* read-only fs — degrade to not-persisted */ }
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// autonomyLevel: 'check' | 'full' | null. We REFUSE to persist 'work' (not
|
|
104
|
+
// selectable) or any unknown value — storing it would be a latent foot-gun. null
|
|
105
|
+
// clears back to the unset/default behavior.
|
|
106
|
+
function getAutonomyLevel() {
|
|
107
|
+
const lvl = read().autonomyLevel;
|
|
108
|
+
return SELECTABLE_AUTONOMY.includes(lvl) ? lvl : null;
|
|
109
|
+
}
|
|
110
|
+
function setAutonomyLevel(level) {
|
|
111
|
+
const clean = SELECTABLE_AUTONOMY.includes(level) ? level : null;
|
|
112
|
+
write({ autonomyLevel: clean });
|
|
113
|
+
return clean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getConversationTitle() {
|
|
117
|
+
const t = read().conversationTitle;
|
|
118
|
+
return typeof t === 'string' && t.trim() ? t.trim() : null;
|
|
119
|
+
}
|
|
120
|
+
function setConversationTitle(title) {
|
|
121
|
+
const clean = typeof title === 'string' ? title.trim().slice(0, TITLE_CAP) : '';
|
|
122
|
+
write({ conversationTitle: clean || null });
|
|
123
|
+
return clean || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getAll() {
|
|
127
|
+
return {
|
|
128
|
+
autonomyLevel: getAutonomyLevel(),
|
|
129
|
+
conversationTitle: getConversationTitle(),
|
|
130
|
+
// Surface what's selectable + which is build-backed, so the UI doesn't hardcode it.
|
|
131
|
+
autonomy: {
|
|
132
|
+
levels: AUTONOMY_LEVELS,
|
|
133
|
+
selectable: SELECTABLE_AUTONOMY,
|
|
134
|
+
comingSoon: AUTONOMY_LEVELS.filter((l) => !SELECTABLE_AUTONOMY.includes(l)),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
dir, file,
|
|
141
|
+
getAutonomyLevel, setAutonomyLevel,
|
|
142
|
+
getConversationTitle, setConversationTitle,
|
|
143
|
+
getAll,
|
|
144
|
+
};
|
|
145
|
+
}
|
package/server/src/share.mjs
CHANGED
|
@@ -1,182 +1,182 @@
|
|
|
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
|
-
// Mint a one-time-ish BOOTSTRAP token for the first-device sign-in on the public
|
|
77
|
-
// URL (B1). The local launcher fetches this over genuine loopback and opens
|
|
78
|
-
// <slug>.venturewild.llc/?t=<token>; the SPA exchanges it for a DURABLE device
|
|
79
|
-
// cookie (see /api/auth/exchange) and strips it from the address bar. It is
|
|
80
|
-
// partner-role but deliberately SHORT-LIVED (5 min default) so its only window
|
|
81
|
-
// of exposure — one navigation through the VW tunnel — is tightly bounded; the
|
|
82
|
-
// long-lived credential the browser keeps is the re-minted device token, never
|
|
83
|
-
// this. Carries `boot:true` so the exchange path can recognise + upgrade it.
|
|
84
|
-
export async function mintBootstrapToken({
|
|
85
|
-
secret,
|
|
86
|
-
workspaceId,
|
|
87
|
-
ttlSeconds = 5 * 60, // 5 minutes — bounds the token-in-URL window
|
|
88
|
-
audience = 'wild-workspace-bootstrap',
|
|
89
|
-
subject = `bootstrap-${nanoid(12)}`,
|
|
90
|
-
}) {
|
|
91
|
-
const key = secretToKey(secret);
|
|
92
|
-
const iat = Math.floor(Date.now() / 1000);
|
|
93
|
-
const exp = iat + ttlSeconds;
|
|
94
|
-
const jwt = await new SignJWT({
|
|
95
|
-
role: 'partner',
|
|
96
|
-
workspaceId,
|
|
97
|
-
boot: true,
|
|
98
|
-
jti: nanoid(16),
|
|
99
|
-
})
|
|
100
|
-
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
101
|
-
.setIssuer(SHARE_ISSUER)
|
|
102
|
-
.setAudience(audience)
|
|
103
|
-
.setSubject(subject)
|
|
104
|
-
.setIssuedAt(iat)
|
|
105
|
-
.setExpirationTime(exp)
|
|
106
|
-
.sign(key);
|
|
107
|
-
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export async function verifyShareToken(token, secret) {
|
|
111
|
-
if (!token) return null;
|
|
112
|
-
try {
|
|
113
|
-
const { payload } = await jwtVerify(token, secretToKey(secret), {
|
|
114
|
-
issuer: SHARE_ISSUER,
|
|
115
|
-
});
|
|
116
|
-
return payload;
|
|
117
|
-
} catch {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
|
|
123
|
-
const base = shareBaseUrl.replace(/\/$/, '');
|
|
124
|
-
return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Token registry so the partner can list + revoke. The revocation set (and the
|
|
128
|
-
// active-token list) is persisted to `<dataDir>/revoked.json` so a "revoked"
|
|
129
|
-
// share token stays revoked across a server restart (concern C8) — without it,
|
|
130
|
-
// the in-memory set reset on restart and a previously-revoked-but-unexpired JWT
|
|
131
|
-
// validated again by signature. No `persistPath` → in-memory only (tests).
|
|
132
|
-
export class TokenRegistry {
|
|
133
|
-
constructor({ persistPath = null } = {}) {
|
|
134
|
-
this.persistPath = persistPath;
|
|
135
|
-
this.tokens = new Map(); // sub -> { sub, role, workspaceId, exp, label, createdAt }
|
|
136
|
-
this.revoked = new Set();
|
|
137
|
-
this._load();
|
|
138
|
-
}
|
|
139
|
-
_load() {
|
|
140
|
-
if (!this.persistPath) return;
|
|
141
|
-
try {
|
|
142
|
-
const data = JSON.parse(readFileSync(this.persistPath, 'utf8'));
|
|
143
|
-
if (Array.isArray(data.revoked)) this.revoked = new Set(data.revoked);
|
|
144
|
-
if (Array.isArray(data.tokens)) {
|
|
145
|
-
for (const t of data.tokens) if (t?.sub) this.tokens.set(t.sub, t);
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
/* missing / corrupt — start empty */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
_persist() {
|
|
152
|
-
if (!this.persistPath) return;
|
|
153
|
-
try {
|
|
154
|
-
const now = Math.floor(Date.now() / 1000);
|
|
155
|
-
// Don't carry expired tokens forward; the revoked set stays (small).
|
|
156
|
-
const tokens = [...this.tokens.values()].filter((r) => r.exp > now);
|
|
157
|
-
writeFileSync(
|
|
158
|
-
this.persistPath,
|
|
159
|
-
JSON.stringify({ revoked: [...this.revoked], tokens }, null, 2),
|
|
160
|
-
{ mode: 0o600 },
|
|
161
|
-
);
|
|
162
|
-
} catch {
|
|
163
|
-
/* read-only fs — revocation degrades to in-memory for this run */
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
add(record) {
|
|
167
|
-
this.tokens.set(record.sub, record);
|
|
168
|
-
this._persist();
|
|
169
|
-
}
|
|
170
|
-
list() {
|
|
171
|
-
const now = Math.floor(Date.now() / 1000);
|
|
172
|
-
return [...this.tokens.values()].filter((r) => r.exp > now && !this.revoked.has(r.sub));
|
|
173
|
-
}
|
|
174
|
-
revoke(sub) {
|
|
175
|
-
this.revoked.add(sub);
|
|
176
|
-
this.tokens.delete(sub);
|
|
177
|
-
this._persist();
|
|
178
|
-
}
|
|
179
|
-
isRevoked(sub) {
|
|
180
|
-
return this.revoked.has(sub);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
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
|
+
// Mint a one-time-ish BOOTSTRAP token for the first-device sign-in on the public
|
|
77
|
+
// URL (B1). The local launcher fetches this over genuine loopback and opens
|
|
78
|
+
// <slug>.venturewild.llc/?t=<token>; the SPA exchanges it for a DURABLE device
|
|
79
|
+
// cookie (see /api/auth/exchange) and strips it from the address bar. It is
|
|
80
|
+
// partner-role but deliberately SHORT-LIVED (5 min default) so its only window
|
|
81
|
+
// of exposure — one navigation through the VW tunnel — is tightly bounded; the
|
|
82
|
+
// long-lived credential the browser keeps is the re-minted device token, never
|
|
83
|
+
// this. Carries `boot:true` so the exchange path can recognise + upgrade it.
|
|
84
|
+
export async function mintBootstrapToken({
|
|
85
|
+
secret,
|
|
86
|
+
workspaceId,
|
|
87
|
+
ttlSeconds = 5 * 60, // 5 minutes — bounds the token-in-URL window
|
|
88
|
+
audience = 'wild-workspace-bootstrap',
|
|
89
|
+
subject = `bootstrap-${nanoid(12)}`,
|
|
90
|
+
}) {
|
|
91
|
+
const key = secretToKey(secret);
|
|
92
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
93
|
+
const exp = iat + ttlSeconds;
|
|
94
|
+
const jwt = await new SignJWT({
|
|
95
|
+
role: 'partner',
|
|
96
|
+
workspaceId,
|
|
97
|
+
boot: true,
|
|
98
|
+
jti: nanoid(16),
|
|
99
|
+
})
|
|
100
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
101
|
+
.setIssuer(SHARE_ISSUER)
|
|
102
|
+
.setAudience(audience)
|
|
103
|
+
.setSubject(subject)
|
|
104
|
+
.setIssuedAt(iat)
|
|
105
|
+
.setExpirationTime(exp)
|
|
106
|
+
.sign(key);
|
|
107
|
+
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function verifyShareToken(token, secret) {
|
|
111
|
+
if (!token) return null;
|
|
112
|
+
try {
|
|
113
|
+
const { payload } = await jwtVerify(token, secretToKey(secret), {
|
|
114
|
+
issuer: SHARE_ISSUER,
|
|
115
|
+
});
|
|
116
|
+
return payload;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
|
|
123
|
+
const base = shareBaseUrl.replace(/\/$/, '');
|
|
124
|
+
return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Token registry so the partner can list + revoke. The revocation set (and the
|
|
128
|
+
// active-token list) is persisted to `<dataDir>/revoked.json` so a "revoked"
|
|
129
|
+
// share token stays revoked across a server restart (concern C8) — without it,
|
|
130
|
+
// the in-memory set reset on restart and a previously-revoked-but-unexpired JWT
|
|
131
|
+
// validated again by signature. No `persistPath` → in-memory only (tests).
|
|
132
|
+
export class TokenRegistry {
|
|
133
|
+
constructor({ persistPath = null } = {}) {
|
|
134
|
+
this.persistPath = persistPath;
|
|
135
|
+
this.tokens = new Map(); // sub -> { sub, role, workspaceId, exp, label, createdAt }
|
|
136
|
+
this.revoked = new Set();
|
|
137
|
+
this._load();
|
|
138
|
+
}
|
|
139
|
+
_load() {
|
|
140
|
+
if (!this.persistPath) return;
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(readFileSync(this.persistPath, 'utf8'));
|
|
143
|
+
if (Array.isArray(data.revoked)) this.revoked = new Set(data.revoked);
|
|
144
|
+
if (Array.isArray(data.tokens)) {
|
|
145
|
+
for (const t of data.tokens) if (t?.sub) this.tokens.set(t.sub, t);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
/* missing / corrupt — start empty */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
_persist() {
|
|
152
|
+
if (!this.persistPath) return;
|
|
153
|
+
try {
|
|
154
|
+
const now = Math.floor(Date.now() / 1000);
|
|
155
|
+
// Don't carry expired tokens forward; the revoked set stays (small).
|
|
156
|
+
const tokens = [...this.tokens.values()].filter((r) => r.exp > now);
|
|
157
|
+
writeFileSync(
|
|
158
|
+
this.persistPath,
|
|
159
|
+
JSON.stringify({ revoked: [...this.revoked], tokens }, null, 2),
|
|
160
|
+
{ mode: 0o600 },
|
|
161
|
+
);
|
|
162
|
+
} catch {
|
|
163
|
+
/* read-only fs — revocation degrades to in-memory for this run */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
add(record) {
|
|
167
|
+
this.tokens.set(record.sub, record);
|
|
168
|
+
this._persist();
|
|
169
|
+
}
|
|
170
|
+
list() {
|
|
171
|
+
const now = Math.floor(Date.now() / 1000);
|
|
172
|
+
return [...this.tokens.values()].filter((r) => r.exp > now && !this.revoked.has(r.sub));
|
|
173
|
+
}
|
|
174
|
+
revoke(sub) {
|
|
175
|
+
this.revoked.add(sub);
|
|
176
|
+
this.tokens.delete(sub);
|
|
177
|
+
this._persist();
|
|
178
|
+
}
|
|
179
|
+
isRevoked(sub) {
|
|
180
|
+
return this.revoked.has(sub);
|
|
181
|
+
}
|
|
182
|
+
}
|