@veilio-inc/cli 0.5.4 → 0.6.1
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/README.md +62 -15
- package/dist/args.d.ts +20 -2
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +48 -3
- package/dist/args.js.map +1 -1
- package/dist/client/index.d.ts +9 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +13 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/keys.d.ts +22 -0
- package/dist/client/keys.d.ts.map +1 -0
- package/dist/client/keys.js +170 -0
- package/dist/client/keys.js.map +1 -0
- package/dist/client/maps.d.ts +115 -0
- package/dist/client/maps.d.ts.map +1 -0
- package/dist/client/maps.js +798 -0
- package/dist/client/maps.js.map +1 -0
- package/dist/client/personal-maps-key.d.ts +42 -0
- package/dist/client/personal-maps-key.d.ts.map +1 -0
- package/dist/client/personal-maps-key.js +168 -0
- package/dist/client/personal-maps-key.js.map +1 -0
- package/dist/client/project-scope.d.ts +87 -0
- package/dist/client/project-scope.d.ts.map +1 -0
- package/dist/client/project-scope.js +132 -0
- package/dist/client/project-scope.js.map +1 -0
- package/dist/client/projects.d.ts +53 -0
- package/dist/client/projects.d.ts.map +1 -0
- package/dist/client/projects.js +129 -0
- package/dist/client/projects.js.map +1 -0
- package/dist/client/status.d.ts +21 -0
- package/dist/client/status.d.ts.map +1 -0
- package/dist/client/status.js +71 -0
- package/dist/client/status.js.map +1 -0
- package/dist/client/team-key-bootstrap.d.ts +55 -0
- package/dist/client/team-key-bootstrap.d.ts.map +1 -0
- package/dist/client/team-key-bootstrap.js +127 -0
- package/dist/client/team-key-bootstrap.js.map +1 -0
- package/dist/client/vault-key.d.ts +5 -0
- package/dist/client/vault-key.d.ts.map +1 -0
- package/dist/client/vault-key.js +35 -0
- package/dist/client/vault-key.js.map +1 -0
- package/dist/client/wording.d.ts +62 -0
- package/dist/client/wording.d.ts.map +1 -0
- package/dist/client/wording.js +358 -0
- package/dist/client/wording.js.map +1 -0
- package/dist/cloud-commands.d.ts +34 -43
- package/dist/cloud-commands.d.ts.map +1 -1
- package/dist/cloud-commands.js +282 -466
- package/dist/cloud-commands.js.map +1 -1
- package/dist/cloud.d.ts +101 -2
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +91 -7
- package/dist/cloud.js.map +1 -1
- package/dist/commands.d.ts +14 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +24 -0
- package/dist/commands.js.map +1 -1
- package/dist/credential.d.ts +7 -0
- package/dist/credential.d.ts.map +1 -1
- package/dist/credential.js +10 -1
- package/dist/credential.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -5
- package/dist/index.js.map +1 -1
- package/dist/team-namespace.d.ts +74 -2
- package/dist/team-namespace.d.ts.map +1 -1
- package/dist/team-namespace.js +78 -18
- package/dist/team-namespace.js.map +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
// The map operations, once, for the CLI and the MCP server (spec 052 §3).
|
|
2
|
+
//
|
|
3
|
+
// Moved out of `cloud-commands.ts` so the MCP server's map tools are the CLI's
|
|
4
|
+
// map commands rather than a second copy of them: the same checks in the same
|
|
5
|
+
// order, the same refusals in the same words (FR-007, FR-019). Each operation
|
|
6
|
+
// answers an `Outcome` - `{ ok: true, value }` or `{ ok: false, refusal }` -
|
|
7
|
+
// and prints nothing. The terminal renders a refusal to stderr; the MCP server
|
|
8
|
+
// returns it as a tool error.
|
|
9
|
+
//
|
|
10
|
+
// Two orderings hold everywhere below, and they are the controls:
|
|
11
|
+
//
|
|
12
|
+
// - REFUSE, THEN WRITE. Every check - keys, ownership, clashes, dropped
|
|
13
|
+
// entries - happens before the first byte is sealed or sent, so a refusal
|
|
14
|
+
// costs nothing and changes nothing (Constitution IV).
|
|
15
|
+
// - DECRYPT, THEN SAVE. A pull writes the local store only after the map has
|
|
16
|
+
// opened. The store is the one thing that can restore text already masked,
|
|
17
|
+
// and a half-written one is text nobody reads back.
|
|
18
|
+
//
|
|
19
|
+
// Which key opens a personal map (research §8): this machine's personal-maps
|
|
20
|
+
// key when `veilio vault unlock` left one - and then ONLY that key, so an
|
|
21
|
+
// agent's tool call and a terminal command behave alike - otherwise, where a
|
|
22
|
+
// person is present to type it (the CLI), the vault passphrase.
|
|
23
|
+
import { decryptMapFromVault, decryptTeamMapWithAny, encryptMapForVault, encryptTeamMap, importTeamKey, parseVaultEnvelope, } from '@veilio-inc/engine';
|
|
24
|
+
import { CloudError, createMap, deleteMap, getMap, getPersonalMapsKeyWrap, listMaps, listProjects, updateMapContent, } from '../cloud.js';
|
|
25
|
+
import { readCredential } from '../credential.js';
|
|
26
|
+
import { loadMap, loadRemote, saveMap } from '../store.js';
|
|
27
|
+
import { readTeamUnlock } from '../team-unlock.js';
|
|
28
|
+
import { describeMaps, resolveTeamNamespace } from '../team-namespace.js';
|
|
29
|
+
import { importPersonalMapsKey, readPersonalMapsKey, unwrapPersonalMapsKey, } from './personal-maps-key.js';
|
|
30
|
+
import { vaultKeyFor } from './vault-key.js';
|
|
31
|
+
import { projectLabel, projectOverride, refusalScope, scopeCredential, } from './project-scope.js';
|
|
32
|
+
import { refuse, refusalFromCloudError } from './wording.js';
|
|
33
|
+
const DEFAULT_NAME = 'from the terminal';
|
|
34
|
+
function ok(value) {
|
|
35
|
+
return { ok: true, value };
|
|
36
|
+
}
|
|
37
|
+
function signedIn(ctx) {
|
|
38
|
+
return readCredential(ctx.home);
|
|
39
|
+
}
|
|
40
|
+
function cloudRefusal(err, credential) {
|
|
41
|
+
return { ok: false, refusal: refusalFromCloudError(err, refusalScope(credential)) };
|
|
42
|
+
}
|
|
43
|
+
/** Five placeholders, then a count. Placeholders only - never what they mean. */
|
|
44
|
+
function shortList(items) {
|
|
45
|
+
return items.slice(0, 5).join(', ') + (items.length > 5 ? ` and ${items.length - 5} more` : '');
|
|
46
|
+
}
|
|
47
|
+
export async function listCloudMaps(ctx) {
|
|
48
|
+
const stored = signedIn(ctx);
|
|
49
|
+
if (!stored)
|
|
50
|
+
return refuse('not_signed_in');
|
|
51
|
+
const scoped = await scopeCredential(stored, ctx.env);
|
|
52
|
+
if (!scoped.ok)
|
|
53
|
+
return scoped;
|
|
54
|
+
const credential = scoped.value;
|
|
55
|
+
let list;
|
|
56
|
+
try {
|
|
57
|
+
list = await listMaps(credential);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return cloudRefusal(err, credential);
|
|
61
|
+
}
|
|
62
|
+
const shape = (mine) => (m) => ({
|
|
63
|
+
id: m.id,
|
|
64
|
+
name: m.name,
|
|
65
|
+
scope: m.scope,
|
|
66
|
+
count: m.identifier_count,
|
|
67
|
+
mine,
|
|
68
|
+
updatedAt: m.updated_at,
|
|
69
|
+
});
|
|
70
|
+
return ok({
|
|
71
|
+
maps: [...list.personalMaps.map(shape(true)), ...list.teamMaps.map(shape(false))],
|
|
72
|
+
teamId: list.team?.id ?? null,
|
|
73
|
+
project: projectLabel(credential),
|
|
74
|
+
projectFromEnv: credential.projectFrom !== undefined,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The keys this run may use for personal maps.
|
|
79
|
+
*
|
|
80
|
+
* The personal-maps key on disk wins, and then nothing else is asked for.
|
|
81
|
+
* Without it, a run with nobody to ask (the MCP server) is refused, naming the
|
|
82
|
+
* command that unlocks it; a terminal asks for the passphrase, checks it
|
|
83
|
+
* against the verifier BEFORE anything is opened or sealed, and unwraps the
|
|
84
|
+
* account's personal-maps key with it where the server holds one.
|
|
85
|
+
*/
|
|
86
|
+
async function personalKeys(credential, ctx) {
|
|
87
|
+
const stored = readPersonalMapsKey(credential, ctx.home);
|
|
88
|
+
if (stored)
|
|
89
|
+
return ok({ pmk: await importPersonalMapsKey(stored), vault: null });
|
|
90
|
+
if (!ctx.passphrase)
|
|
91
|
+
return refuse('vault_locked');
|
|
92
|
+
const derived = await vaultKeyFor(credential, ctx.passphrase);
|
|
93
|
+
if (!derived.ok)
|
|
94
|
+
return derived;
|
|
95
|
+
const vaultKey = derived.value;
|
|
96
|
+
// The account's personal-maps key, if the server holds one. An instance
|
|
97
|
+
// that predates spec 052 has no such route; that is "no key yet", and
|
|
98
|
+
// personal maps then stay under the vault key as they always were.
|
|
99
|
+
let wrapped;
|
|
100
|
+
try {
|
|
101
|
+
const answer = await getPersonalMapsKeyWrap(credential);
|
|
102
|
+
wrapped = typeof answer?.wrapped === 'string' && answer.wrapped !== '' ? answer.wrapped : null;
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
// Only the route being absent means "no key": anything else - a 503, a
|
|
106
|
+
// dropped connection - says nothing about the account, and treating it as
|
|
107
|
+
// "none" would seal a new map where the MCP server cannot open it, or
|
|
108
|
+
// tell somebody their key does not exist (code review).
|
|
109
|
+
if (!(err instanceof CloudError && err.status === 404))
|
|
110
|
+
return cloudRefusal(err, credential);
|
|
111
|
+
wrapped = null;
|
|
112
|
+
}
|
|
113
|
+
if (wrapped === null)
|
|
114
|
+
return ok({ pmk: null, vault: vaultKey });
|
|
115
|
+
try {
|
|
116
|
+
return ok({
|
|
117
|
+
pmk: await importPersonalMapsKey(await unwrapPersonalMapsKey(vaultKey, wrapped)),
|
|
118
|
+
vault: vaultKey,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// The passphrase was RIGHT (the verifier said so), so this is a damaged
|
|
123
|
+
// wrap. Sealing anything now could only make it worse.
|
|
124
|
+
return refuse('pmk_unopenable');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Open a personal envelope with whichever key sealed it: the personal-maps
|
|
128
|
+
* key first, then the vault key if this run holds it. */
|
|
129
|
+
async function openPersonal(keys, mapData, label) {
|
|
130
|
+
let envelope;
|
|
131
|
+
try {
|
|
132
|
+
envelope = parseVaultEnvelope(mapData);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return refuse('not_envelope');
|
|
136
|
+
}
|
|
137
|
+
if (keys.pmk) {
|
|
138
|
+
try {
|
|
139
|
+
return ok(await decryptMapFromVault(keys.pmk, envelope));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// AES-GCM authenticates: the wrong key fails cleanly. Try the next.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (keys.vault) {
|
|
146
|
+
try {
|
|
147
|
+
return ok(await decryptMapFromVault(keys.vault, envelope));
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return refuse('personal_unopenable', {
|
|
151
|
+
...label,
|
|
152
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Only the personal-maps key here, and it did not open: an older map,
|
|
157
|
+
// still under the vault key. `vault unlock` re-seals those.
|
|
158
|
+
return refuse('personal_old_seal', label);
|
|
159
|
+
}
|
|
160
|
+
/** The team keys `veilio team unlock` left. Read, never derived: nobody's
|
|
161
|
+
* passphrase produces a team key. */
|
|
162
|
+
async function teamKeys(credential, ctx) {
|
|
163
|
+
const unlock = readTeamUnlock({ instance: credential.instance, account: credential.account }, ctx.home);
|
|
164
|
+
if (!unlock || unlock.keys.length === 0)
|
|
165
|
+
return refuse('team_locked');
|
|
166
|
+
// Newest first: a team mid-rotation holds two, and new content belongs
|
|
167
|
+
// under the newer one. Declared on upload so the server can refuse a write
|
|
168
|
+
// under a key a departed member knew (spec 014).
|
|
169
|
+
const newestStored = [...unlock.keys].sort((a, b) => b.version - a.version)[0];
|
|
170
|
+
let newest;
|
|
171
|
+
try {
|
|
172
|
+
newest = await importTeamKey(newestStored.key);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
return refuse('team_key_unreadable', {
|
|
176
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const all = [];
|
|
180
|
+
for (const stored of unlock.keys) {
|
|
181
|
+
try {
|
|
182
|
+
all.push({ version: stored.version, key: await importTeamKey(stored.key) });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// A damaged entry; the others may still open the map.
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ok({ teamId: unlock.teamId, all, newest: { version: newestStored.version, key: newest } });
|
|
190
|
+
}
|
|
191
|
+
async function openTeam(keys, mapData, label) {
|
|
192
|
+
try {
|
|
193
|
+
return ok(await decryptTeamMapWithAny(keys.all, JSON.parse(mapData)));
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return refuse('team_map_unopenable', {
|
|
197
|
+
...label,
|
|
198
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function isTeamEnvelope(mapData) {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(mapData).alg === 'AES-256-GCM-TEAM';
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** The listing, for a team write: which team this account acts for, and the
|
|
211
|
+
* maps the namespace read then reuses. */
|
|
212
|
+
async function listingForTeam(credential) {
|
|
213
|
+
try {
|
|
214
|
+
return ok(await listMaps(credential));
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// Not knowing the team is not knowing whether this map is safe to publish.
|
|
218
|
+
const detail = err instanceof CloudError
|
|
219
|
+
? `${err.message}${err.status ? ` (HTTP ${err.status})` : ''}`
|
|
220
|
+
: String(err);
|
|
221
|
+
return refuse('team_maps_unreadable', { detail });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ─── pull ────────────────────────────────────────────────────────────────────
|
|
225
|
+
export async function pullCloudMap(ctx, id, opts) {
|
|
226
|
+
const credential = signedIn(ctx);
|
|
227
|
+
if (!credential)
|
|
228
|
+
return refuse('not_signed_in');
|
|
229
|
+
if (!id)
|
|
230
|
+
return refuse('which_map');
|
|
231
|
+
let remote;
|
|
232
|
+
try {
|
|
233
|
+
remote = await getMap(credential, id);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (err instanceof CloudError && err.status === 404 && !/project/i.test(err.message))
|
|
237
|
+
return refuse('map_not_found', { id });
|
|
238
|
+
return cloudRefusal(err, credential);
|
|
239
|
+
}
|
|
240
|
+
// Every map is an envelope the server cannot open: a personal map under the
|
|
241
|
+
// owner's keys, a team map under the team key. Which one is written on the
|
|
242
|
+
// envelope itself.
|
|
243
|
+
if (typeof remote.map_data !== 'string')
|
|
244
|
+
return refuse('not_envelope');
|
|
245
|
+
const team = isTeamEnvelope(remote.map_data);
|
|
246
|
+
if (team === null)
|
|
247
|
+
return refuse('not_envelope');
|
|
248
|
+
const label = { id: remote.id, name: remote.name };
|
|
249
|
+
let opened;
|
|
250
|
+
if (team) {
|
|
251
|
+
// A team map belongs to one project: with VEILIO_PROJECT set it must be in
|
|
252
|
+
// that one, and a value that names nothing is refused as everywhere else.
|
|
253
|
+
// A personal map is never asked (FR-012a).
|
|
254
|
+
if (projectOverride(ctx.env) !== null) {
|
|
255
|
+
const scoped = await scopeCredential(credential, ctx.env);
|
|
256
|
+
if (!scoped.ok)
|
|
257
|
+
return scoped;
|
|
258
|
+
let listed;
|
|
259
|
+
try {
|
|
260
|
+
listed = await listMaps(scoped.value);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
return cloudRefusal(err, scoped.value);
|
|
264
|
+
}
|
|
265
|
+
if (![...listed.personalMaps, ...listed.teamMaps].some((m) => m.id === remote.id))
|
|
266
|
+
return refuse('map_not_in_project', {
|
|
267
|
+
id: remote.id,
|
|
268
|
+
project: scoped.value.projectName ?? scoped.value.project,
|
|
269
|
+
override: true,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const keys = await teamKeys(credential, ctx);
|
|
273
|
+
if (!keys.ok)
|
|
274
|
+
return keys;
|
|
275
|
+
opened = await openTeam(keys.value, remote.map_data, label);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const keys = await personalKeys(credential, ctx);
|
|
279
|
+
if (!keys.ok)
|
|
280
|
+
return keys;
|
|
281
|
+
opened = await openPersonal(keys.value, remote.map_data, label);
|
|
282
|
+
}
|
|
283
|
+
if (!opened.ok)
|
|
284
|
+
return opened;
|
|
285
|
+
const map = opened.value;
|
|
286
|
+
// Only now is anything read from, let alone written to, disk.
|
|
287
|
+
const existing = loadRemote(opts.mapPath);
|
|
288
|
+
const local = loadMap(opts.mapPath);
|
|
289
|
+
const hasLocal = Object.keys(local).length > 0;
|
|
290
|
+
const diverged = hasLocal &&
|
|
291
|
+
existing !== undefined &&
|
|
292
|
+
existing.id === remote.id &&
|
|
293
|
+
existing.updatedAt !== remote.updated_at &&
|
|
294
|
+
JSON.stringify(local) !== JSON.stringify(map);
|
|
295
|
+
// Reported, not resolved. Overwriting either side silently loses entries
|
|
296
|
+
// that some text out there still depends on.
|
|
297
|
+
if (diverged && opts.force !== true) {
|
|
298
|
+
return refuse('diverged', { from: existing.updatedAt, to: remote.updated_at });
|
|
299
|
+
}
|
|
300
|
+
// Whether or not the Cloud copy changed: a local entry the pulled map lacks,
|
|
301
|
+
// or gives another name, is a placeholder some masked text still depends on
|
|
302
|
+
// (staging walk, 2026-10-01).
|
|
303
|
+
const dropped = Object.keys(local).filter((p) => map[p] !== local[p]);
|
|
304
|
+
if (dropped.length > 0 && opts.force !== true) {
|
|
305
|
+
return refuse('pull_would_drop', {
|
|
306
|
+
name: remote.name,
|
|
307
|
+
count: dropped.length,
|
|
308
|
+
list: shortList(dropped),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
saveMap(opts.mapPath, map, {
|
|
312
|
+
force: true,
|
|
313
|
+
remote: { id: remote.id, updatedAt: remote.updated_at, pulledAt: new Date().toISOString() },
|
|
314
|
+
});
|
|
315
|
+
return ok({
|
|
316
|
+
id: remote.id,
|
|
317
|
+
name: remote.name,
|
|
318
|
+
count: Object.keys(map).length,
|
|
319
|
+
path: opts.mapPath,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
// ─── the team checks, shared by create and update ───────────────────────────
|
|
323
|
+
/**
|
|
324
|
+
* Placeholders the local map and the team's namespace use for DIFFERENT names.
|
|
325
|
+
*
|
|
326
|
+
* Publishing one creates a DISPUTED placeholder, which then restores for no
|
|
327
|
+
* member of the team until somebody deletes a map. One person's push, a cost
|
|
328
|
+
* paid by everyone, and nothing errors at the time - so it is checked before
|
|
329
|
+
* anything is sealed. An identifier the team holds under a DIFFERENT
|
|
330
|
+
* placeholder is an alias, not a conflict: both still restore (spec 017).
|
|
331
|
+
*/
|
|
332
|
+
export function conflictsWithTeam(local, teamNamespace) {
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const [placeholder, mine] of Object.entries(local)) {
|
|
335
|
+
const theirs = teamNamespace[placeholder];
|
|
336
|
+
if (theirs !== undefined && theirs !== mine)
|
|
337
|
+
out.push({ placeholder, mine, theirs });
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/** The team's namespace for a write, read once - from the listing this
|
|
342
|
+
* command already holds (spec 052: one listing per command). Anything but a
|
|
343
|
+
* readable team namespace is a refusal: publishing blind is what the clash
|
|
344
|
+
* check exists to prevent. */
|
|
345
|
+
async function teamForWrite(ctx, credential, list, excludeMapId) {
|
|
346
|
+
// The credential this command already scoped: resolving VEILIO_PROJECT a
|
|
347
|
+
// second time would be a second listing for the same answer.
|
|
348
|
+
const found = await resolveTeamNamespace(ctx.home, { excludeMapId, list, credential });
|
|
349
|
+
if (found.status === 'local')
|
|
350
|
+
return refuse('not_in_team');
|
|
351
|
+
if (found.status === 'locked')
|
|
352
|
+
return refuse('team_key_mismatch');
|
|
353
|
+
if (found.status === 'unavailable') {
|
|
354
|
+
if (/project not found/i.test(found.reason)) {
|
|
355
|
+
const scope = refusalScope(credential);
|
|
356
|
+
return refuse('project_gone', { ...scope, project: scope.project ?? 'the default' });
|
|
357
|
+
}
|
|
358
|
+
return refuse('team_maps_unreadable', { detail: found.reason });
|
|
359
|
+
}
|
|
360
|
+
return ok(found);
|
|
361
|
+
}
|
|
362
|
+
function checkAgainstTeam(found, map) {
|
|
363
|
+
const clashes = conflictsWithTeam(map, found.analysis.namespace);
|
|
364
|
+
if (clashes.length > 0) {
|
|
365
|
+
return refuse('clash', { count: clashes.length, list: shortList(clashes.map((c) => c.placeholder)) }, [
|
|
366
|
+
...clashes
|
|
367
|
+
.slice(0, 10)
|
|
368
|
+
.map(({ placeholder, mine, theirs }) => `${placeholder}: this map says ${mine}, the team says ${theirs}`),
|
|
369
|
+
...(clashes.length > 10 ? [`… and ${clashes.length - 10} more`] : []),
|
|
370
|
+
]);
|
|
371
|
+
}
|
|
372
|
+
// Some maps opened and some did not: the check ran against the readable
|
|
373
|
+
// ones. Said, naming them, rather than passed off as a full check (FR-018).
|
|
374
|
+
const notes = found.unreadable.length > 0
|
|
375
|
+
? [
|
|
376
|
+
`${found.unreadable.length} team map${found.unreadable.length === 1 ? '' : 's'} could not be opened and ` +
|
|
377
|
+
`${found.unreadable.length === 1 ? 'was' : 'were'} not checked for clashes: ${describeMaps(found.unreadable)}. ` +
|
|
378
|
+
'Run `veilio team unlock` again, or ask the publisher to remove a damaged one.',
|
|
379
|
+
]
|
|
380
|
+
: [];
|
|
381
|
+
return ok({
|
|
382
|
+
namespace: found.analysis.namespace,
|
|
383
|
+
disputed: Object.keys(found.analysis.conflicts).length,
|
|
384
|
+
notes,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** A refused team write, said in terms of what happened and who can fix it. */
|
|
388
|
+
function teamWriteRefusal(err, credential, verb, label) {
|
|
389
|
+
if (err instanceof CloudError) {
|
|
390
|
+
// POST answers an owed rotation with 409; PATCH uses 409 for a stale
|
|
391
|
+
// version and 423 for the rotation (contracts/api.md).
|
|
392
|
+
if (verb === 'create' && err.status === 409)
|
|
393
|
+
return refuse('rotation_owed');
|
|
394
|
+
if (err.status === 423)
|
|
395
|
+
return refuse('rotation_owed');
|
|
396
|
+
if (verb === 'update' && err.status === 409) {
|
|
397
|
+
const now = err.body?.current_updated_at;
|
|
398
|
+
return refuse('stale', { ...label, to: typeof now === 'string' ? now : 'a newer version' });
|
|
399
|
+
}
|
|
400
|
+
if (err.status === 413)
|
|
401
|
+
return refuse('too_large');
|
|
402
|
+
if (verb === 'update' && err.status === 404 && !/project/i.test(err.message))
|
|
403
|
+
return refuse('map_not_found', label);
|
|
404
|
+
if (verb === 'update' &&
|
|
405
|
+
err.status === 403 &&
|
|
406
|
+
err.kind === 'forbidden' &&
|
|
407
|
+
!/limit/i.test(err.message))
|
|
408
|
+
return refuse('not_yours_update', label);
|
|
409
|
+
}
|
|
410
|
+
return cloudRefusal(err, credential);
|
|
411
|
+
}
|
|
412
|
+
/** Seal the local map and upload it as a new map, personal or the team's. */
|
|
413
|
+
export async function createCloudMap(ctx, opts) {
|
|
414
|
+
const stored = signedIn(ctx);
|
|
415
|
+
if (!stored)
|
|
416
|
+
return refuse('not_signed_in');
|
|
417
|
+
const map = loadMap(opts.mapPath);
|
|
418
|
+
if (Object.keys(map).length === 0)
|
|
419
|
+
return refuse('no_local_map', { path: opts.mapPath });
|
|
420
|
+
// A team map lands in a project; a personal map has none, so only a team
|
|
421
|
+
// push resolves VEILIO_PROJECT (and pays for the listing that takes).
|
|
422
|
+
let credential = stored;
|
|
423
|
+
if (opts.scope === 'team') {
|
|
424
|
+
const scoped = await scopeCredential(stored, ctx.env);
|
|
425
|
+
if (!scoped.ok)
|
|
426
|
+
return scoped;
|
|
427
|
+
credential = scoped.value;
|
|
428
|
+
}
|
|
429
|
+
const name = opts.name ?? DEFAULT_NAME;
|
|
430
|
+
const count = Object.keys(map).length;
|
|
431
|
+
if (opts.scope === 'team') {
|
|
432
|
+
const keys = await teamKeys(credential, ctx);
|
|
433
|
+
if (!keys.ok)
|
|
434
|
+
return keys;
|
|
435
|
+
// Keys unlocked for a team this account has since left - or another
|
|
436
|
+
// team entirely - must never seal a map for this one: the team could not
|
|
437
|
+
// read it, and whoever holds that other key could. Checked HERE, at the
|
|
438
|
+
// key, the way the namespace read checks it - not left to the namespace
|
|
439
|
+
// read, which an older instance answers with no key at all (code review).
|
|
440
|
+
const listed = await listingForTeam(credential);
|
|
441
|
+
if (!listed.ok)
|
|
442
|
+
return listed;
|
|
443
|
+
const teamId = listed.value.team?.id ?? null;
|
|
444
|
+
if (teamId === null)
|
|
445
|
+
return refuse('not_in_team');
|
|
446
|
+
if (teamId !== keys.value.teamId)
|
|
447
|
+
return refuse('team_key_mismatch');
|
|
448
|
+
const found = await teamForWrite(ctx, credential, listed.value);
|
|
449
|
+
if (!found.ok)
|
|
450
|
+
return found;
|
|
451
|
+
const check = checkAgainstTeam(found.value, map);
|
|
452
|
+
if (!check.ok)
|
|
453
|
+
return check;
|
|
454
|
+
// Nothing above this line has sent anything.
|
|
455
|
+
const envelope = await encryptTeamMap(keys.value.newest.key, map);
|
|
456
|
+
let created;
|
|
457
|
+
try {
|
|
458
|
+
created = await createMap(credential, {
|
|
459
|
+
name,
|
|
460
|
+
map_data: JSON.stringify(envelope),
|
|
461
|
+
identifier_count: count,
|
|
462
|
+
scope: 'team',
|
|
463
|
+
key_version: keys.value.newest.version,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return teamWriteRefusal(err, credential, 'create', { id: '', name });
|
|
468
|
+
}
|
|
469
|
+
// The local map is NOT given a remote id: a team map is not this
|
|
470
|
+
// project's copy of anything, and recording it would make the next
|
|
471
|
+
// `maps pull` treat the team's map as this project's own. A union, not a
|
|
472
|
+
// sum: an identifier both hold is one identifier (spec 011 T030).
|
|
473
|
+
return ok({
|
|
474
|
+
id: created.id,
|
|
475
|
+
name,
|
|
476
|
+
scope: 'team',
|
|
477
|
+
count,
|
|
478
|
+
teamTotal: new Set([...Object.keys(check.value.namespace), ...Object.keys(map)]).size,
|
|
479
|
+
disputed: check.value.disputed,
|
|
480
|
+
notes: check.value.notes,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const keys = await personalKeys(credential, ctx);
|
|
484
|
+
if (!keys.ok)
|
|
485
|
+
return keys;
|
|
486
|
+
// New personal maps go under the personal-maps key wherever there is one
|
|
487
|
+
// (research §1), so the MCP server can open them; under the vault key only
|
|
488
|
+
// for an account that has none yet, as every personal map was before.
|
|
489
|
+
const sealKey = keys.value.pmk ?? keys.value.vault;
|
|
490
|
+
const envelope = await encryptMapForVault(sealKey, map);
|
|
491
|
+
let created;
|
|
492
|
+
try {
|
|
493
|
+
created = await createMap(credential, {
|
|
494
|
+
name,
|
|
495
|
+
map_data: JSON.stringify(envelope),
|
|
496
|
+
identifier_count: count,
|
|
497
|
+
scope: 'personal',
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
return cloudRefusal(err, credential);
|
|
502
|
+
}
|
|
503
|
+
const now = new Date().toISOString();
|
|
504
|
+
saveMap(opts.mapPath, map, {
|
|
505
|
+
force: true,
|
|
506
|
+
remote: { id: created.id, updatedAt: now, pushedAt: now },
|
|
507
|
+
});
|
|
508
|
+
return ok({ id: created.id, name, scope: 'personal', count, notes: [] });
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The personal map `id`, as the account's own listing shows it - asked WITHOUT
|
|
512
|
+
* any project, so neither VEILIO_PROJECT nor a stored choice that has gone can
|
|
513
|
+
* stand between a person and their own personal map. Null when it is not a
|
|
514
|
+
* personal map (a team map, another project's, none) or the listing fails: the
|
|
515
|
+
* caller then scopes the credential and asks again, as for any team map.
|
|
516
|
+
*/
|
|
517
|
+
async function personalListing(stored, id) {
|
|
518
|
+
const open = { ...stored };
|
|
519
|
+
delete open.project;
|
|
520
|
+
try {
|
|
521
|
+
const list = await listMaps(open);
|
|
522
|
+
const mine = list.personalMaps.find((m) => m.id === id);
|
|
523
|
+
return mine && mine.scope === 'personal' ? { credential: open, list } : null;
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Replace a Cloud map's entries with the local map's, in place: same id, name
|
|
531
|
+
* and project (spec 052 US1).
|
|
532
|
+
*
|
|
533
|
+
* The server cannot read names, so the two checks that need them are made
|
|
534
|
+
* here, before anything is sealed: the entries the update would drop (FR-005),
|
|
535
|
+
* and - for a team map - clashes with the project's OTHER team maps (FR-003).
|
|
536
|
+
* The server decides the rest: owner or publisher, the rotation lock, and the
|
|
537
|
+
* version this read (`if_updated_at`), so a teammate's write in between is
|
|
538
|
+
* reported rather than overwritten.
|
|
539
|
+
*/
|
|
540
|
+
export async function updateCloudMap(ctx, id, opts) {
|
|
541
|
+
const stored = signedIn(ctx);
|
|
542
|
+
if (!stored)
|
|
543
|
+
return refuse('not_signed_in');
|
|
544
|
+
if (!id)
|
|
545
|
+
return refuse('which_map');
|
|
546
|
+
const local = loadMap(opts.mapPath);
|
|
547
|
+
if (Object.keys(local).length === 0)
|
|
548
|
+
return refuse('no_local_map', { path: opts.mapPath });
|
|
549
|
+
// VEILIO_PROJECT is a team map's question: a personal map belongs to no
|
|
550
|
+
// project, so with the variable set it is found first, and a value that
|
|
551
|
+
// names nothing never refuses it (FR-012a).
|
|
552
|
+
const personal = projectOverride(ctx.env) === null ? null : await personalListing(stored, id);
|
|
553
|
+
let credential;
|
|
554
|
+
let list;
|
|
555
|
+
if (personal) {
|
|
556
|
+
credential = personal.credential;
|
|
557
|
+
list = personal.list;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const scoped = await scopeCredential(stored, ctx.env);
|
|
561
|
+
if (!scoped.ok)
|
|
562
|
+
return scoped;
|
|
563
|
+
credential = scoped.value;
|
|
564
|
+
// Whose it is, before reading it: a teammate's map is refused without
|
|
565
|
+
// opening anything (FR-006, the same rule as removing it).
|
|
566
|
+
try {
|
|
567
|
+
list = await listMaps(credential);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
return cloudRefusal(err, credential);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const theirs = list.teamMaps.find((m) => m.id === id);
|
|
574
|
+
if (theirs)
|
|
575
|
+
return refuse('not_yours_update', { id, name: theirs.name });
|
|
576
|
+
if (!list.personalMaps.some((m) => m.id === id)) {
|
|
577
|
+
// With a project chosen the listing is that project's, so "not here" may
|
|
578
|
+
// well mean "in another project": said, with the way to switch.
|
|
579
|
+
if (credential.project) {
|
|
580
|
+
return refuse('map_not_in_project', {
|
|
581
|
+
id,
|
|
582
|
+
project: await projectName(credential),
|
|
583
|
+
...(credential.projectFrom ? { override: true } : {}),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return refuse('map_not_found', { id });
|
|
587
|
+
}
|
|
588
|
+
// A team map: the keys on this machine must be THIS team's before the map
|
|
589
|
+
// is even read (security review) - a key from a team this account has left
|
|
590
|
+
// must never seal or open anything for this one.
|
|
591
|
+
const summary = list.personalMaps.find((m) => m.id === id);
|
|
592
|
+
if (summary.scope === 'team') {
|
|
593
|
+
const keys = await teamKeys(credential, ctx);
|
|
594
|
+
if (!keys.ok)
|
|
595
|
+
return keys;
|
|
596
|
+
if (list.team?.id !== keys.value.teamId)
|
|
597
|
+
return refuse('team_key_mismatch');
|
|
598
|
+
}
|
|
599
|
+
const team = summary.scope === 'team';
|
|
600
|
+
const label = { id, name: summary.name };
|
|
601
|
+
// The version this update replaces: the one the listing read. A write that
|
|
602
|
+
// landed since is refused by the server (409), never overwritten.
|
|
603
|
+
const ifUpdatedAt = summary.updated_at;
|
|
604
|
+
let sealed;
|
|
605
|
+
let current;
|
|
606
|
+
let notes = [];
|
|
607
|
+
if (team) {
|
|
608
|
+
const keys = await teamKeys(credential, ctx);
|
|
609
|
+
if (!keys.ok)
|
|
610
|
+
return keys;
|
|
611
|
+
// The team's maps, read once, with this one among them: its current
|
|
612
|
+
// content comes from the same request as the clash check, not from a
|
|
613
|
+
// second download (spec 052, the staging rate limit).
|
|
614
|
+
const found = await teamForWrite(ctx, credential, list, id);
|
|
615
|
+
if (!found.ok)
|
|
616
|
+
return found;
|
|
617
|
+
let opened;
|
|
618
|
+
if (found.value.excluded) {
|
|
619
|
+
opened =
|
|
620
|
+
found.value.excluded.map === null
|
|
621
|
+
? refuse('team_map_unopenable', {
|
|
622
|
+
...label,
|
|
623
|
+
detail: 'it did not open with the unlocked team keys',
|
|
624
|
+
})
|
|
625
|
+
: ok(found.value.excluded.map);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
// Not in the bulk read (an older instance, or one the server itself
|
|
629
|
+
// could not read): fetched on its own.
|
|
630
|
+
const fetched = await fetchEnvelope(credential, id);
|
|
631
|
+
if (!fetched.ok)
|
|
632
|
+
return fetched;
|
|
633
|
+
opened = await openTeam(keys.value, fetched.value.map_data, label);
|
|
634
|
+
}
|
|
635
|
+
if (!opened.ok)
|
|
636
|
+
return opened;
|
|
637
|
+
current = opened.value;
|
|
638
|
+
const dropped = droppedBy(current, local);
|
|
639
|
+
if (dropped.length > 0 && opts.allowDrop !== true)
|
|
640
|
+
return refuse('would_drop', { ...label, count: dropped.length, list: shortList(dropped) });
|
|
641
|
+
const check = checkAgainstTeam(found.value, local);
|
|
642
|
+
if (!check.ok)
|
|
643
|
+
return check;
|
|
644
|
+
notes = check.value.notes;
|
|
645
|
+
sealed = {
|
|
646
|
+
map_data: JSON.stringify(await encryptTeamMap(keys.value.newest.key, local)),
|
|
647
|
+
key_version: keys.value.newest.version,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
const fetched = await fetchEnvelope(credential, id);
|
|
652
|
+
if (!fetched.ok)
|
|
653
|
+
return fetched;
|
|
654
|
+
if (isTeamEnvelope(fetched.value.map_data) !== false)
|
|
655
|
+
return refuse('not_envelope');
|
|
656
|
+
const keys = await personalKeys(credential, ctx);
|
|
657
|
+
if (!keys.ok)
|
|
658
|
+
return keys;
|
|
659
|
+
const opened = await openPersonal(keys.value, fetched.value.map_data, label);
|
|
660
|
+
if (!opened.ok)
|
|
661
|
+
return opened;
|
|
662
|
+
current = opened.value;
|
|
663
|
+
const dropped = droppedBy(current, local);
|
|
664
|
+
if (dropped.length > 0 && opts.allowDrop !== true)
|
|
665
|
+
return refuse('would_drop', { ...label, count: dropped.length, list: shortList(dropped) });
|
|
666
|
+
// An updated personal map is sealed under the personal-maps key - never
|
|
667
|
+
// left under the vault key, or the MCP server could not open it (FR-011).
|
|
668
|
+
if (!keys.value.pmk)
|
|
669
|
+
return refuse('no_pmk_yet');
|
|
670
|
+
sealed = { map_data: JSON.stringify(await encryptMapForVault(keys.value.pmk, local)) };
|
|
671
|
+
}
|
|
672
|
+
let answer;
|
|
673
|
+
try {
|
|
674
|
+
answer = await updateMapContent(credential, id, {
|
|
675
|
+
...sealed,
|
|
676
|
+
identifier_count: Object.keys(local).length,
|
|
677
|
+
if_updated_at: ifUpdatedAt,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
return teamWriteRefusal(err, credential, 'update', label);
|
|
682
|
+
}
|
|
683
|
+
const updatedAt = typeof answer?.updated_at === 'string' ? answer.updated_at : new Date().toISOString();
|
|
684
|
+
// A personal map is this project's copy: its remote marker moves with it,
|
|
685
|
+
// so the next pull does not read the update as a divergence.
|
|
686
|
+
if (!team) {
|
|
687
|
+
const prior = loadRemote(opts.mapPath);
|
|
688
|
+
if (prior?.id === id) {
|
|
689
|
+
saveMap(opts.mapPath, local, {
|
|
690
|
+
force: true,
|
|
691
|
+
remote: { ...prior, updatedAt, pushedAt: new Date().toISOString() },
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return ok({
|
|
696
|
+
id,
|
|
697
|
+
name: summary.name,
|
|
698
|
+
scope: team ? 'team' : 'personal',
|
|
699
|
+
count: Object.keys(local).length,
|
|
700
|
+
dropped: droppedBy(current, local).length,
|
|
701
|
+
updatedAt,
|
|
702
|
+
notes,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/** One map's envelope, by id. */
|
|
706
|
+
async function fetchEnvelope(credential, id) {
|
|
707
|
+
let remote;
|
|
708
|
+
try {
|
|
709
|
+
remote = await getMap(credential, id);
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
if (err instanceof CloudError && err.status === 404 && !/project/i.test(err.message))
|
|
713
|
+
return refuse('map_not_found', { id });
|
|
714
|
+
return cloudRefusal(err, credential);
|
|
715
|
+
}
|
|
716
|
+
if (typeof remote.map_data !== 'string')
|
|
717
|
+
return refuse('not_envelope');
|
|
718
|
+
return ok({ map_data: remote.map_data, updated_at: remote.updated_at });
|
|
719
|
+
}
|
|
720
|
+
/** The chosen project's name, or its id when the server cannot say. */
|
|
721
|
+
async function projectName(credential) {
|
|
722
|
+
// VEILIO_PROJECT was resolved from a listing already: no second one.
|
|
723
|
+
// Bare: the refusal's own wording says the variable chose it.
|
|
724
|
+
if (credential.projectFrom)
|
|
725
|
+
return credential.projectName ?? credential.project;
|
|
726
|
+
try {
|
|
727
|
+
const found = (await listProjects(credential)).projects.find((p) => p.id === credential.project);
|
|
728
|
+
return found?.name ?? credential.project;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return credential.project;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** What the Cloud copy holds that the update would lose: a placeholder the
|
|
735
|
+
* local map lacks, or gives another name - the same rule a pull applies. */
|
|
736
|
+
function droppedBy(cloud, local) {
|
|
737
|
+
return Object.keys(cloud).filter((p) => local[p] !== cloud[p]);
|
|
738
|
+
}
|
|
739
|
+
// ─── remove ──────────────────────────────────────────────────────────────────
|
|
740
|
+
/**
|
|
741
|
+
* Remove a map from Cloud. Who may remove a team map is the server's decision;
|
|
742
|
+
* this says what each refusal means and changes nothing locally either way.
|
|
743
|
+
*
|
|
744
|
+
* `confirmName`, when given (the MCP server, FR-010), must equal the map's
|
|
745
|
+
* exact name, and is checked before the removal is sent. Without it the name
|
|
746
|
+
* is read only so the confirmation can say WHICH map went.
|
|
747
|
+
*/
|
|
748
|
+
export async function removeCloudMap(ctx, id, opts = {}) {
|
|
749
|
+
const stored = signedIn(ctx);
|
|
750
|
+
if (!stored)
|
|
751
|
+
return refuse('not_signed_in');
|
|
752
|
+
if (!id)
|
|
753
|
+
return refuse('which_map');
|
|
754
|
+
// A personal map belongs to no project: with VEILIO_PROJECT set it is found
|
|
755
|
+
// first, and a value that names nothing never refuses it (FR-012a).
|
|
756
|
+
const personal = projectOverride(ctx.env) === null ? null : await personalListing(stored, id);
|
|
757
|
+
let credential;
|
|
758
|
+
let name;
|
|
759
|
+
if (personal) {
|
|
760
|
+
credential = personal.credential;
|
|
761
|
+
name = personal.list.personalMaps.find((m) => m.id === id)?.name || null;
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
const scoped = await scopeCredential(stored, ctx.env);
|
|
765
|
+
if (!scoped.ok)
|
|
766
|
+
return scoped;
|
|
767
|
+
credential = scoped.value;
|
|
768
|
+
// The name from the listing, not from the map: downloading a whole
|
|
769
|
+
// envelope to read one label was a round trip and a server-side decrypt
|
|
770
|
+
// for nothing (code review). Not listed - another project's team map, or
|
|
771
|
+
// the listing failing - leaves the id as all there is to report.
|
|
772
|
+
try {
|
|
773
|
+
const list = await listMaps(credential);
|
|
774
|
+
name = [...list.personalMaps, ...list.teamMaps].find((m) => m.id === id)?.name || null;
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
name = null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (opts.confirmName !== undefined) {
|
|
781
|
+
if (name === null)
|
|
782
|
+
return refuse('confirm_unknown', { id });
|
|
783
|
+
if (opts.confirmName !== name)
|
|
784
|
+
return refuse('confirm_name', { id, name });
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
await deleteMap(credential, id);
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
if (err instanceof CloudError && err.status === 404)
|
|
791
|
+
return refuse('map_not_found', { id });
|
|
792
|
+
if (err instanceof CloudError && err.status === 403)
|
|
793
|
+
return refuse('not_yours_remove', name ? { id, name } : { id });
|
|
794
|
+
return cloudRefusal(err, credential);
|
|
795
|
+
}
|
|
796
|
+
return ok({ id, name });
|
|
797
|
+
}
|
|
798
|
+
//# sourceMappingURL=maps.js.map
|