slidemind 0.3.20 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
// The direct SharePoint adapter: read an item, prove a snapshot is consistent,
|
|
2
|
+
// and replace it under a server-enforced precondition.
|
|
3
|
+
//
|
|
4
|
+
// This module is written against the direct-endpoint shapes listed in
|
|
5
|
+
// `docs/slidemind-cli-connect/20-sharepoint-pull-publish.md`; the ones exercised
|
|
6
|
+
// live on 2026-09-16 are marked there. Every write sends `If-Match` and treats a
|
|
7
|
+
// 412 as the mechanism working; the pull-before-write in lib/publish.mjs catches
|
|
8
|
+
// a foreign save before any byte leaves.
|
|
9
|
+
//
|
|
10
|
+
// Direct endpoints are not Graph, and the differences bite exactly where the
|
|
11
|
+
// safety lives:
|
|
12
|
+
// - upload-session creation is the `oneDrive.createUploadSession` action, not
|
|
13
|
+
// Graph's `microsoft.graph.createUploadSession`;
|
|
14
|
+
// - conflict behaviour rides `@name.conflictBehavior`, not the Graph
|
|
15
|
+
// annotation;
|
|
16
|
+
// - Graph's documentation that an If-Match on session CREATION is honoured at
|
|
17
|
+
// COMMIT does not transfer, and would not prove exclusion against an Office
|
|
18
|
+
// writer even if it did.
|
|
19
|
+
//
|
|
20
|
+
// Two transport rules are absolute:
|
|
21
|
+
// 1. The bearer goes to the configured API origin and nowhere else. Download
|
|
22
|
+
// URLs and upload-session URLs are PREAUTHENTICATED — they carry their own
|
|
23
|
+
// credential in the query string, and forwarding an Authorization header to
|
|
24
|
+
// them would hand a second credential to a host we did not choose.
|
|
25
|
+
// 2. A read may be retried. A write whose outcome is unknown may NOT — it goes
|
|
26
|
+
// to inspection first, because "did my replace land?" and "should I send
|
|
27
|
+
// these bytes again?" are the same question and the wrong answer is a
|
|
28
|
+
// duplicate document or a clobbered save.
|
|
29
|
+
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export const PROVIDER = "sharepoint";
|
|
36
|
+
|
|
37
|
+
// The direct (non-Graph) API version these routes are written against. Recorded
|
|
38
|
+
// in doc 20; a tenant that answers a different one is a proof failure, not a
|
|
39
|
+
// silent fallback.
|
|
40
|
+
export const API_VERSION = "v2.1";
|
|
41
|
+
|
|
42
|
+
export const ROUTES = [
|
|
43
|
+
"read",
|
|
44
|
+
"create",
|
|
45
|
+
"rename",
|
|
46
|
+
"conditionalSmall",
|
|
47
|
+
"conditionalLarge",
|
|
48
|
+
"exactVersionRead",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const sha256 = (bytes) => createHash("sha256").update(Buffer.from(bytes)).digest("hex");
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// routes and limits
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// The documented single-PUT ceiling and upload-session chunk size. Both are
|
|
58
|
+
// fixed: a payload above `smallMaxBytes` goes through an upload session.
|
|
59
|
+
export const LIMITS = Object.freeze({ smallMaxBytes: 4 * 1024 * 1024, chunkBytes: 10 * 1024 * 1024 });
|
|
60
|
+
|
|
61
|
+
/** Every route is live. Kept as a function so the mode logic has one place to ask. */
|
|
62
|
+
export function capabilities() {
|
|
63
|
+
return { routes: Object.fromEntries(ROUTES.map((r) => [r, "supported"])), limits: LIMITS };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Which conditional route carries a payload of this size. */
|
|
67
|
+
export function routeForSize(_host, bytes) {
|
|
68
|
+
return { large: bytes > LIMITS.smallMaxBytes, smallMaxBytes: LIMITS.smallMaxBytes };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// transport
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** A URL with its query stripped — the only form a transfer URL may be printed in. */
|
|
76
|
+
export function redactUrl(url) {
|
|
77
|
+
try {
|
|
78
|
+
const u = new URL(String(url));
|
|
79
|
+
return `${u.origin}${u.pathname}${u.search ? "?<redacted>" : ""}`;
|
|
80
|
+
} catch {
|
|
81
|
+
return "<unparseable url>";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// HTTPS, or plain HTTP on loopback — the same transport rule the setup claim and
|
|
86
|
+
// the Entra authority hold to, so a local mock host can exercise these paths
|
|
87
|
+
// without loosening anything a real tenant would meet.
|
|
88
|
+
function transportAllowed(url) {
|
|
89
|
+
if (url.protocol === "https:") return true;
|
|
90
|
+
return (
|
|
91
|
+
url.protocol === "http:" &&
|
|
92
|
+
["127.0.0.1", "localhost", "::1", "[::1]"].includes(url.hostname)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertSameOrigin(origin, url) {
|
|
97
|
+
const target = new URL(url, origin);
|
|
98
|
+
if (target.origin !== new URL(origin).origin) {
|
|
99
|
+
const e = new Error(
|
|
100
|
+
`refusing to send credentials to ${redactUrl(target)} — the authorized API origin is ${new URL(origin).origin}`,
|
|
101
|
+
);
|
|
102
|
+
e.code = "origin_not_allowed";
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
if (!transportAllowed(target)) {
|
|
106
|
+
const e = new Error(`refusing a non-HTTPS SharePoint request to ${redactUrl(target)}`);
|
|
107
|
+
e.code = "origin_not_allowed";
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
return target;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
114
|
+
|
|
115
|
+
// Retry-After wins when the server sent one; otherwise exponential with jitter,
|
|
116
|
+
// so a throttled tenant is not hammered back into throttling by a fleet of
|
|
117
|
+
// synchronized clients.
|
|
118
|
+
function backoffMs(attempt, response) {
|
|
119
|
+
const header = response?.headers?.get?.("retry-after");
|
|
120
|
+
if (header) {
|
|
121
|
+
const seconds = Number(header);
|
|
122
|
+
if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000);
|
|
123
|
+
const at = Date.parse(header);
|
|
124
|
+
if (Number.isFinite(at)) return Math.max(0, Math.min(at - Date.now(), 60_000));
|
|
125
|
+
}
|
|
126
|
+
const base = Math.min(1000 * 2 ** attempt, 16_000);
|
|
127
|
+
return base / 2 + Math.random() * (base / 2);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const RETRYABLE = new Set([429, 502, 503, 504]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One authenticated request to the configured origin.
|
|
134
|
+
*
|
|
135
|
+
* `retry` is opt-in and means "this request is SAFE to repeat". Never pass it
|
|
136
|
+
* for a write: a POST/PUT/PATCH that timed out may have been applied, and the
|
|
137
|
+
* recovery for that is inspection, not another attempt.
|
|
138
|
+
*/
|
|
139
|
+
export async function apiFetch(
|
|
140
|
+
client,
|
|
141
|
+
urlPath,
|
|
142
|
+
{ method = "GET", headers = {}, body = null, retry = false, raw = false } = {},
|
|
143
|
+
) {
|
|
144
|
+
const url = assertSameOrigin(client.origin, urlPath);
|
|
145
|
+
const attempts = retry ? Number(client.maxRetries ?? 4) : 1;
|
|
146
|
+
let lastResponse = null;
|
|
147
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
148
|
+
let response;
|
|
149
|
+
try {
|
|
150
|
+
response = await client.fetch(url.toString(), {
|
|
151
|
+
method,
|
|
152
|
+
headers: {
|
|
153
|
+
accept: "application/json",
|
|
154
|
+
authorization: `Bearer ${client.token}`,
|
|
155
|
+
...headers,
|
|
156
|
+
},
|
|
157
|
+
body,
|
|
158
|
+
redirect: "manual",
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (retry && attempt < attempts - 1) {
|
|
162
|
+
await sleep(backoffMs(attempt, null));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const e = new Error(`SharePoint request failed: ${err?.message ?? err}`);
|
|
166
|
+
e.code = "offline";
|
|
167
|
+
e.cause = err;
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
lastResponse = response;
|
|
171
|
+
// A redirect off the API origin is a preauthenticated transfer destination.
|
|
172
|
+
// It is returned as a CAPABILITY for the caller to fetch without a bearer —
|
|
173
|
+
// never followed here, because `fetch`'s own redirect handling would carry
|
|
174
|
+
// the Authorization header across the hop.
|
|
175
|
+
if (response.status >= 300 && response.status < 400) {
|
|
176
|
+
const location = response.headers.get("location");
|
|
177
|
+
return { response, redirect: location ?? null, status: response.status, json: null, bytes: null };
|
|
178
|
+
}
|
|
179
|
+
if (RETRYABLE.has(response.status) && retry && attempt < attempts - 1) {
|
|
180
|
+
await sleep(backoffMs(attempt, response));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (raw) {
|
|
184
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
185
|
+
return { response, status: response.status, bytes, json: null, redirect: null };
|
|
186
|
+
}
|
|
187
|
+
const text = await response.text();
|
|
188
|
+
let json = null;
|
|
189
|
+
try {
|
|
190
|
+
json = text ? JSON.parse(text) : null;
|
|
191
|
+
} catch {
|
|
192
|
+
/* non-JSON body — kept as text on the error path */
|
|
193
|
+
}
|
|
194
|
+
return { response, status: response.status, json, text, bytes: null, redirect: null };
|
|
195
|
+
}
|
|
196
|
+
return { response: lastResponse, status: lastResponse?.status ?? 0, json: null, bytes: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Fetch a server-issued preauthenticated URL. NO bearer, ever.
|
|
201
|
+
*
|
|
202
|
+
* The URL must have arrived in a validated API response — the caller passes what
|
|
203
|
+
* the API handed it, and this refuses anything that is not HTTPS. The query
|
|
204
|
+
* string is the credential, so it never reaches a log line: every error here
|
|
205
|
+
* names the redacted form.
|
|
206
|
+
*/
|
|
207
|
+
export async function transferFetch(client, url, { retry = true } = {}) {
|
|
208
|
+
let target;
|
|
209
|
+
try {
|
|
210
|
+
target = new URL(String(url));
|
|
211
|
+
} catch {
|
|
212
|
+
const e = new Error("the API returned a transfer destination that is not a URL");
|
|
213
|
+
e.code = "invalid_transfer_url";
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
if (!transportAllowed(target)) {
|
|
217
|
+
const e = new Error(`refusing a non-HTTPS transfer destination (${redactUrl(target)})`);
|
|
218
|
+
e.code = "invalid_transfer_url";
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
const attempts = retry ? Number(client.maxRetries ?? 4) : 1;
|
|
222
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
223
|
+
let response;
|
|
224
|
+
try {
|
|
225
|
+
// `redirect: "follow"` is safe HERE and only here: no Authorization header
|
|
226
|
+
// is attached, so a redirect cannot leak a credential. It can only ever
|
|
227
|
+
// spend the URL's own embedded one, which is what it is for.
|
|
228
|
+
response = await client.fetch(target.toString(), { redirect: "follow" });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (attempt < attempts - 1) {
|
|
231
|
+
await sleep(backoffMs(attempt, null));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const e = new Error(`could not fetch ${redactUrl(target)}: ${err?.message ?? err}`);
|
|
235
|
+
e.code = "offline";
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
if (RETRYABLE.has(response.status) && attempt < attempts - 1) {
|
|
239
|
+
await sleep(backoffMs(attempt, response));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (response.status < 200 || response.status >= 300) {
|
|
243
|
+
const e = new Error(`${redactUrl(target)} answered ${response.status}`);
|
|
244
|
+
e.code = "transfer_failed";
|
|
245
|
+
e.status = response.status;
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
return Buffer.from(await response.arrayBuffer());
|
|
249
|
+
}
|
|
250
|
+
const e = new Error(`could not fetch ${redactUrl(target)}`);
|
|
251
|
+
e.code = "offline";
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// the client
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A client bound to one SharePoint origin and one delegated token.
|
|
261
|
+
*
|
|
262
|
+
* `fetch` is injected so the mock-host suite can drive every branch — redirects,
|
|
263
|
+
* 412s, name conflicts, equal-size races — against a local `node:http` server
|
|
264
|
+
* without a tenant.
|
|
265
|
+
*/
|
|
266
|
+
export function createClient({ origin, token, fetch: fetchImpl = globalThis.fetch, maxRetries = 4 }) {
|
|
267
|
+
if (!origin) throw new Error("createClient: origin is required");
|
|
268
|
+
if (!token) {
|
|
269
|
+
const e = new Error(
|
|
270
|
+
"no SharePoint access token — run `slidemind login` (one sign-in mints both resources).",
|
|
271
|
+
);
|
|
272
|
+
e.code = "write_scope_missing";
|
|
273
|
+
throw e;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
origin: String(origin).replace(/\/$/, ""),
|
|
277
|
+
token,
|
|
278
|
+
fetch: fetchImpl,
|
|
279
|
+
maxRetries,
|
|
280
|
+
host: new URL(origin).origin,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Item metadata is requested by explicit field list: a `$select` keeps the
|
|
285
|
+
// response small and, more importantly, makes doc 20's "fields relied upon"
|
|
286
|
+
// list checkable against the code rather than aspirational.
|
|
287
|
+
const ITEM_FIELDS = [
|
|
288
|
+
"id",
|
|
289
|
+
"name",
|
|
290
|
+
"eTag",
|
|
291
|
+
"cTag",
|
|
292
|
+
"size",
|
|
293
|
+
"webUrl",
|
|
294
|
+
"lastModifiedDateTime",
|
|
295
|
+
"parentReference",
|
|
296
|
+
"file",
|
|
297
|
+
"folder",
|
|
298
|
+
"deleted",
|
|
299
|
+
].join(",");
|
|
300
|
+
|
|
301
|
+
// The folder facet plus SharePoint ids, asked for only where a folder is the
|
|
302
|
+
// subject (destination lookup, child listing). Kept off ITEM_FIELDS so the
|
|
303
|
+
// routes proved live on 2026-09-16 keep their observed `$select` verbatim.
|
|
304
|
+
const FOLDER_FIELDS = `${ITEM_FIELDS},sharepointIds`;
|
|
305
|
+
|
|
306
|
+
function itemBase(client, { driveId, itemId }) {
|
|
307
|
+
return `/_api/${API_VERSION}/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The normalized metadata every other call compares on. */
|
|
311
|
+
export function normalizeItem(json) {
|
|
312
|
+
if (!json) return null;
|
|
313
|
+
return {
|
|
314
|
+
itemId: json.id ?? null,
|
|
315
|
+
driveId: json.parentReference?.driveId ?? null,
|
|
316
|
+
// The folder a first-create and a content-addressed artifact land in. A
|
|
317
|
+
// label, like `name` — publishing never routes on it.
|
|
318
|
+
parentId: json.parentReference?.id ?? null,
|
|
319
|
+
parentPath: json.parentReference?.path ?? null,
|
|
320
|
+
name: json.name ?? null,
|
|
321
|
+
// The PUT (create) response hands back an HTML-escaped webUrl; GET does not.
|
|
322
|
+
webUrl: json.webUrl ? String(json.webUrl).replace(/&/g, "&") : null,
|
|
323
|
+
// eTag changes on ANY change (metadata included); cTag only on content.
|
|
324
|
+
// Which one a check uses is load-bearing — see `sameSnapshot`.
|
|
325
|
+
eTag: json.eTag ?? null,
|
|
326
|
+
cTag: json.cTag ?? null,
|
|
327
|
+
size: typeof json.size === "number" ? json.size : null,
|
|
328
|
+
lastModifiedDateTime: json.lastModifiedDateTime ?? null,
|
|
329
|
+
deleted: Boolean(json.deleted),
|
|
330
|
+
// One of the two facets says what the item IS; anything else (a package,
|
|
331
|
+
// a notebook) is neither and is not offered as a destination or a deck.
|
|
332
|
+
kind: json.folder ? "folder" : json.file ? "file" : null,
|
|
333
|
+
childCount: typeof json.folder?.childCount === "number" ? json.folder.childCount : null,
|
|
334
|
+
mimeType: json.file?.mimeType ?? null,
|
|
335
|
+
tenantId: json.sharepointIds?.tenantId ?? null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function providerError(status, json, { on }) {
|
|
340
|
+
const detail = json?.error?.message ?? json?.error_description ?? `HTTP ${status}`;
|
|
341
|
+
const e = new Error(`SharePoint ${on} failed (${status}): ${detail}`);
|
|
342
|
+
e.status = status;
|
|
343
|
+
e.providerCode = json?.error?.code ?? null;
|
|
344
|
+
e.on = on;
|
|
345
|
+
return e;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Resolve the item a user's link names. The LINK selects the target — nothing else does. */
|
|
349
|
+
export async function resolveLink(client, link) {
|
|
350
|
+
const encoded = Buffer.from(String(link), "utf8")
|
|
351
|
+
.toString("base64")
|
|
352
|
+
.replace(/=+$/, "")
|
|
353
|
+
.replace(/\//g, "_")
|
|
354
|
+
.replace(/\+/g, "-");
|
|
355
|
+
const res = await apiFetch(
|
|
356
|
+
client,
|
|
357
|
+
`/_api/${API_VERSION}/shares/u!${encoded}/driveItem?$select=${ITEM_FIELDS}`,
|
|
358
|
+
{ retry: true },
|
|
359
|
+
);
|
|
360
|
+
if (res.status < 200 || res.status >= 300) throw providerError(res.status, res.json, { on: "link resolution" });
|
|
361
|
+
const item = normalizeItem(res.json);
|
|
362
|
+
if (!item?.itemId || !item?.driveId) {
|
|
363
|
+
const e = new Error("that link resolved to something without a drive item identity");
|
|
364
|
+
e.code = "identity_unresolved";
|
|
365
|
+
throw e;
|
|
366
|
+
}
|
|
367
|
+
return item;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function getItem(client, ref, { fields = ITEM_FIELDS } = {}) {
|
|
371
|
+
const res = await apiFetch(client, `${itemBase(client, ref)}?$select=${fields}`, {
|
|
372
|
+
retry: true,
|
|
373
|
+
});
|
|
374
|
+
if (res.status === 404) {
|
|
375
|
+
const e = new Error("the bound item is gone from SharePoint");
|
|
376
|
+
e.code = "remote_missing";
|
|
377
|
+
e.status = 404;
|
|
378
|
+
throw e;
|
|
379
|
+
}
|
|
380
|
+
if (res.status < 200 || res.status >= 300) throw providerError(res.status, res.json, { on: "metadata read" });
|
|
381
|
+
return normalizeItem(res.json);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Do two metadata reads describe the same stored content?
|
|
386
|
+
*
|
|
387
|
+
* Content identity is `cTag` where the provider gives one, because an eTag also
|
|
388
|
+
* moves on a pure metadata change and would send a pull chasing bytes nobody
|
|
389
|
+
* touched. Size alone is never enough — that is the equal-size race the spec
|
|
390
|
+
* names — so it is an extra check, not the check.
|
|
391
|
+
*/
|
|
392
|
+
export function sameSnapshot(a, b) {
|
|
393
|
+
if (!a || !b) return false;
|
|
394
|
+
if (a.cTag && b.cTag) return a.cTag === b.cTag && a.size === b.size;
|
|
395
|
+
return a.eTag === b.eTag && a.size === b.size;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* One consistent snapshot of the item's bytes, with the revision they belong to.
|
|
400
|
+
*
|
|
401
|
+
* Preferred route: pin an exact version and read THAT — a version, once written,
|
|
402
|
+
* does not change under the read. Where exact-version reads are unproved, the
|
|
403
|
+
* fallback is metadata A → bytes → metadata B, and the two metadata reads must
|
|
404
|
+
* agree; if they do not, the attempt is discarded and retried, bounded. Bytes
|
|
405
|
+
* are never labelled with an eTag that was not proved to describe them.
|
|
406
|
+
*/
|
|
407
|
+
export async function downloadSnapshot(client, ref, { versionId = null, attempts = 3 } = {}) {
|
|
408
|
+
if (versionId) {
|
|
409
|
+
const bytes = await downloadVersion(client, ref, versionId);
|
|
410
|
+
const meta = await getItem(client, ref);
|
|
411
|
+
return { bytes, revision: { ...meta, versionId }, pinned: true, sha256: sha256(bytes) };
|
|
412
|
+
}
|
|
413
|
+
let lastBefore = null;
|
|
414
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
415
|
+
const before = await getItem(client, ref);
|
|
416
|
+
lastBefore = before;
|
|
417
|
+
const bytes = await downloadContent(client, ref);
|
|
418
|
+
const after = await getItem(client, ref);
|
|
419
|
+
if (sameSnapshot(before, after)) {
|
|
420
|
+
return { bytes, revision: after, pinned: false, sha256: sha256(bytes) };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const e = new Error(
|
|
424
|
+
"the item kept changing while it was being read — nothing was staged. Try again in a moment.",
|
|
425
|
+
);
|
|
426
|
+
e.code = "snapshot_raced";
|
|
427
|
+
e.lastSeen = lastBefore;
|
|
428
|
+
throw e;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function downloadContent(client, ref) {
|
|
432
|
+
const res = await apiFetch(client, `${itemBase(client, ref)}/content`, {
|
|
433
|
+
retry: true,
|
|
434
|
+
raw: true,
|
|
435
|
+
});
|
|
436
|
+
// The documented shape: the content route answers a 302 to a short-lived
|
|
437
|
+
// preauthenticated URL. The bearer stops here; the URL carries its own.
|
|
438
|
+
if (res.redirect) return transferFetch(client, res.redirect);
|
|
439
|
+
if (res.status < 200 || res.status >= 300) throw providerError(res.status, null, { on: "content read" });
|
|
440
|
+
return res.bytes;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* One item by name inside a folder, or null when there is none.
|
|
445
|
+
*
|
|
446
|
+
* This is how a content-addressed artifact is REUSED rather than uploaded
|
|
447
|
+
* again: a name that already exists is fetched and its bytes compared, so an
|
|
448
|
+
* unchanged context costs a metadata read instead of a new stored object. Null
|
|
449
|
+
* on 404 — "not there" is the ordinary answer here, not a failure.
|
|
450
|
+
*/
|
|
451
|
+
export async function getItemByPath(client, { driveId, parentId }, name) {
|
|
452
|
+
const res = await apiFetch(
|
|
453
|
+
client,
|
|
454
|
+
`/_api/${API_VERSION}/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(parentId)}` +
|
|
455
|
+
`:/${encodeURIComponent(name)}?$select=${ITEM_FIELDS}`,
|
|
456
|
+
{ retry: true },
|
|
457
|
+
);
|
|
458
|
+
if (res.status === 404) return null;
|
|
459
|
+
if (res.status < 200 || res.status >= 300) throw providerError(res.status, res.json, { on: "folder lookup" });
|
|
460
|
+
return normalizeItem(res.json);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// folders — destination lookup and bounded child listing
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
// The daemon's "Open SharePoint link → pick a child" flow reads ONE level at a
|
|
468
|
+
// time. There is no recursion and no `$expand`: a library with ten thousand
|
|
469
|
+
// items costs the user a page, not the CLI a crawl. Doc 20 R7/R8.
|
|
470
|
+
|
|
471
|
+
/** How many children one page carries. The cursor continues from there. */
|
|
472
|
+
export const CHILD_PAGE_SIZE = 50;
|
|
473
|
+
|
|
474
|
+
function coded(code, message) {
|
|
475
|
+
const e = new Error(message);
|
|
476
|
+
e.code = code;
|
|
477
|
+
return e;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A ref is `{driveId,itemId}` already, or a link the user pasted. */
|
|
481
|
+
async function folderRef(client, ref) {
|
|
482
|
+
if (ref && typeof ref === "object" && ref.driveId && ref.itemId) return ref;
|
|
483
|
+
if (typeof ref === "string") {
|
|
484
|
+
const item = await resolveLink(client, ref);
|
|
485
|
+
return { driveId: item.driveId, itemId: item.itemId };
|
|
486
|
+
}
|
|
487
|
+
throw coded("validation_failed", "a folder is named by {driveId,itemId} or a link");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The folder a plan or deck would be created in, with the identity a
|
|
492
|
+
* destination record stores. A file is refused here rather than at the first
|
|
493
|
+
* write: `not_a_folder` is the honest answer to "connect this deck to that link".
|
|
494
|
+
*/
|
|
495
|
+
export async function getFolder(client, ref) {
|
|
496
|
+
const item = await getItem(client, await folderRef(client, ref), { fields: FOLDER_FIELDS });
|
|
497
|
+
if (item.kind !== "folder") throw coded("not_a_folder", `${item.name ?? "that item"} is not a folder`);
|
|
498
|
+
return {
|
|
499
|
+
id: item.itemId,
|
|
500
|
+
driveId: item.driveId,
|
|
501
|
+
parentId: item.parentId,
|
|
502
|
+
name: item.name,
|
|
503
|
+
webUrl: item.webUrl,
|
|
504
|
+
eTag: item.eTag,
|
|
505
|
+
childCount: item.childCount,
|
|
506
|
+
tenantId: item.tenantId,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Is `name` already taken inside the folder? R6 by another door: 404 is
|
|
512
|
+
* "free", anything found is handed back so the caller can offer "open the
|
|
513
|
+
* existing one" instead of silently creating a sibling.
|
|
514
|
+
*/
|
|
515
|
+
export async function resolveDestination(client, ref, name) {
|
|
516
|
+
const { driveId, itemId } = await folderRef(client, ref);
|
|
517
|
+
const item = await getItemByPath(client, { driveId, parentId: itemId }, name);
|
|
518
|
+
return { exists: item !== null, item };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const encodeCursor = (o) => Buffer.from(JSON.stringify(o), "utf8").toString("base64url");
|
|
522
|
+
|
|
523
|
+
// A cursor is OPAQUE to the caller and UNTRUSTED here: it comes back from a
|
|
524
|
+
// browser or a job file, so the next-link inside it is checked against the
|
|
525
|
+
// folder it was issued for and the origin the bearer is allowed to reach before
|
|
526
|
+
// a single byte is sent. A forged cursor cannot walk another folder or carry
|
|
527
|
+
// the token to another host.
|
|
528
|
+
function decodeCursor(client, cursor, { driveId, itemId }) {
|
|
529
|
+
let parsed;
|
|
530
|
+
try {
|
|
531
|
+
parsed = JSON.parse(Buffer.from(String(cursor), "base64url").toString("utf8"));
|
|
532
|
+
} catch {
|
|
533
|
+
throw coded("validation_failed", "that page cursor is not one this listing issued");
|
|
534
|
+
}
|
|
535
|
+
if (parsed?.driveId !== driveId || parsed?.itemId !== itemId || typeof parsed?.nextLink !== "string") {
|
|
536
|
+
throw coded("validation_failed", "that page cursor belongs to a different folder");
|
|
537
|
+
}
|
|
538
|
+
let next;
|
|
539
|
+
try {
|
|
540
|
+
next = new URL(parsed.nextLink);
|
|
541
|
+
} catch {
|
|
542
|
+
throw coded("validation_failed", "that page cursor carries no usable next page");
|
|
543
|
+
}
|
|
544
|
+
const childrenPath = `${itemBase(client, { driveId, itemId })}/children`;
|
|
545
|
+
if (next.origin !== client.host || next.pathname !== childrenPath) {
|
|
546
|
+
throw coded("access_denied", `refusing to continue a listing at ${redactUrl(next)}`);
|
|
547
|
+
}
|
|
548
|
+
return `${next.pathname}${next.search}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* One page of a folder's IMMEDIATE children — folders and files only, `pageSize`
|
|
553
|
+
* at a time, continued through the returned `nextCursor` and never recursed.
|
|
554
|
+
*/
|
|
555
|
+
export async function listChildren(client, ref, { cursor = null, pageSize = CHILD_PAGE_SIZE } = {}) {
|
|
556
|
+
const { driveId, itemId } = await folderRef(client, ref);
|
|
557
|
+
const top = Math.min(Math.max(1, Number(pageSize) || CHILD_PAGE_SIZE), CHILD_PAGE_SIZE);
|
|
558
|
+
const urlPath = cursor
|
|
559
|
+
? decodeCursor(client, cursor, { driveId, itemId })
|
|
560
|
+
: `${itemBase(client, { driveId, itemId })}/children?$select=${FOLDER_FIELDS}&$top=${top}`;
|
|
561
|
+
const res = await apiFetch(client, urlPath, { retry: true });
|
|
562
|
+
if (res.status === 404) throw coded("remote_missing", "that folder is gone from SharePoint");
|
|
563
|
+
if (res.status === 403) throw coded("access_denied", "you cannot list that folder");
|
|
564
|
+
if (res.status < 200 || res.status >= 300) throw providerError(res.status, res.json, { on: "folder listing" });
|
|
565
|
+
const items = (res.json?.value ?? [])
|
|
566
|
+
.map(normalizeItem)
|
|
567
|
+
.filter((i) => i.kind && !i.deleted)
|
|
568
|
+
.map((i) => ({
|
|
569
|
+
id: i.itemId,
|
|
570
|
+
driveId: i.driveId ?? driveId,
|
|
571
|
+
name: i.name,
|
|
572
|
+
kind: i.kind,
|
|
573
|
+
...(i.kind === "folder" ? { childCount: i.childCount } : { size: i.size, mimeType: i.mimeType }),
|
|
574
|
+
webUrl: i.webUrl,
|
|
575
|
+
eTag: i.eTag,
|
|
576
|
+
lastModified: i.lastModifiedDateTime,
|
|
577
|
+
}));
|
|
578
|
+
const nextLink = res.json?.["@odata.nextLink"];
|
|
579
|
+
return {
|
|
580
|
+
items,
|
|
581
|
+
nextCursor: typeof nextLink === "string" && nextLink ? encodeCursor({ driveId, itemId, nextLink }) : null,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function listVersions(client, ref) {
|
|
586
|
+
const res = await apiFetch(client, `${itemBase(client, ref)}/versions`, { retry: true });
|
|
587
|
+
if (res.status < 200 || res.status >= 300) throw providerError(res.status, res.json, { on: "version list" });
|
|
588
|
+
return (res.json?.value ?? []).map((v) => ({
|
|
589
|
+
versionId: v.id ?? null,
|
|
590
|
+
size: typeof v.size === "number" ? v.size : null,
|
|
591
|
+
lastModifiedDateTime: v.lastModifiedDateTime ?? null,
|
|
592
|
+
lastModifiedBy: v.lastModifiedBy?.user?.displayName ?? null,
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function downloadVersion(client, ref, versionId) {
|
|
597
|
+
const res = await apiFetch(
|
|
598
|
+
client,
|
|
599
|
+
`${itemBase(client, ref)}/versions/${encodeURIComponent(versionId)}/content`,
|
|
600
|
+
{ retry: true, raw: true },
|
|
601
|
+
);
|
|
602
|
+
if (res.redirect) return transferFetch(client, res.redirect);
|
|
603
|
+
if (res.status < 200 || res.status >= 300) {
|
|
604
|
+
throw providerError(res.status, null, { on: `version ${versionId} read` });
|
|
605
|
+
}
|
|
606
|
+
return res.bytes;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// writes
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
// Every write answers in this shape. `outcome` is the only field a caller may
|
|
614
|
+
// branch on, and `unknown` is a first-class answer — not an exception to swallow.
|
|
615
|
+
function landed(route, item, version) {
|
|
616
|
+
// A 2xx whose body was empty or unparseable normalizes to `null`, and a receipt
|
|
617
|
+
// with no item and no version is indistinguishable from one that never happened:
|
|
618
|
+
// publish would skip its identity check, keep the previous acceptedETag, and read
|
|
619
|
+
// its own save back as a foreign one forever after. Unconfirmed IS `unknown`.
|
|
620
|
+
if (!item?.itemId) return unknown(route, "the write answered no item metadata, so its outcome is unconfirmed");
|
|
621
|
+
return { outcome: "committed", route, item, version };
|
|
622
|
+
}
|
|
623
|
+
function refused(route, reason, detail = {}) {
|
|
624
|
+
return { outcome: "refused", route, reason, ...detail };
|
|
625
|
+
}
|
|
626
|
+
function unknown(route, why) {
|
|
627
|
+
return { outcome: "unknown", route, why };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Replace an item's bytes under a server-enforced precondition, small route.
|
|
632
|
+
*
|
|
633
|
+
* `ifMatch` is the eTag the caller proved was current moments ago. A 412 is the
|
|
634
|
+
* SUCCESS of this mechanism, not its failure: somebody else's save is in the
|
|
635
|
+
* file and the caller must rebase rather than overwrite.
|
|
636
|
+
*/
|
|
637
|
+
export async function replaceSmall(client, ref, bytes, { ifMatch }) {
|
|
638
|
+
if (!ifMatch) {
|
|
639
|
+
const e = new Error("a conditional replace needs the eTag it expects to replace");
|
|
640
|
+
e.code = "conditional_write_unsupported";
|
|
641
|
+
throw e;
|
|
642
|
+
}
|
|
643
|
+
let res;
|
|
644
|
+
try {
|
|
645
|
+
res = await apiFetch(client, `${itemBase(client, ref)}/content`, {
|
|
646
|
+
method: "PUT",
|
|
647
|
+
headers: {
|
|
648
|
+
"content-type":
|
|
649
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
650
|
+
"if-match": ifMatch,
|
|
651
|
+
},
|
|
652
|
+
body: bytes,
|
|
653
|
+
// NEVER retried: a PUT that timed out may already be the stored version.
|
|
654
|
+
retry: false,
|
|
655
|
+
});
|
|
656
|
+
} catch (err) {
|
|
657
|
+
return unknown("conditionalSmall", err?.message ?? String(err));
|
|
658
|
+
}
|
|
659
|
+
if (res.status === 412) return refused("conditionalSmall", "needs_reconcile");
|
|
660
|
+
if (res.status === 403) return refused("conditionalSmall", "target_access_denied", { status: 403 });
|
|
661
|
+
if (res.status === 404) return refused("conditionalSmall", "remote_missing", { status: 404 });
|
|
662
|
+
if (res.status === 429 || res.status === 503) {
|
|
663
|
+
return refused("conditionalSmall", "throttled", { status: res.status });
|
|
664
|
+
}
|
|
665
|
+
if (res.status < 200 || res.status >= 300) {
|
|
666
|
+
// A response we cannot classify is not a failure we may assume: the write
|
|
667
|
+
// may have been applied on the far side of whatever went wrong.
|
|
668
|
+
return unknown("conditionalSmall", `HTTP ${res.status}`);
|
|
669
|
+
}
|
|
670
|
+
const item = normalizeItem(res.json);
|
|
671
|
+
return landed("conditionalSmall", item, {
|
|
672
|
+
eTag: item?.eTag ?? null,
|
|
673
|
+
cTag: item?.cTag ?? null,
|
|
674
|
+
size: item?.size ?? null,
|
|
675
|
+
lastModifiedDateTime: item?.lastModifiedDateTime ?? null,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Replace an item's bytes through an upload session, large route.
|
|
681
|
+
*
|
|
682
|
+
* The precondition rides BOTH the session creation and the final chunk, because
|
|
683
|
+
* which of the two the server enforces at visibility has not been observed
|
|
684
|
+
* (doc 20 §0.2); the belt-and-braces is free.
|
|
685
|
+
*/
|
|
686
|
+
export async function replaceLarge(client, ref, bytes, { ifMatch, onProgress = null }) {
|
|
687
|
+
if (!ifMatch) {
|
|
688
|
+
const e = new Error("a conditional replace needs the eTag it expects to replace");
|
|
689
|
+
e.code = "conditional_write_unsupported";
|
|
690
|
+
throw e;
|
|
691
|
+
}
|
|
692
|
+
const chunkBytes = LIMITS.chunkBytes;
|
|
693
|
+
let session;
|
|
694
|
+
try {
|
|
695
|
+
const res = await apiFetch(client, `${itemBase(client, ref)}/oneDrive.createUploadSession`, {
|
|
696
|
+
method: "POST",
|
|
697
|
+
headers: { "content-type": "application/json", "if-match": ifMatch },
|
|
698
|
+
body: JSON.stringify({ item: { "@name.conflictBehavior": "replace" } }),
|
|
699
|
+
retry: false,
|
|
700
|
+
});
|
|
701
|
+
if (res.status === 412) return refused("conditionalLarge", "needs_reconcile");
|
|
702
|
+
if (res.status < 200 || res.status >= 300) {
|
|
703
|
+
return unknown("conditionalLarge", `session creation answered ${res.status}`);
|
|
704
|
+
}
|
|
705
|
+
session = res.json;
|
|
706
|
+
} catch (err) {
|
|
707
|
+
// Session creation alone changes nothing visible, but saying so would be a
|
|
708
|
+
// guess — a created session is a server-side resource with our name on it.
|
|
709
|
+
return unknown("conditionalLarge", err?.message ?? String(err));
|
|
710
|
+
}
|
|
711
|
+
const uploadUrl = session?.uploadUrl;
|
|
712
|
+
if (!uploadUrl) return unknown("conditionalLarge", "the session carried no uploadUrl");
|
|
713
|
+
// Every other server-supplied destination is transport-checked before we send to
|
|
714
|
+
// it (assertSameOrigin, transferFetch). This one carries the whole deck, so an
|
|
715
|
+
// API answer naming an http:// or otherwise unacceptable uploadUrl must not get
|
|
716
|
+
// the bytes in cleartext just because no bearer rides along.
|
|
717
|
+
let uploadTarget;
|
|
718
|
+
try {
|
|
719
|
+
uploadTarget = new URL(String(uploadUrl));
|
|
720
|
+
} catch {
|
|
721
|
+
return unknown("conditionalLarge", "the session carried an uploadUrl that is not a URL");
|
|
722
|
+
}
|
|
723
|
+
if (!transportAllowed(uploadTarget)) {
|
|
724
|
+
return unknown("conditionalLarge", `refusing a non-HTTPS upload destination (${redactUrl(uploadTarget)})`);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const total = bytes.length;
|
|
728
|
+
for (let offset = 0; offset < total; offset += chunkBytes) {
|
|
729
|
+
const end = Math.min(offset + chunkBytes, total);
|
|
730
|
+
const last = end === total;
|
|
731
|
+
const chunk = bytes.subarray(offset, end);
|
|
732
|
+
let response;
|
|
733
|
+
try {
|
|
734
|
+
// The session URL is preauthenticated: no bearer. Same rule as a download.
|
|
735
|
+
response = await client.fetch(uploadTarget.toString(), {
|
|
736
|
+
method: "PUT",
|
|
737
|
+
headers: {
|
|
738
|
+
"content-length": String(chunk.length),
|
|
739
|
+
"content-range": `bytes ${offset}-${end - 1}/${total}`,
|
|
740
|
+
// The precondition again, on the request that makes the bytes visible.
|
|
741
|
+
...(last ? { "if-match": ifMatch } : {}),
|
|
742
|
+
},
|
|
743
|
+
body: chunk,
|
|
744
|
+
redirect: "follow",
|
|
745
|
+
});
|
|
746
|
+
} catch (err) {
|
|
747
|
+
return unknown("conditionalLarge", `chunk at ${offset} failed: ${err?.message ?? err}`);
|
|
748
|
+
}
|
|
749
|
+
if (response.status === 412) return refused("conditionalLarge", "needs_reconcile");
|
|
750
|
+
if (!last) {
|
|
751
|
+
if (response.status !== 202 && !(response.status >= 200 && response.status < 300)) {
|
|
752
|
+
return unknown("conditionalLarge", `chunk at ${offset} answered ${response.status}`);
|
|
753
|
+
}
|
|
754
|
+
onProgress?.({ uploaded: end, total });
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (response.status < 200 || response.status >= 300) {
|
|
758
|
+
return unknown("conditionalLarge", `the final chunk answered ${response.status}`);
|
|
759
|
+
}
|
|
760
|
+
let json = null;
|
|
761
|
+
try {
|
|
762
|
+
json = JSON.parse(await response.text());
|
|
763
|
+
} catch {
|
|
764
|
+
return unknown("conditionalLarge", "the commit answered a body that is not JSON");
|
|
765
|
+
}
|
|
766
|
+
const item = normalizeItem(json);
|
|
767
|
+
onProgress?.({ uploaded: total, total });
|
|
768
|
+
return landed("conditionalLarge", item, {
|
|
769
|
+
eTag: item?.eTag ?? null,
|
|
770
|
+
cTag: item?.cTag ?? null,
|
|
771
|
+
size: item?.size ?? null,
|
|
772
|
+
lastModifiedDateTime: item?.lastModifiedDateTime ?? null,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return unknown("conditionalLarge", "the upload ended without a commit response");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Create a new item, refusing an existing name.
|
|
780
|
+
*
|
|
781
|
+
* `fail` rather than `rename`: the destination folder is someone's actual
|
|
782
|
+
* document library, and a silent `deck 1.pptx` next to their `deck.pptx`
|
|
783
|
+
* is how two versions of one deck start circulating.
|
|
784
|
+
*/
|
|
785
|
+
export async function createConflictFail(client, { driveId, parentId }, name, bytes) {
|
|
786
|
+
const url =
|
|
787
|
+
`/_api/${API_VERSION}/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(parentId)}` +
|
|
788
|
+
`:/${encodeURIComponent(name)}:/content?%40name.conflictBehavior=fail`;
|
|
789
|
+
let res;
|
|
790
|
+
try {
|
|
791
|
+
res = await apiFetch(client, url, {
|
|
792
|
+
method: "PUT",
|
|
793
|
+
headers: {
|
|
794
|
+
"content-type":
|
|
795
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
796
|
+
},
|
|
797
|
+
body: bytes,
|
|
798
|
+
retry: false,
|
|
799
|
+
});
|
|
800
|
+
} catch (err) {
|
|
801
|
+
return unknown("create", err?.message ?? String(err));
|
|
802
|
+
}
|
|
803
|
+
if (res.status === 409) return refused("create", "name_conflict", { name });
|
|
804
|
+
if (res.status === 403) return refused("create", "target_access_denied", { status: 403 });
|
|
805
|
+
if (res.status < 200 || res.status >= 300) return unknown("create", `HTTP ${res.status}`);
|
|
806
|
+
const item = normalizeItem(res.json);
|
|
807
|
+
return landed("create", item, {
|
|
808
|
+
eTag: item?.eTag ?? null,
|
|
809
|
+
cTag: item?.cTag ?? null,
|
|
810
|
+
size: item?.size ?? null,
|
|
811
|
+
lastModifiedDateTime: item?.lastModifiedDateTime ?? null,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Rename the bound item under its expected revision. Identity does not move, no
|
|
817
|
+
* second file is created, and an existing name refuses.
|
|
818
|
+
*/
|
|
819
|
+
export async function renameItem(client, ref, name, { ifMatch }) {
|
|
820
|
+
if (!ifMatch) {
|
|
821
|
+
const e = new Error("a rename needs the revision it expects to rename");
|
|
822
|
+
e.code = "conditional_write_unsupported";
|
|
823
|
+
throw e;
|
|
824
|
+
}
|
|
825
|
+
let res;
|
|
826
|
+
try {
|
|
827
|
+
res = await apiFetch(client, itemBase(client, ref), {
|
|
828
|
+
method: "PATCH",
|
|
829
|
+
headers: { "content-type": "application/json", "if-match": ifMatch },
|
|
830
|
+
body: JSON.stringify({ name, "@name.conflictBehavior": "fail" }),
|
|
831
|
+
retry: false,
|
|
832
|
+
});
|
|
833
|
+
} catch (err) {
|
|
834
|
+
return unknown("rename", err?.message ?? String(err));
|
|
835
|
+
}
|
|
836
|
+
if (res.status === 412) return refused("rename", "needs_reconcile");
|
|
837
|
+
if (res.status === 409) return refused("rename", "name_conflict", { name });
|
|
838
|
+
if (res.status === 403) return refused("rename", "target_access_denied", { status: 403 });
|
|
839
|
+
if (res.status === 404) return refused("rename", "remote_missing", { status: 404 });
|
|
840
|
+
if (res.status < 200 || res.status >= 300) return unknown("rename", `HTTP ${res.status}`);
|
|
841
|
+
const item = normalizeItem(res.json);
|
|
842
|
+
return landed("rename", item, {
|
|
843
|
+
eTag: item?.eTag ?? null,
|
|
844
|
+
cTag: item?.cTag ?? null,
|
|
845
|
+
size: item?.size ?? null,
|
|
846
|
+
lastModifiedDateTime: item?.lastModifiedDateTime ?? null,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// unknown-outcome inspection
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Did OUR write land?
|
|
856
|
+
*
|
|
857
|
+
* Answered from content identity, never from a timestamp and never from "someone
|
|
858
|
+
* saved after we started". `expectedSha256` is the candidate we were uploading;
|
|
859
|
+
* if a version of the item hashes to it, that version is ours. A newer version
|
|
860
|
+
* on top of it does not make ours a failure — it makes ours superseded, which is
|
|
861
|
+
* a different thing with a different remedy (stage the newer one, keep the
|
|
862
|
+
* receipt for the version that actually landed).
|
|
863
|
+
*
|
|
864
|
+
* Returns:
|
|
865
|
+
* { landed: true, version, superseded } our bytes are in the history
|
|
866
|
+
* { landed: false, current } they are not, and a retry is safe
|
|
867
|
+
* { landed: null, why } we could not tell — do NOT resend
|
|
868
|
+
*/
|
|
869
|
+
export async function inspectWriteOutcome(client, ref, { expectedSha256, expectedBytes = null }) {
|
|
870
|
+
let current;
|
|
871
|
+
try {
|
|
872
|
+
current = await getItem(client, ref);
|
|
873
|
+
} catch (err) {
|
|
874
|
+
if (err?.code === "remote_missing") return { landed: false, current: null, missing: true };
|
|
875
|
+
return { landed: null, why: err?.message ?? String(err) };
|
|
876
|
+
}
|
|
877
|
+
// The cheap case first: the item as it stands IS our bytes.
|
|
878
|
+
try {
|
|
879
|
+
const head = await downloadSnapshot(client, ref);
|
|
880
|
+
if (head.sha256 === expectedSha256) {
|
|
881
|
+
return { landed: true, version: head.revision, superseded: false, current };
|
|
882
|
+
}
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return { landed: null, why: err?.message ?? String(err), current };
|
|
885
|
+
}
|
|
886
|
+
let versions;
|
|
887
|
+
try {
|
|
888
|
+
versions = await listVersions(client, ref);
|
|
889
|
+
} catch (err) {
|
|
890
|
+
return { landed: null, why: err?.message ?? String(err), current };
|
|
891
|
+
}
|
|
892
|
+
for (const version of versions) {
|
|
893
|
+
// Size is a cheap prefilter only; identity is the hash. A version of another
|
|
894
|
+
// LENGTH cannot hash to ours, so skipping it saves downloading a whole deck —
|
|
895
|
+
// and this loop runs on the recovery path, where the history can be long.
|
|
896
|
+
// Only ever skips what the hash below would have rejected anyway.
|
|
897
|
+
if (expectedBytes != null && typeof version.size === "number" && version.size !== expectedBytes) continue;
|
|
898
|
+
try {
|
|
899
|
+
const bytes = await downloadVersion(client, ref, version.versionId);
|
|
900
|
+
if (sha256(bytes) === expectedSha256) {
|
|
901
|
+
return { landed: true, version, superseded: true, current };
|
|
902
|
+
}
|
|
903
|
+
} catch {
|
|
904
|
+
return {
|
|
905
|
+
landed: null,
|
|
906
|
+
why: `version ${version.versionId} could not be read, so the history is incomplete`,
|
|
907
|
+
current,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return { landed: false, current };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export { sha256 as sha256Bytes };
|