clay-server 4.0.0-beta.8 → 4.0.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/lib/capsule-display-floor.js +51 -0
- package/lib/capsule-frame-server.js +255 -0
- package/lib/capsule-mate-turn.js +105 -0
- package/lib/capsule-pig-logic.js +300 -0
- package/lib/capsule-server-runtimes.js +39 -0
- package/lib/capsules/pig/display.js +198 -0
- package/lib/capsules/pig/manifest.json +9 -0
- package/lib/capsules/pig/ui.json +84 -0
- package/lib/config.js +25 -1
- package/lib/daemon-projects.js +13 -8
- package/lib/daemon.js +127 -14
- package/lib/git-cli.js +95 -1
- package/lib/knowledge-import.js +436 -0
- package/lib/knowledge-record-store.js +188 -0
- package/lib/knowledge-search.js +240 -0
- package/lib/mate-knowledge-mcp-server.js +151 -0
- package/lib/mate-knowledge-migration.js +283 -0
- package/lib/mate-knowledge-service.js +488 -0
- package/lib/mate-knowledge-sync.js +482 -0
- package/lib/migrate-single-user.js +2 -0
- package/lib/notes-lifecycle.js +149 -0
- package/lib/notes.js +60 -3
- package/lib/os-users.js +3 -1
- package/lib/project-capsule-catalog.js +9 -1
- package/lib/project-capsule-turn.js +96 -0
- package/lib/project-connection.js +15 -1
- package/lib/project-file-history.js +223 -0
- package/lib/project-file-watch.js +77 -49
- package/lib/project-filesystem.js +31 -220
- package/lib/project-http.js +15 -1
- package/lib/project-knowledge.js +22 -0
- package/lib/project-log-context.js +172 -0
- package/lib/project-log-feedback-delivery.js +118 -0
- package/lib/project-logs-comments.js +137 -0
- package/lib/project-logs-context-state.js +42 -0
- package/lib/project-logs-mcp-server.js +328 -0
- package/lib/project-logs-query.js +185 -0
- package/lib/project-logs-root.js +75 -0
- package/lib/project-logs-schema.js +259 -0
- package/lib/project-logs-service.js +463 -0
- package/lib/project-logs-snapshot.js +205 -0
- package/lib/project-logs-store.js +495 -0
- package/lib/project-logs-versioning.js +132 -0
- package/lib/project-logs.js +400 -0
- package/lib/project-mate-interaction.js +14 -0
- package/lib/project-mate-knowledge.js +110 -0
- package/lib/project-memory.js +22 -0
- package/lib/project-message-delivery.js +70 -0
- package/lib/project-pair-lifecycle.js +460 -0
- package/lib/project-request-access.js +66 -0
- package/lib/project-session-handoff.js +1 -1
- package/lib/project-session-notes.js +64 -13
- package/lib/project-session-pair.js +159 -146
- package/lib/project-session-spawn.js +1 -1
- package/lib/project-sessions.js +58 -13
- package/lib/project-user-message.js +53 -4
- package/lib/project-worker-permission.js +430 -0
- package/lib/project-worker-proposal.js +208 -62
- package/lib/project.js +185 -6
- package/lib/public/app.js +63 -35
- package/lib/public/css/admin.css +2 -2
- package/lib/public/css/capsule-ui.css +38 -0
- package/lib/public/css/command-palette.css +19 -9
- package/lib/public/css/git-placard.css +154 -0
- package/lib/public/css/home-hub.css +4 -0
- package/lib/public/css/home-session-actions.css +66 -0
- package/lib/public/css/home-sidebar.css +66 -0
- package/lib/public/css/input.css +84 -3
- package/lib/public/css/messages.css +40 -1
- package/lib/public/css/mobile-nav.css +80 -0
- package/lib/public/css/notifications-center.css +67 -0
- package/lib/public/css/pane.css +97 -10
- package/lib/public/css/project-logs.css +198 -0
- package/lib/public/css/scheduler.css +1 -1
- package/lib/public/css/session-actions.css +16 -101
- package/lib/public/css/sidebar.css +103 -0
- package/lib/public/css/sticky-notes.css +237 -229
- package/lib/public/css/worker-proposal.css +36 -3
- package/lib/public/index.html +43 -11
- package/lib/public/modules/admin.js +8 -8
- package/lib/public/modules/app-connection.js +124 -6
- package/lib/public/modules/app-home-hub.js +12 -8
- package/lib/public/modules/app-message-cards.js +2 -1
- package/lib/public/modules/app-messages.js +92 -11
- package/lib/public/modules/app-misc.js +2 -14
- package/lib/public/modules/app-notifications.js +11 -7
- package/lib/public/modules/app-panels.js +3 -2
- package/lib/public/modules/app-projects.js +56 -18
- package/lib/public/modules/app-rendering.js +1 -0
- package/lib/public/modules/capsule-preference.js +44 -0
- package/lib/public/modules/chat-bubble-renderer.js +1 -1
- package/lib/public/modules/clay-log-links.js +70 -0
- package/lib/public/modules/command-palette.js +52 -15
- package/lib/public/modules/context-sources.js +0 -3
- package/lib/public/modules/git-agent-sessions.js +85 -0
- package/lib/public/modules/git-panel.js +111 -84
- package/lib/public/modules/git-placard.js +161 -0
- package/lib/public/modules/home-chat-empty-state.js +39 -0
- package/lib/public/modules/home-chat-identity.js +9 -0
- package/lib/public/modules/home-conversations-sheet.js +102 -48
- package/lib/public/modules/home-mate-chat.js +15 -35
- package/lib/public/modules/home-session-actions.js +6 -0
- package/lib/public/modules/home-sidebar-chat-list.js +101 -41
- package/lib/public/modules/home-sidebar.js +7 -0
- package/lib/public/modules/home-surface-boot.js +7 -3
- package/lib/public/modules/home-tool-frame.js +182 -0
- package/lib/public/modules/home-tools.js +138 -1
- package/lib/public/modules/input.js +38 -7
- package/lib/public/modules/markdown.js +2 -0
- package/lib/public/modules/mate-sidebar.js +0 -8
- package/lib/public/modules/message-delivery.js +97 -0
- package/lib/public/modules/paste-modal.js +84 -0
- package/lib/public/modules/project-activation.js +45 -0
- package/lib/public/modules/project-logs-render.js +405 -0
- package/lib/public/modules/project-logs.js +461 -0
- package/lib/public/modules/project-removal-target.js +26 -0
- package/lib/public/modules/scheduler.js +17 -3
- package/lib/public/modules/session-actions.js +29 -105
- package/lib/public/modules/session-hierarchy.js +54 -0
- package/lib/public/modules/sidebar-mobile.js +40 -15
- package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
- package/lib/public/modules/sidebar-sessions.js +77 -11
- package/lib/public/modules/sidebar.js +17 -5
- package/lib/public/modules/split-group-helpers.js +8 -0
- package/lib/public/modules/split-pair-ui.js +15 -4
- package/lib/public/modules/split-view.js +5 -2
- package/lib/public/modules/split-worker-runtime.js +24 -0
- package/lib/public/modules/sticky-note-markdown.js +5 -0
- package/lib/public/modules/sticky-notes-browser.js +326 -0
- package/lib/public/modules/sticky-notes-card.js +360 -0
- package/lib/public/modules/sticky-notes-editor.js +291 -0
- package/lib/public/modules/sticky-notes-shared.js +90 -0
- package/lib/public/modules/sticky-notes.js +170 -946
- package/lib/public/modules/thinking-lifecycle.js +175 -0
- package/lib/public/modules/thinking-summary.js +28 -0
- package/lib/public/modules/thinking-view.js +75 -0
- package/lib/public/modules/tool-palette-order.js +143 -0
- package/lib/public/modules/tool-palette-overlays.js +159 -0
- package/lib/public/modules/tool-palette.js +21 -188
- package/lib/public/modules/tools.js +31 -123
- package/lib/public/modules/update-snooze.js +169 -0
- package/lib/public/modules/user-settings.js +4 -0
- package/lib/public/modules/worker-pane-lock.js +176 -0
- package/lib/public/modules/worker-proposal-state.js +16 -0
- package/lib/public/modules/worker-proposal.js +49 -20
- package/lib/public/style.css +3 -1
- package/lib/sdk-bridge.js +135 -46
- package/lib/sdk-message-processor.js +36 -19
- package/lib/server-experimental-settings.js +49 -0
- package/lib/server-global-ws.js +11 -0
- package/lib/server-home-chat-events.js +31 -1
- package/lib/server-home-chat.js +27 -4
- package/lib/server-home-clay-entry.js +1 -1
- package/lib/server-home-clay-session-links.js +34 -5
- package/lib/server-settings.js +5 -0
- package/lib/server-tools.js +127 -9
- package/lib/server.js +137 -22
- package/lib/session-driver-eligibility.js +38 -0
- package/lib/session-driver-orchestration.js +31 -0
- package/lib/session-hygiene.js +3 -0
- package/lib/session-notes-mcp-server.js +25 -5
- package/lib/session-pair-factory.js +247 -0
- package/lib/session-pair-mcp-server.js +46 -8
- package/lib/session-pair-prompts.js +79 -0
- package/lib/session-pair-turn-control.js +153 -0
- package/lib/session-provenance.js +119 -0
- package/lib/session-spawn-mcp-server.js +1 -1
- package/lib/session-split-groups.js +4 -1
- package/lib/session-title-policy.js +60 -0
- package/lib/session-visibility.js +41 -0
- package/lib/sessions.js +57 -10
- package/lib/tools-registry.js +89 -10
- package/lib/update-snooze.js +437 -0
- package/lib/users-experimental-preferences.js +30 -0
- package/lib/users-permissions.js +6 -6
- package/lib/users.js +22 -16
- package/lib/workspace-query-access.js +102 -0
- package/lib/workspace-query-service.js +24 -17
- package/lib/worktree.js +25 -38
- package/lib/ws-schema.js +38 -7
- package/lib/yoke/adapters/claude-worker.js +16 -6
- package/lib/yoke/adapters/claude.js +12 -0
- package/lib/yoke/adapters/codex.js +24 -14
- package/lib/yoke/vendor-registry.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// Update-notification capability and snooze policy.
|
|
2
|
+
//
|
|
3
|
+
// Two concerns that belong together because both decide whether a given
|
|
4
|
+
// connection may be told that a new version exists:
|
|
5
|
+
//
|
|
6
|
+
// 1. Capability. Update version metadata and update actions are
|
|
7
|
+
// admin-grade. In multi-user mode that means an authenticated user whose
|
|
8
|
+
// role is "admin"; a connection with no identity is refused. A
|
|
9
|
+
// single-user install has no user records at all by design, and its sole
|
|
10
|
+
// local operator is the owner, so it is admin-grade. This is the one
|
|
11
|
+
// canonical check; do not re-derive it anywhere else.
|
|
12
|
+
//
|
|
13
|
+
// 2. Snooze. A per-user, per-version deadline stored on the server so it
|
|
14
|
+
// survives reloads and applies on every device. Snoozing 4.1.0 says
|
|
15
|
+
// nothing about 4.2.0, so a newer release notifies immediately.
|
|
16
|
+
//
|
|
17
|
+
// The store is a small JSON file under CONFIG_DIR rather than the users
|
|
18
|
+
// record, because a single-user install has no users record to write to and
|
|
19
|
+
// the same policy has to work in both modes.
|
|
20
|
+
|
|
21
|
+
var crypto = require("crypto");
|
|
22
|
+
var fs = require("fs");
|
|
23
|
+
var path = require("path");
|
|
24
|
+
var config = require("./config");
|
|
25
|
+
|
|
26
|
+
var STORE_VERSION = 1;
|
|
27
|
+
var STORE_FILE = "update-snooze.json";
|
|
28
|
+
|
|
29
|
+
// Requested durations are chosen from this allowlist by key. A duration is
|
|
30
|
+
// never read as a number from the payload, and a deadline is never read from
|
|
31
|
+
// the payload at all: the server computes every instant itself.
|
|
32
|
+
var SNOOZE_OPTIONS = {
|
|
33
|
+
"3h": { label: "3 hours", ms: 3 * 60 * 60 * 1000 },
|
|
34
|
+
"8h": { label: "8 hours", ms: 8 * 60 * 60 * 1000 },
|
|
35
|
+
"tomorrow": { label: "Tomorrow", tomorrow: true },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// "Tomorrow" means the next calendar day at 09:00 local time, so at 23:00 it
|
|
39
|
+
// is 10 hours away and at 00:30 it is 32.5 hours away. That upper bound plus
|
|
40
|
+
// headroom is the hard server-side maximum: whatever timezone a client claims,
|
|
41
|
+
// the stored deadline is clamped to it.
|
|
42
|
+
var TOMORROW_HOUR = 9;
|
|
43
|
+
var MAX_SNOOZE_MS = 36 * 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// A UTC offset outside real-world bounds is refused rather than clamped, so a
|
|
46
|
+
// malformed hint falls back to server local time instead of silently skewing.
|
|
47
|
+
var MIN_TZ_OFFSET_MINUTES = -14 * 60;
|
|
48
|
+
var MAX_TZ_OFFSET_MINUTES = 14 * 60;
|
|
49
|
+
|
|
50
|
+
// The key a single-user install stores its snooze under. It is a server-side
|
|
51
|
+
// constant, never a value a client can supply.
|
|
52
|
+
var LOCAL_USER_KEY = "__local__";
|
|
53
|
+
|
|
54
|
+
function storePath() {
|
|
55
|
+
return path.join(config.CONFIG_DIR, STORE_FILE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyStore() {
|
|
59
|
+
return { version: STORE_VERSION, users: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadStore() {
|
|
63
|
+
var raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = fs.readFileSync(storePath(), "utf8");
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return emptyStore();
|
|
68
|
+
}
|
|
69
|
+
var parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(raw);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// A torn or hand-edited file must not break update notifications; it just
|
|
74
|
+
// means nothing is snoozed.
|
|
75
|
+
return emptyStore();
|
|
76
|
+
}
|
|
77
|
+
if (!parsed || typeof parsed !== "object" || !parsed.users || typeof parsed.users !== "object") {
|
|
78
|
+
return emptyStore();
|
|
79
|
+
}
|
|
80
|
+
return { version: STORE_VERSION, users: parsed.users };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A per-writer temp name. A shared ".tmp" would let two concurrent writers
|
|
84
|
+
// scribble over each other's half-written file and then rename it into place,
|
|
85
|
+
// publishing a mix of both.
|
|
86
|
+
var tmpCounter = 0;
|
|
87
|
+
|
|
88
|
+
function saveStore(store) {
|
|
89
|
+
try {
|
|
90
|
+
config.ensureConfigDir();
|
|
91
|
+
} catch (e) {}
|
|
92
|
+
var target = storePath();
|
|
93
|
+
tmpCounter++;
|
|
94
|
+
var tmp = target + "." + process.pid + "-" + tmpCounter + ".tmp";
|
|
95
|
+
try {
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify(store), { mode: 0o600 });
|
|
97
|
+
fs.renameSync(tmp, target);
|
|
98
|
+
return true;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
try { fs.unlinkSync(tmp); } catch (e2) {}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Write serialization --------------------------------------------------
|
|
106
|
+
//
|
|
107
|
+
// One user's snooze must never erase another's. Node's synchronous I/O already
|
|
108
|
+
// serializes writers inside a process, but two daemons can share a CLAY_HOME,
|
|
109
|
+
// so read-modify-write is done under an exclusive lock file and the store is
|
|
110
|
+
// re-read inside that lock. That makes each mutation a merge into whatever is
|
|
111
|
+
// currently on disk rather than an overwrite with a stale snapshot.
|
|
112
|
+
//
|
|
113
|
+
// Follows the `fs.openSync(path, "wx")` convention already used for the Mate
|
|
114
|
+
// knowledge migration lock, with a short bounded wait because these writes are
|
|
115
|
+
// tiny and rare (a person clicking Snooze).
|
|
116
|
+
//
|
|
117
|
+
// A live holder always wins, however old its lock is: a daemon paused under a
|
|
118
|
+
// debugger is still holding the store, and stealing from it would let two
|
|
119
|
+
// writers merge against the same snapshot. Age alone can only reclaim a lock
|
|
120
|
+
// whose holder is confirmed dead or cannot be identified at all.
|
|
121
|
+
|
|
122
|
+
var LOCK_RETRIES = 25;
|
|
123
|
+
var LOCK_WAIT_MS = 2;
|
|
124
|
+
var LOCK_STALE_MS = 5000;
|
|
125
|
+
|
|
126
|
+
function lockPath() {
|
|
127
|
+
return storePath() + ".lock";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sleepBriefly(ms) {
|
|
131
|
+
try {
|
|
132
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// Without SharedArrayBuffer the retry loop simply spins; it is bounded.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Delegates to the shared helper, whose contract is exactly what this lock
|
|
139
|
+
// needs: a positive-integer pid, EPERM counted as alive (a second daemon run
|
|
140
|
+
// by another user still holds the store), ESRCH as the only dead, and any
|
|
141
|
+
// unanswerable error as alive. Erring towards "alive" is the safe direction
|
|
142
|
+
// here too: the worst case is refusing to write, never stealing a live lock.
|
|
143
|
+
function isHolderAlive(pid) {
|
|
144
|
+
return config.isPidAlive(pid);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readHolder(target) {
|
|
148
|
+
var raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = fs.readFileSync(target, "utf8");
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
var parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(raw);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return { pid: null, nonce: null, startedAt: 0 };
|
|
159
|
+
}
|
|
160
|
+
if (!parsed || typeof parsed !== "object") return { pid: null, nonce: null, startedAt: 0 };
|
|
161
|
+
return {
|
|
162
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : null,
|
|
163
|
+
nonce: typeof parsed.nonce === "string" ? parsed.nonce : null,
|
|
164
|
+
startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The nonce is what makes release safe. A pid alone cannot distinguish our own
|
|
169
|
+
// lock from a successor's lock written by a recycled pid, so every acquisition
|
|
170
|
+
// stamps a fresh random nonce and release only unlinks a file that still
|
|
171
|
+
// carries it.
|
|
172
|
+
function writeLock(target) {
|
|
173
|
+
var nonce = crypto.randomBytes(12).toString("hex");
|
|
174
|
+
var payload = JSON.stringify({ pid: process.pid, nonce: nonce, startedAt: Date.now() }) + "\n";
|
|
175
|
+
var fd = fs.openSync(target, "wx");
|
|
176
|
+
try {
|
|
177
|
+
fs.writeSync(fd, Buffer.from(payload, "utf8"));
|
|
178
|
+
} finally {
|
|
179
|
+
fs.closeSync(fd);
|
|
180
|
+
}
|
|
181
|
+
return { path: target, pid: process.pid, nonce: nonce };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function acquireLock() {
|
|
185
|
+
var target = lockPath();
|
|
186
|
+
for (var attempt = 0; attempt < LOCK_RETRIES; attempt++) {
|
|
187
|
+
try {
|
|
188
|
+
return writeLock(target);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (!e || e.code !== "EEXIST") return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
var holder = readHolder(target);
|
|
194
|
+
if (!holder) continue; // vanished between open and read: try again
|
|
195
|
+
|
|
196
|
+
if (holder.pid !== null) {
|
|
197
|
+
// An identifiable holder is only ever reclaimed once it is confirmed
|
|
198
|
+
// dead. Age is irrelevant. A recycled pid can therefore only make us
|
|
199
|
+
// wait and ultimately refuse the write, never double-enter.
|
|
200
|
+
if (isHolderAlive(holder.pid)) {
|
|
201
|
+
sleepBriefly(LOCK_WAIT_MS);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
// No identifiable holder: reclaim only once unambiguously abandoned.
|
|
206
|
+
// The recorded stamp is preferred over mtime, which anything can touch;
|
|
207
|
+
// an unparseable lock has no stamp, so mtime is the only clock left.
|
|
208
|
+
var stamp = holder.startedAt;
|
|
209
|
+
if (!stamp) {
|
|
210
|
+
try { stamp = fs.statSync(target).mtimeMs; } catch (e2) { stamp = 0; }
|
|
211
|
+
}
|
|
212
|
+
var age = Date.now() - stamp;
|
|
213
|
+
// A future stamp (clock skew, or a hostile value) must not wedge the
|
|
214
|
+
// lock forever; it simply starts ageing from now.
|
|
215
|
+
if (age < 0) age = 0;
|
|
216
|
+
if (age < LOCK_STALE_MS) {
|
|
217
|
+
sleepBriefly(LOCK_WAIT_MS);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Reclaim, then loop so the exclusive create decides the winner rather
|
|
223
|
+
// than this process assuming it won the race.
|
|
224
|
+
try { fs.unlinkSync(target); } catch (e3) {}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Only unlink a lock this call still owns. If the file now carries a different
|
|
230
|
+
// pid or nonce, ownership changed while we held it and the file belongs to a
|
|
231
|
+
// successor, so removing it would release someone else's lock.
|
|
232
|
+
function releaseLock(token) {
|
|
233
|
+
if (!token || !token.path) return;
|
|
234
|
+
var holder = readHolder(token.path);
|
|
235
|
+
if (!holder) return;
|
|
236
|
+
if (holder.pid !== token.pid || holder.nonce !== token.nonce) return;
|
|
237
|
+
try { fs.unlinkSync(token.path); } catch (e) {}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Apply `mutator` to the store under the lock. The mutator receives the store
|
|
241
|
+
// as it exists on disk right now and mutates it in place; returning false means
|
|
242
|
+
// "nothing changed", so no write happens.
|
|
243
|
+
function mutate(mutator) {
|
|
244
|
+
try {
|
|
245
|
+
config.ensureConfigDir();
|
|
246
|
+
} catch (e) {}
|
|
247
|
+
var lock = acquireLock();
|
|
248
|
+
if (!lock) return false;
|
|
249
|
+
try {
|
|
250
|
+
var store = loadStore();
|
|
251
|
+
if (mutator(store) === false) return true;
|
|
252
|
+
return saveStore(store);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return false;
|
|
255
|
+
} finally {
|
|
256
|
+
releaseLock(lock);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Capability -----------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
// The canonical admin-grade check for everything update-related: delivery of
|
|
263
|
+
// version metadata, the snooze action, manual checks, channel changes, and
|
|
264
|
+
// installing.
|
|
265
|
+
//
|
|
266
|
+
// `conn._clayUser` is a snapshot taken when the socket was opened and can be
|
|
267
|
+
// arbitrarily stale: a user demoted or deleted mid-session still carries
|
|
268
|
+
// role "admin" on their live connection. So the snapshot is only used for its
|
|
269
|
+
// id, and the role is re-resolved from the authoritative users store on every
|
|
270
|
+
// delivery and every action. Both must independently say "admin"; a missing
|
|
271
|
+
// record, a throwing store, or any disagreement between the two is a denial.
|
|
272
|
+
function isUpdateAdmin(conn, usersModule) {
|
|
273
|
+
if (!conn) return false;
|
|
274
|
+
var multiUser = false;
|
|
275
|
+
try {
|
|
276
|
+
multiUser = !!(usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser());
|
|
277
|
+
} catch (e) {
|
|
278
|
+
// An unreadable users record must not be treated as "no restrictions".
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (!multiUser) return true;
|
|
282
|
+
|
|
283
|
+
var user = conn._clayUser;
|
|
284
|
+
if (!user || !user.id || user.role !== "admin") return false;
|
|
285
|
+
if (!usersModule || typeof usersModule.findUserById !== "function") return false;
|
|
286
|
+
|
|
287
|
+
var live;
|
|
288
|
+
try {
|
|
289
|
+
live = usersModule.findUserById(user.id);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
// Deleted between connect and now, or demoted since.
|
|
294
|
+
if (!live || live.role !== "admin") return false;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The storage key for a connection. Returns null when multi-user mode cannot
|
|
299
|
+
// attribute the connection, so callers persist nothing rather than guessing.
|
|
300
|
+
// Identity is always read from the connection, never from a message payload.
|
|
301
|
+
function userKeyFor(conn, usersModule) {
|
|
302
|
+
if (!isUpdateAdmin(conn, usersModule)) return null;
|
|
303
|
+
var multiUser = false;
|
|
304
|
+
try {
|
|
305
|
+
multiUser = !!(usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser());
|
|
306
|
+
} catch (e) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (!multiUser) return LOCAL_USER_KEY;
|
|
310
|
+
var user = conn._clayUser;
|
|
311
|
+
if (!user || !user.id) return null;
|
|
312
|
+
return String(user.id);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- Deadlines ------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
function isValidTzOffset(value) {
|
|
318
|
+
return typeof value === "number" && isFinite(value) &&
|
|
319
|
+
Math.floor(value) === value &&
|
|
320
|
+
value >= MIN_TZ_OFFSET_MINUTES && value <= MAX_TZ_OFFSET_MINUTES;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Next calendar day at TOMORROW_HOUR, in the zone described by a UTC offset in
|
|
324
|
+
// minutes east of UTC, expressed as a UTC instant. With no usable offset the
|
|
325
|
+
// server's own local time is used, which is the safe default because it is the
|
|
326
|
+
// only clock the server can vouch for.
|
|
327
|
+
function nextLocalMorning(nowMs, tzOffsetMinutes) {
|
|
328
|
+
if (!isValidTzOffset(tzOffsetMinutes)) {
|
|
329
|
+
var local = new Date(nowMs);
|
|
330
|
+
var serverTarget = new Date(local.getFullYear(), local.getMonth(), local.getDate() + 1, TOMORROW_HOUR, 0, 0, 0);
|
|
331
|
+
return serverTarget.getTime();
|
|
332
|
+
}
|
|
333
|
+
var offsetMs = tzOffsetMinutes * 60 * 1000;
|
|
334
|
+
// Shift into the target zone, take the calendar date there, then shift back.
|
|
335
|
+
var shifted = new Date(nowMs + offsetMs);
|
|
336
|
+
var midnightNextDayUtc = Date.UTC(
|
|
337
|
+
shifted.getUTCFullYear(),
|
|
338
|
+
shifted.getUTCMonth(),
|
|
339
|
+
shifted.getUTCDate() + 1,
|
|
340
|
+
TOMORROW_HOUR, 0, 0, 0
|
|
341
|
+
);
|
|
342
|
+
return midnightNextDayUtc - offsetMs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Resolve an allowlisted duration key to a UTC deadline, clamped to the
|
|
346
|
+
// server-side maximum. Returns null for anything not on the allowlist.
|
|
347
|
+
function resolveDeadline(durationKey, nowMs, tzOffsetMinutes) {
|
|
348
|
+
if (typeof durationKey !== "string") return null;
|
|
349
|
+
var option = Object.prototype.hasOwnProperty.call(SNOOZE_OPTIONS, durationKey)
|
|
350
|
+
? SNOOZE_OPTIONS[durationKey]
|
|
351
|
+
: null;
|
|
352
|
+
if (!option) return null;
|
|
353
|
+
var until = option.tomorrow
|
|
354
|
+
? nextLocalMorning(nowMs, tzOffsetMinutes)
|
|
355
|
+
: nowMs + option.ms;
|
|
356
|
+
if (!isFinite(until)) return null;
|
|
357
|
+
if (until <= nowMs) return null;
|
|
358
|
+
if (until > nowMs + MAX_SNOOZE_MS) until = nowMs + MAX_SNOOZE_MS;
|
|
359
|
+
return until;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Store operations ----------------------------------------------------
|
|
363
|
+
|
|
364
|
+
function readRecord(userKey) {
|
|
365
|
+
if (!userKey) return null;
|
|
366
|
+
var store = loadStore();
|
|
367
|
+
var record = store.users[userKey];
|
|
368
|
+
if (!record || typeof record !== "object") return null;
|
|
369
|
+
var until = typeof record.until === "number" && isFinite(record.until) ? record.until : 0;
|
|
370
|
+
var version = typeof record.version === "string" ? record.version : "";
|
|
371
|
+
if (!until || !version) return null;
|
|
372
|
+
return { version: version, until: until };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Touches exactly one user's entry and leaves every other entry as found on
|
|
376
|
+
// disk, so two users snoozing at the same moment both survive.
|
|
377
|
+
function writeRecord(userKey, record) {
|
|
378
|
+
if (!userKey) return false;
|
|
379
|
+
return mutate(function (store) {
|
|
380
|
+
if (record) store.users[userKey] = { version: record.version, until: record.until };
|
|
381
|
+
else if (Object.prototype.hasOwnProperty.call(store.users, userKey)) delete store.users[userKey];
|
|
382
|
+
else return false;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Whether this user is currently snoozing exactly this version. A snooze for a
|
|
387
|
+
// different version never suppresses a newer one, and an expired snooze never
|
|
388
|
+
// suppresses anything.
|
|
389
|
+
function isSnoozed(userKey, version, nowMs) {
|
|
390
|
+
if (!userKey || !version) return false;
|
|
391
|
+
var record = readRecord(userKey);
|
|
392
|
+
if (!record) return false;
|
|
393
|
+
if (record.version !== version) return false;
|
|
394
|
+
return record.until > nowMs;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Record a snooze. `version` is the server's authoritative latest version; a
|
|
398
|
+
// version named in a client payload is never used.
|
|
399
|
+
function snooze(userKey, version, durationKey, nowMs, tzOffsetMinutes) {
|
|
400
|
+
if (!userKey) return { ok: false, error: "no_identity" };
|
|
401
|
+
if (typeof version !== "string" || !version) return { ok: false, error: "no_update" };
|
|
402
|
+
var until = resolveDeadline(durationKey, nowMs, tzOffsetMinutes);
|
|
403
|
+
if (!until) return { ok: false, error: "invalid_duration" };
|
|
404
|
+
if (!writeRecord(userKey, { version: version, until: until })) {
|
|
405
|
+
return { ok: false, error: "not_persisted" };
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, version: version, until: until, duration: durationKey };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Drop any snooze for this user. Used when the user re-engages deliberately:
|
|
411
|
+
// a manual update check, or installing.
|
|
412
|
+
function clearSnooze(userKey) {
|
|
413
|
+
if (!userKey) return false;
|
|
414
|
+
// Delegates straight to the locked mutation rather than checking first and
|
|
415
|
+
// writing after; the check-then-write pair was itself a race.
|
|
416
|
+
return writeRecord(userKey, null);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
module.exports = {
|
|
420
|
+
LOCAL_USER_KEY: LOCAL_USER_KEY,
|
|
421
|
+
LOCK_STALE_MS: LOCK_STALE_MS,
|
|
422
|
+
acquireLock: acquireLock,
|
|
423
|
+
isHolderAlive: isHolderAlive,
|
|
424
|
+
lockPath: lockPath,
|
|
425
|
+
releaseLock: releaseLock,
|
|
426
|
+
MAX_SNOOZE_MS: MAX_SNOOZE_MS,
|
|
427
|
+
SNOOZE_OPTIONS: SNOOZE_OPTIONS,
|
|
428
|
+
TOMORROW_HOUR: TOMORROW_HOUR,
|
|
429
|
+
clearSnooze: clearSnooze,
|
|
430
|
+
isSnoozed: isSnoozed,
|
|
431
|
+
isUpdateAdmin: isUpdateAdmin,
|
|
432
|
+
readRecord: readRecord,
|
|
433
|
+
resolveDeadline: resolveDeadline,
|
|
434
|
+
snooze: snooze,
|
|
435
|
+
storePath: storePath,
|
|
436
|
+
userKeyFor: userKeyFor,
|
|
437
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function attachExperimentalPreferences(deps) {
|
|
2
|
+
var loadUsers = deps.loadUsers;
|
|
3
|
+
var saveUsers = deps.saveUsers;
|
|
4
|
+
|
|
5
|
+
function getCapsulesEnabled(userId) {
|
|
6
|
+
var data = loadUsers();
|
|
7
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
8
|
+
if (data.users[i].id === userId) return data.users[i].capsulesEnabled === true;
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setCapsulesEnabled(userId, enabled) {
|
|
14
|
+
var data = loadUsers();
|
|
15
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
16
|
+
if (data.users[i].id !== userId) continue;
|
|
17
|
+
data.users[i].capsulesEnabled = enabled === true;
|
|
18
|
+
saveUsers(data);
|
|
19
|
+
return { ok: true, capsulesEnabled: data.users[i].capsulesEnabled };
|
|
20
|
+
}
|
|
21
|
+
return { error: "User not found" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
getCapsulesEnabled: getCapsulesEnabled,
|
|
26
|
+
setCapsulesEnabled: setCapsulesEnabled,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { attachExperimentalPreferences: attachExperimentalPreferences };
|
package/lib/users-permissions.js
CHANGED
|
@@ -89,15 +89,15 @@ function attachPermissions(deps) {
|
|
|
89
89
|
function canAccessSession(userId, session, project) {
|
|
90
90
|
// Must have project access first
|
|
91
91
|
if (!canAccessProject(userId, project)) return false;
|
|
92
|
+
// Resolve the stored user record so client-supplied roles cannot elevate access.
|
|
93
|
+
var user = findUserById(userId);
|
|
94
|
+
if (user && user.role === "admin") return true;
|
|
92
95
|
// Sessions without ownerId are legacy -- only admin can see them
|
|
93
|
-
if (!session.ownerId)
|
|
94
|
-
var user = findUserById(userId);
|
|
95
|
-
return !!(user && user.role === "admin");
|
|
96
|
-
}
|
|
96
|
+
if (!session.ownerId) return false;
|
|
97
97
|
// Owner can always see their own sessions
|
|
98
98
|
if (session.ownerId === userId) return true;
|
|
99
|
-
//
|
|
100
|
-
if (
|
|
99
|
+
// Sharing is opt-in; missing or invalid visibility stays private.
|
|
100
|
+
if (session.sessionVisibility === "shared") return true;
|
|
101
101
|
// Private sessions are only visible to the owner
|
|
102
102
|
return false;
|
|
103
103
|
}
|
package/lib/users.js
CHANGED
|
@@ -8,6 +8,7 @@ var { DEFAULT_PERMISSIONS, ALL_PERMISSIONS, attachPermissions } = require("./use
|
|
|
8
8
|
var { attachPreferences } = require("./users-preferences");
|
|
9
9
|
var { attachHomeDockPreferences } = require("./users-home-dock-preferences");
|
|
10
10
|
var { attachHomeSurfacePreferences } = require("./users-home-surface-preferences");
|
|
11
|
+
var { attachExperimentalPreferences } = require("./users-experimental-preferences");
|
|
11
12
|
|
|
12
13
|
var USERS_FILE = path.join(CONFIG_DIR, "users.json");
|
|
13
14
|
|
|
@@ -54,6 +55,16 @@ function generateUserId() {
|
|
|
54
55
|
return crypto.randomUUID();
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function ensureUserBuiltinMates(userId) {
|
|
59
|
+
try {
|
|
60
|
+
var mates = require("./mates");
|
|
61
|
+
var mateCtx = mates.buildMateCtx(userId);
|
|
62
|
+
mates.ensureBuiltinMates(mateCtx);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error("[users] Failed to seed built-in mates for user " + userId + ":", e.message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
function createUser(opts) {
|
|
58
69
|
var data = loadUsers();
|
|
59
70
|
// Check username uniqueness
|
|
@@ -91,14 +102,9 @@ function createUser(opts) {
|
|
|
91
102
|
data.users.push(user);
|
|
92
103
|
saveUsers(data);
|
|
93
104
|
|
|
94
|
-
// Seed
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
var mateCtx = mates.buildMateCtx(user.id);
|
|
98
|
-
mates.ensureBuiltinMates(mateCtx);
|
|
99
|
-
} catch (e) {
|
|
100
|
-
console.error("[users] Failed to seed built-in mates for user " + user.id + ":", e.message);
|
|
101
|
-
}
|
|
105
|
+
// Seed immediately for ordinary multi-user storage. OS-user provisioning
|
|
106
|
+
// calls this again after the Linux mapping exists, targeting its final home.
|
|
107
|
+
ensureUserBuiltinMates(user.id);
|
|
102
108
|
|
|
103
109
|
return { ok: true, user: user };
|
|
104
110
|
}
|
|
@@ -245,6 +251,7 @@ function updateLinuxUser(userId, linuxUsername) {
|
|
|
245
251
|
if (data.users[i].id === userId) {
|
|
246
252
|
data.users[i].linuxUser = null;
|
|
247
253
|
saveUsers(data);
|
|
254
|
+
ensureUserBuiltinMates(userId);
|
|
248
255
|
return { ok: true };
|
|
249
256
|
}
|
|
250
257
|
}
|
|
@@ -268,6 +275,7 @@ function updateLinuxUser(userId, linuxUsername) {
|
|
|
268
275
|
if (data.users[i].id === userId) {
|
|
269
276
|
data.users[i].linuxUser = linuxUsername;
|
|
270
277
|
saveUsers(data);
|
|
278
|
+
ensureUserBuiltinMates(userId);
|
|
271
279
|
return { ok: true };
|
|
272
280
|
}
|
|
273
281
|
}
|
|
@@ -325,14 +333,7 @@ function createUserWithoutPin(opts) {
|
|
|
325
333
|
data.users.push(user);
|
|
326
334
|
saveUsers(data);
|
|
327
335
|
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
var mates = require("./mates");
|
|
331
|
-
var mateCtx = mates.buildMateCtx(user.id);
|
|
332
|
-
mates.ensureBuiltinMates(mateCtx);
|
|
333
|
-
} catch (e) {
|
|
334
|
-
console.error("[users] Failed to seed built-in mates for user " + user.id + ":", e.message);
|
|
335
|
-
}
|
|
336
|
+
ensureUserBuiltinMates(user.id);
|
|
336
337
|
|
|
337
338
|
return { ok: true, user: user };
|
|
338
339
|
}
|
|
@@ -398,6 +399,7 @@ var permissions = attachPermissions({ loadUsers: loadUsers, saveUsers: saveUsers
|
|
|
398
399
|
var preferences = attachPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
399
400
|
var homeDockPreferences = attachHomeDockPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
400
401
|
var homeSurfacePreferences = attachHomeSurfacePreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
402
|
+
var experimentalPreferences = attachExperimentalPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
401
403
|
|
|
402
404
|
// Alias auth functions
|
|
403
405
|
var isMultiUser = auth.isMultiUser;
|
|
@@ -446,6 +448,8 @@ var markWhatsNewSeen = preferences.markWhatsNewSeen;
|
|
|
446
448
|
var getTerminalFont = preferences.getTerminalFont;
|
|
447
449
|
var setTerminalFont = preferences.setTerminalFont;
|
|
448
450
|
var setMateOnboarded = preferences.setMateOnboarded;
|
|
451
|
+
var getCapsulesEnabled = experimentalPreferences.getCapsulesEnabled;
|
|
452
|
+
var setCapsulesEnabled = experimentalPreferences.setCapsulesEnabled;
|
|
449
453
|
var getHomeDockPreference = homeDockPreferences.getHomeDockPreference;
|
|
450
454
|
var setHomeDockPreference = homeDockPreferences.setHomeDockPreference;
|
|
451
455
|
var getHomeSurfacePreference = homeSurfacePreferences.getHomeSurfacePreference;
|
|
@@ -513,6 +517,8 @@ module.exports = {
|
|
|
513
517
|
setClaudeUserAllowList: setClaudeUserAllowList,
|
|
514
518
|
getMatesEnabled: getMatesEnabled,
|
|
515
519
|
setMatesEnabled: setMatesEnabled,
|
|
520
|
+
getCapsulesEnabled: getCapsulesEnabled,
|
|
521
|
+
setCapsulesEnabled: setCapsulesEnabled,
|
|
516
522
|
getToolPalettes: getToolPalettes,
|
|
517
523
|
setToolPalette: setToolPalette,
|
|
518
524
|
getWhatsNewSeenIds: getWhatsNewSeenIds,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Authoritative project access for workspace queries.
|
|
2
|
+
//
|
|
3
|
+
// getStatus() reports slug, path, title, and owner, but carries no visibility
|
|
4
|
+
// and no allowedUsers. Rebuilding an access record from it therefore defaults
|
|
5
|
+
// every project to public, and users-permissions treats a record with no
|
|
6
|
+
// visibility as public, which authorizes any authenticated user against any
|
|
7
|
+
// project. Access is resolved here from the record onGetProjectAccess()
|
|
8
|
+
// returns, and every failure path denies.
|
|
9
|
+
//
|
|
10
|
+
// The record is re-read on every evaluation rather than captured at bind time,
|
|
11
|
+
// so a project turning private, changing owner, or dropping a user from
|
|
12
|
+
// allowedUsers takes effect on that caller's next query.
|
|
13
|
+
|
|
14
|
+
function attachWorkspaceQueryAccess(deps) {
|
|
15
|
+
var settings = deps || {};
|
|
16
|
+
var isMultiUser = settings.isMultiUser;
|
|
17
|
+
var resolveMate = settings.resolveMate;
|
|
18
|
+
var onGetProjectAccess = settings.onGetProjectAccess;
|
|
19
|
+
var canAccessProject = settings.canAccessProject;
|
|
20
|
+
|
|
21
|
+
// The authoritative record for a project, or null. A missing slug, a missing
|
|
22
|
+
// resolver, a throwing resolver, a non-record return, and an error record all
|
|
23
|
+
// resolve to null, which denies.
|
|
24
|
+
function record(status) {
|
|
25
|
+
if (!status || typeof status.slug !== "string" || !status.slug) return null;
|
|
26
|
+
if (typeof onGetProjectAccess !== "function") return null;
|
|
27
|
+
var access;
|
|
28
|
+
try {
|
|
29
|
+
access = onGetProjectAccess(status.slug);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!access || typeof access !== "object" || Array.isArray(access)) return null;
|
|
34
|
+
if (access.error) return null;
|
|
35
|
+
return access;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The permission predicate is only ever consulted with a real record, and
|
|
39
|
+
// anything other than an explicit true denies.
|
|
40
|
+
function permitted(userId, access) {
|
|
41
|
+
if (!userId || !access) return false;
|
|
42
|
+
if (typeof canAccessProject !== "function") return false;
|
|
43
|
+
try {
|
|
44
|
+
return canAccessProject(userId, access) === true;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Single-user ownership. There are no visibility rules and no access records
|
|
51
|
+
// to consult in this mode, so the project's own owner field is authoritative.
|
|
52
|
+
// Unchanged from the behaviour this module replaced.
|
|
53
|
+
function ownsSingleUser(principal, status) {
|
|
54
|
+
if (!status.projectOwnerId) return true;
|
|
55
|
+
return !!principal.singleUserOwnerId && status.projectOwnerId === principal.singleUserOwnerId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A Mate project belongs to whoever the Mate registry says created the Mate.
|
|
59
|
+
//
|
|
60
|
+
// This is not a convenience: Mate projects are registered directly on the
|
|
61
|
+
// running server and are never written to the persisted project list, so
|
|
62
|
+
// onGetProjectAccess() has no record for them at all. The registry is the
|
|
63
|
+
// authority for Mate ownership, and resolveMate is already scoped to the
|
|
64
|
+
// asking user, so this path can only ever grant a Mate its own project.
|
|
65
|
+
function ownsMateProject(principal, status) {
|
|
66
|
+
if (!status || status.isMate !== true || !status.mateId) return false;
|
|
67
|
+
if (!principal.userId || typeof resolveMate !== "function") return false;
|
|
68
|
+
var mate;
|
|
69
|
+
try {
|
|
70
|
+
mate = resolveMate(principal.userId, status.mateId);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!mate || mate.id !== status.mateId) return false;
|
|
75
|
+
return !!mate.createdBy && mate.createdBy === principal.userId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// `accessible` decides whether the container is visible at all; `owned`
|
|
79
|
+
// drives the separate rule that an accessible container holding no session of
|
|
80
|
+
// the caller's own is omitted. Worktrees are never surfaced directly.
|
|
81
|
+
function evaluate(principal, status) {
|
|
82
|
+
var denied = { owned: false, accessible: false };
|
|
83
|
+
if (!principal || !status || status.isWorktree) return denied;
|
|
84
|
+
if (typeof isMultiUser !== "function" || !isMultiUser()) {
|
|
85
|
+
var owned = ownsSingleUser(principal, status);
|
|
86
|
+
return { owned: owned, accessible: owned };
|
|
87
|
+
}
|
|
88
|
+
if (!principal.userId) return denied;
|
|
89
|
+
if (ownsMateProject(principal, status)) return { owned: true, accessible: true };
|
|
90
|
+
// status.projectOwnerId is never trusted on its own in multi-user mode: it
|
|
91
|
+
// is a snapshot that can outlive a transfer or a revocation, so treating it
|
|
92
|
+
// as ownership would let a stale status skip the authoritative record.
|
|
93
|
+
var access = record(status);
|
|
94
|
+
if (!access) return denied;
|
|
95
|
+
if (access.ownerId && access.ownerId === principal.userId) return { owned: true, accessible: true };
|
|
96
|
+
return { owned: false, accessible: permitted(principal.userId, access) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { evaluate: evaluate, getProjectAccess: record };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { attachWorkspaceQueryAccess: attachWorkspaceQueryAccess };
|