@bakapiano/ccsm 0.22.6 → 0.22.7
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/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +279 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +154 -154
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +546 -546
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +28 -0
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +728 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +53 -53
- package/public/js/state.js +335 -335
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1820 -1807
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
package/lib/devices.js
CHANGED
|
@@ -1,229 +1,229 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Remote-device approval store. Each browser that arrives via the tunnel
|
|
4
|
-
// generates a UUID client-side (`ccsm.deviceId` in localStorage) and sends
|
|
5
|
-
// it as `X-Device-Id` on every API call. server.js' middleware feeds those
|
|
6
|
-
// arrivals through record() — known devices get their lastSeen bumped,
|
|
7
|
-
// new ones get inserted as `pending`. Until the host explicitly Approves
|
|
8
|
-
// from the Remote page, every non-loopback request returns 403 with the
|
|
9
|
-
// pending status.
|
|
10
|
-
//
|
|
11
|
-
// Stored at ~/.ccsm/devices.json:
|
|
12
|
-
// {
|
|
13
|
-
// "<uuid>": {
|
|
14
|
-
// id, status: 'pending'|'approved'|'rejected',
|
|
15
|
-
// userAgent, ip,
|
|
16
|
-
// firstSeen, lastSeen, approvedAt,
|
|
17
|
-
// label // user-set or auto-derived from UA
|
|
18
|
-
// },
|
|
19
|
-
// ...
|
|
20
|
-
// }
|
|
21
|
-
|
|
22
|
-
const { DATA_DIR } = require('./config');
|
|
23
|
-
const { createKeyedJsonStore } = require('./jsonStore');
|
|
24
|
-
const { withFileLock } = require('./atomicJson');
|
|
25
|
-
|
|
26
|
-
const store = createKeyedJsonStore({
|
|
27
|
-
dataDir: DATA_DIR,
|
|
28
|
-
filename: 'devices.json',
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
// `record()` runs on EVERY non-loopback API request. Two side effects
|
|
32
|
-
// without these guards:
|
|
33
|
-
// 1. Concurrent calls each do load→mutate→save independently; the
|
|
34
|
-
// parallel rename(tmp → target) collides on Windows and surfaces
|
|
35
|
-
// as `EPERM: operation not permitted`.
|
|
36
|
-
// 2. Even when serialized, writing on every request hammers the disk
|
|
37
|
-
// for a value that only needs ~minute-grained accuracy (lastSeen
|
|
38
|
-
// drives "seen 5m ago" labels in the UI).
|
|
39
|
-
// Fix: serialize all mutators through the shared per-file lock, and
|
|
40
|
-
// short-circuit lastSeen-only updates that landed within MIN_FLUSH_MS
|
|
41
|
-
// of the last persisted write for the same id.
|
|
42
|
-
const MIN_FLUSH_MS = 15_000;
|
|
43
|
-
const lastFlushAt = new Map(); // id → ms timestamp of last save
|
|
44
|
-
|
|
45
|
-
// Pending entries older than 24h are auto-pruned on each list() so a
|
|
46
|
-
// drive-by scanner doesn't grow the file forever. Rejected entries kept
|
|
47
|
-
// 1h so the host can see what got bounced and rename / un-reject if
|
|
48
|
-
// they realize it was legit.
|
|
49
|
-
const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
50
|
-
const REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
51
|
-
|
|
52
|
-
// Quick UA → human-readable label. We keep this tiny on purpose — full
|
|
53
|
-
// UA parsing libraries are huge and the only consumer is one line in
|
|
54
|
-
// the approval UI. Order matters: Edge UA includes "Chrome" so detect
|
|
55
|
-
// Edge first.
|
|
56
|
-
function describeUA(ua) {
|
|
57
|
-
ua = String(ua || '');
|
|
58
|
-
const device =
|
|
59
|
-
/iPhone/.test(ua) ? 'iPhone'
|
|
60
|
-
: /iPad/.test(ua) ? 'iPad'
|
|
61
|
-
: /Android/.test(ua) ? 'Android'
|
|
62
|
-
: /Mac OS X/.test(ua) ? 'Mac'
|
|
63
|
-
: /Windows/.test(ua) ? 'Windows'
|
|
64
|
-
: /Linux/.test(ua) ? 'Linux'
|
|
65
|
-
: null;
|
|
66
|
-
const browser =
|
|
67
|
-
/Edg\//.test(ua) ? 'Edge'
|
|
68
|
-
: /OPR\//.test(ua) ? 'Opera'
|
|
69
|
-
: /Chrome\//.test(ua) ? 'Chrome'
|
|
70
|
-
: /Firefox\//.test(ua) ? 'Firefox'
|
|
71
|
-
: /Safari\//.test(ua) ? 'Safari'
|
|
72
|
-
: null;
|
|
73
|
-
if (device && browser) return `${device} · ${browser}`;
|
|
74
|
-
if (device) return device;
|
|
75
|
-
if (browser) return browser;
|
|
76
|
-
return 'Unknown device';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function pruneStale(map) {
|
|
80
|
-
const now = Date.now();
|
|
81
|
-
let dirty = false;
|
|
82
|
-
for (const [id, d] of Object.entries(map)) {
|
|
83
|
-
if (d.status === 'pending' && now - d.firstSeen > PENDING_TTL_MS) { delete map[id]; dirty = true; }
|
|
84
|
-
if (d.status === 'rejected' && now - (d.rejectedAt || d.lastSeen) > REJECTED_TTL_MS) { delete map[id]; dirty = true; }
|
|
85
|
-
}
|
|
86
|
-
if (dirty) await store.save(map);
|
|
87
|
-
return map;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Upsert. Returns the (possibly newly-created) device record. Caller
|
|
91
|
-
// uses .status to decide whether to gate further work.
|
|
92
|
-
async function record(id, { userAgent, ip, code } = {}) {
|
|
93
|
-
if (!id) throw new Error('device id required');
|
|
94
|
-
return withFileLock(store.filePath, async () => {
|
|
95
|
-
const map = await store.load();
|
|
96
|
-
const now = Date.now();
|
|
97
|
-
const existing = map[id];
|
|
98
|
-
if (existing) {
|
|
99
|
-
// Throttled lastSeen update: if the only thing that would change
|
|
100
|
-
// is lastSeen and we flushed for this id within MIN_FLUSH_MS,
|
|
101
|
-
// return the in-memory copy without touching disk. Saves a
|
|
102
|
-
// disk write per request when remote pages poll at 2.5s.
|
|
103
|
-
const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
|
|
104
|
-
let nonLastSeenChange = false;
|
|
105
|
-
existing.lastSeen = now;
|
|
106
|
-
if (userAgent && !existing.userAgent) { existing.userAgent = userAgent; nonLastSeenChange = true; }
|
|
107
|
-
if (ip && existing.ip !== ip) { existing.ip = ip; nonLastSeenChange = true; }
|
|
108
|
-
// Backfill code on existing records that pre-date the field, but
|
|
109
|
-
// never overwrite a code we've already stored — a device's code
|
|
110
|
-
// is its identifier across the approval flow and changing it
|
|
111
|
-
// mid-request would defeat the disambiguation it exists for.
|
|
112
|
-
if (code && !existing.code) {
|
|
113
|
-
existing.code = String(code).slice(0, 8);
|
|
114
|
-
nonLastSeenChange = true;
|
|
115
|
-
}
|
|
116
|
-
if (!nonLastSeenChange && recentlyFlushed) return existing;
|
|
117
|
-
await store.save(map);
|
|
118
|
-
lastFlushAt.set(id, now);
|
|
119
|
-
return existing;
|
|
120
|
-
}
|
|
121
|
-
map[id] = {
|
|
122
|
-
id,
|
|
123
|
-
status: 'pending',
|
|
124
|
-
userAgent: userAgent || null,
|
|
125
|
-
ip: ip || null,
|
|
126
|
-
// 4-digit identification code the requesting browser generated
|
|
127
|
-
// and stashed in its own localStorage. Purely human-facing;
|
|
128
|
-
// the Remote page renders it next to each pending device so the
|
|
129
|
-
// operator can match against what the user reads off their
|
|
130
|
-
// screen before clicking Approve. Not a credential.
|
|
131
|
-
code: code ? String(code).slice(0, 8) : null,
|
|
132
|
-
firstSeen: now,
|
|
133
|
-
lastSeen: now,
|
|
134
|
-
approvedAt: null,
|
|
135
|
-
rejectedAt: null,
|
|
136
|
-
label: describeUA(userAgent),
|
|
137
|
-
};
|
|
138
|
-
await store.save(map);
|
|
139
|
-
lastFlushAt.set(id, now);
|
|
140
|
-
return map[id];
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function get(id) {
|
|
145
|
-
const map = await store.load();
|
|
146
|
-
return map[id] || null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function isApproved(id) {
|
|
150
|
-
const d = await get(id);
|
|
151
|
-
return !!(d && d.status === 'approved');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async function approve(id, label) {
|
|
155
|
-
return withFileLock(store.filePath, async () => {
|
|
156
|
-
const map = await store.load();
|
|
157
|
-
const d = map[id];
|
|
158
|
-
if (!d) return null;
|
|
159
|
-
d.status = 'approved';
|
|
160
|
-
d.approvedAt = Date.now();
|
|
161
|
-
d.rejectedAt = null;
|
|
162
|
-
if (label) d.label = String(label);
|
|
163
|
-
await store.save(map);
|
|
164
|
-
lastFlushAt.set(id, Date.now());
|
|
165
|
-
return d;
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function reject(id) {
|
|
170
|
-
return withFileLock(store.filePath, async () => {
|
|
171
|
-
const map = await store.load();
|
|
172
|
-
const d = map[id];
|
|
173
|
-
if (!d) return null;
|
|
174
|
-
d.status = 'rejected';
|
|
175
|
-
d.rejectedAt = Date.now();
|
|
176
|
-
d.approvedAt = null;
|
|
177
|
-
await store.save(map);
|
|
178
|
-
lastFlushAt.set(id, Date.now());
|
|
179
|
-
return d;
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Identical to reject in storage terms, but separate API name so the UI
|
|
184
|
-
// can distinguish "I'm declining a new request" from "I'm taking back
|
|
185
|
-
// access from someone I'd previously approved". Both end up status:
|
|
186
|
-
// 'rejected' and clear approvedAt — once cleared, the device must
|
|
187
|
-
// request again from scratch.
|
|
188
|
-
async function revoke(id) {
|
|
189
|
-
return reject(id);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function rename(id, label) {
|
|
193
|
-
return withFileLock(store.filePath, async () => {
|
|
194
|
-
const map = await store.load();
|
|
195
|
-
const d = map[id];
|
|
196
|
-
if (!d) return null;
|
|
197
|
-
d.label = String(label || '').slice(0, 60);
|
|
198
|
-
await store.save(map);
|
|
199
|
-
return d;
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async function remove(id) {
|
|
204
|
-
return store.remove(id);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function list() {
|
|
208
|
-
const map = await pruneStale(await store.load());
|
|
209
|
-
return Object.values(map).sort((a, b) => {
|
|
210
|
-
// Pending first (so the host sees them at the top), then approved
|
|
211
|
-
// by approvedAt desc, then rejected by rejectedAt desc.
|
|
212
|
-
const order = { pending: 0, approved: 1, rejected: 2 };
|
|
213
|
-
if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
|
|
214
|
-
return (b.lastSeen || 0) - (a.lastSeen || 0);
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
module.exports = {
|
|
219
|
-
record,
|
|
220
|
-
get,
|
|
221
|
-
isApproved,
|
|
222
|
-
approve,
|
|
223
|
-
reject,
|
|
224
|
-
revoke,
|
|
225
|
-
rename,
|
|
226
|
-
remove,
|
|
227
|
-
list,
|
|
228
|
-
describeUA,
|
|
229
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Remote-device approval store. Each browser that arrives via the tunnel
|
|
4
|
+
// generates a UUID client-side (`ccsm.deviceId` in localStorage) and sends
|
|
5
|
+
// it as `X-Device-Id` on every API call. server.js' middleware feeds those
|
|
6
|
+
// arrivals through record() — known devices get their lastSeen bumped,
|
|
7
|
+
// new ones get inserted as `pending`. Until the host explicitly Approves
|
|
8
|
+
// from the Remote page, every non-loopback request returns 403 with the
|
|
9
|
+
// pending status.
|
|
10
|
+
//
|
|
11
|
+
// Stored at ~/.ccsm/devices.json:
|
|
12
|
+
// {
|
|
13
|
+
// "<uuid>": {
|
|
14
|
+
// id, status: 'pending'|'approved'|'rejected',
|
|
15
|
+
// userAgent, ip,
|
|
16
|
+
// firstSeen, lastSeen, approvedAt,
|
|
17
|
+
// label // user-set or auto-derived from UA
|
|
18
|
+
// },
|
|
19
|
+
// ...
|
|
20
|
+
// }
|
|
21
|
+
|
|
22
|
+
const { DATA_DIR } = require('./config');
|
|
23
|
+
const { createKeyedJsonStore } = require('./jsonStore');
|
|
24
|
+
const { withFileLock } = require('./atomicJson');
|
|
25
|
+
|
|
26
|
+
const store = createKeyedJsonStore({
|
|
27
|
+
dataDir: DATA_DIR,
|
|
28
|
+
filename: 'devices.json',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// `record()` runs on EVERY non-loopback API request. Two side effects
|
|
32
|
+
// without these guards:
|
|
33
|
+
// 1. Concurrent calls each do load→mutate→save independently; the
|
|
34
|
+
// parallel rename(tmp → target) collides on Windows and surfaces
|
|
35
|
+
// as `EPERM: operation not permitted`.
|
|
36
|
+
// 2. Even when serialized, writing on every request hammers the disk
|
|
37
|
+
// for a value that only needs ~minute-grained accuracy (lastSeen
|
|
38
|
+
// drives "seen 5m ago" labels in the UI).
|
|
39
|
+
// Fix: serialize all mutators through the shared per-file lock, and
|
|
40
|
+
// short-circuit lastSeen-only updates that landed within MIN_FLUSH_MS
|
|
41
|
+
// of the last persisted write for the same id.
|
|
42
|
+
const MIN_FLUSH_MS = 15_000;
|
|
43
|
+
const lastFlushAt = new Map(); // id → ms timestamp of last save
|
|
44
|
+
|
|
45
|
+
// Pending entries older than 24h are auto-pruned on each list() so a
|
|
46
|
+
// drive-by scanner doesn't grow the file forever. Rejected entries kept
|
|
47
|
+
// 1h so the host can see what got bounced and rename / un-reject if
|
|
48
|
+
// they realize it was legit.
|
|
49
|
+
const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
const REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// Quick UA → human-readable label. We keep this tiny on purpose — full
|
|
53
|
+
// UA parsing libraries are huge and the only consumer is one line in
|
|
54
|
+
// the approval UI. Order matters: Edge UA includes "Chrome" so detect
|
|
55
|
+
// Edge first.
|
|
56
|
+
function describeUA(ua) {
|
|
57
|
+
ua = String(ua || '');
|
|
58
|
+
const device =
|
|
59
|
+
/iPhone/.test(ua) ? 'iPhone'
|
|
60
|
+
: /iPad/.test(ua) ? 'iPad'
|
|
61
|
+
: /Android/.test(ua) ? 'Android'
|
|
62
|
+
: /Mac OS X/.test(ua) ? 'Mac'
|
|
63
|
+
: /Windows/.test(ua) ? 'Windows'
|
|
64
|
+
: /Linux/.test(ua) ? 'Linux'
|
|
65
|
+
: null;
|
|
66
|
+
const browser =
|
|
67
|
+
/Edg\//.test(ua) ? 'Edge'
|
|
68
|
+
: /OPR\//.test(ua) ? 'Opera'
|
|
69
|
+
: /Chrome\//.test(ua) ? 'Chrome'
|
|
70
|
+
: /Firefox\//.test(ua) ? 'Firefox'
|
|
71
|
+
: /Safari\//.test(ua) ? 'Safari'
|
|
72
|
+
: null;
|
|
73
|
+
if (device && browser) return `${device} · ${browser}`;
|
|
74
|
+
if (device) return device;
|
|
75
|
+
if (browser) return browser;
|
|
76
|
+
return 'Unknown device';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pruneStale(map) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
let dirty = false;
|
|
82
|
+
for (const [id, d] of Object.entries(map)) {
|
|
83
|
+
if (d.status === 'pending' && now - d.firstSeen > PENDING_TTL_MS) { delete map[id]; dirty = true; }
|
|
84
|
+
if (d.status === 'rejected' && now - (d.rejectedAt || d.lastSeen) > REJECTED_TTL_MS) { delete map[id]; dirty = true; }
|
|
85
|
+
}
|
|
86
|
+
if (dirty) await store.save(map);
|
|
87
|
+
return map;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Upsert. Returns the (possibly newly-created) device record. Caller
|
|
91
|
+
// uses .status to decide whether to gate further work.
|
|
92
|
+
async function record(id, { userAgent, ip, code } = {}) {
|
|
93
|
+
if (!id) throw new Error('device id required');
|
|
94
|
+
return withFileLock(store.filePath, async () => {
|
|
95
|
+
const map = await store.load();
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const existing = map[id];
|
|
98
|
+
if (existing) {
|
|
99
|
+
// Throttled lastSeen update: if the only thing that would change
|
|
100
|
+
// is lastSeen and we flushed for this id within MIN_FLUSH_MS,
|
|
101
|
+
// return the in-memory copy without touching disk. Saves a
|
|
102
|
+
// disk write per request when remote pages poll at 2.5s.
|
|
103
|
+
const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
|
|
104
|
+
let nonLastSeenChange = false;
|
|
105
|
+
existing.lastSeen = now;
|
|
106
|
+
if (userAgent && !existing.userAgent) { existing.userAgent = userAgent; nonLastSeenChange = true; }
|
|
107
|
+
if (ip && existing.ip !== ip) { existing.ip = ip; nonLastSeenChange = true; }
|
|
108
|
+
// Backfill code on existing records that pre-date the field, but
|
|
109
|
+
// never overwrite a code we've already stored — a device's code
|
|
110
|
+
// is its identifier across the approval flow and changing it
|
|
111
|
+
// mid-request would defeat the disambiguation it exists for.
|
|
112
|
+
if (code && !existing.code) {
|
|
113
|
+
existing.code = String(code).slice(0, 8);
|
|
114
|
+
nonLastSeenChange = true;
|
|
115
|
+
}
|
|
116
|
+
if (!nonLastSeenChange && recentlyFlushed) return existing;
|
|
117
|
+
await store.save(map);
|
|
118
|
+
lastFlushAt.set(id, now);
|
|
119
|
+
return existing;
|
|
120
|
+
}
|
|
121
|
+
map[id] = {
|
|
122
|
+
id,
|
|
123
|
+
status: 'pending',
|
|
124
|
+
userAgent: userAgent || null,
|
|
125
|
+
ip: ip || null,
|
|
126
|
+
// 4-digit identification code the requesting browser generated
|
|
127
|
+
// and stashed in its own localStorage. Purely human-facing;
|
|
128
|
+
// the Remote page renders it next to each pending device so the
|
|
129
|
+
// operator can match against what the user reads off their
|
|
130
|
+
// screen before clicking Approve. Not a credential.
|
|
131
|
+
code: code ? String(code).slice(0, 8) : null,
|
|
132
|
+
firstSeen: now,
|
|
133
|
+
lastSeen: now,
|
|
134
|
+
approvedAt: null,
|
|
135
|
+
rejectedAt: null,
|
|
136
|
+
label: describeUA(userAgent),
|
|
137
|
+
};
|
|
138
|
+
await store.save(map);
|
|
139
|
+
lastFlushAt.set(id, now);
|
|
140
|
+
return map[id];
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function get(id) {
|
|
145
|
+
const map = await store.load();
|
|
146
|
+
return map[id] || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function isApproved(id) {
|
|
150
|
+
const d = await get(id);
|
|
151
|
+
return !!(d && d.status === 'approved');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function approve(id, label) {
|
|
155
|
+
return withFileLock(store.filePath, async () => {
|
|
156
|
+
const map = await store.load();
|
|
157
|
+
const d = map[id];
|
|
158
|
+
if (!d) return null;
|
|
159
|
+
d.status = 'approved';
|
|
160
|
+
d.approvedAt = Date.now();
|
|
161
|
+
d.rejectedAt = null;
|
|
162
|
+
if (label) d.label = String(label);
|
|
163
|
+
await store.save(map);
|
|
164
|
+
lastFlushAt.set(id, Date.now());
|
|
165
|
+
return d;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function reject(id) {
|
|
170
|
+
return withFileLock(store.filePath, async () => {
|
|
171
|
+
const map = await store.load();
|
|
172
|
+
const d = map[id];
|
|
173
|
+
if (!d) return null;
|
|
174
|
+
d.status = 'rejected';
|
|
175
|
+
d.rejectedAt = Date.now();
|
|
176
|
+
d.approvedAt = null;
|
|
177
|
+
await store.save(map);
|
|
178
|
+
lastFlushAt.set(id, Date.now());
|
|
179
|
+
return d;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Identical to reject in storage terms, but separate API name so the UI
|
|
184
|
+
// can distinguish "I'm declining a new request" from "I'm taking back
|
|
185
|
+
// access from someone I'd previously approved". Both end up status:
|
|
186
|
+
// 'rejected' and clear approvedAt — once cleared, the device must
|
|
187
|
+
// request again from scratch.
|
|
188
|
+
async function revoke(id) {
|
|
189
|
+
return reject(id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function rename(id, label) {
|
|
193
|
+
return withFileLock(store.filePath, async () => {
|
|
194
|
+
const map = await store.load();
|
|
195
|
+
const d = map[id];
|
|
196
|
+
if (!d) return null;
|
|
197
|
+
d.label = String(label || '').slice(0, 60);
|
|
198
|
+
await store.save(map);
|
|
199
|
+
return d;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function remove(id) {
|
|
204
|
+
return store.remove(id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function list() {
|
|
208
|
+
const map = await pruneStale(await store.load());
|
|
209
|
+
return Object.values(map).sort((a, b) => {
|
|
210
|
+
// Pending first (so the host sees them at the top), then approved
|
|
211
|
+
// by approvedAt desc, then rejected by rejectedAt desc.
|
|
212
|
+
const order = { pending: 0, approved: 1, rejected: 2 };
|
|
213
|
+
if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
|
|
214
|
+
return (b.lastSeen || 0) - (a.lastSeen || 0);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
record,
|
|
220
|
+
get,
|
|
221
|
+
isApproved,
|
|
222
|
+
approve,
|
|
223
|
+
reject,
|
|
224
|
+
revoke,
|
|
225
|
+
rename,
|
|
226
|
+
remove,
|
|
227
|
+
list,
|
|
228
|
+
describeUA,
|
|
229
|
+
};
|