@naru-pub/dev 0.0.0-stage → 1.1.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/LICENSE +661 -0
- package/README.md +169 -2
- package/dist/build.json +4 -0
- package/dist/chunk-CQFKRWOD.mjs +1230 -0
- package/dist/chunk-K7ZVYKB2.mjs +11 -0
- package/dist/cli.mjs +103 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.mjs +7 -0
- package/dist/sdk/1.0.0/naru.d.ts +207 -0
- package/dist/sdk/1.0.0/naru.js +722 -0
- package/dist/sdk/1.1.0/functions.d.ts +206 -0
- package/dist/sdk/1.1.0/naru.d.ts +349 -0
- package/dist/sdk/1.1.0/naru.js +1066 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.mjs +7 -0
- package/dist/worker/05f16b7a30ddbe6e522bc3300013f93b30366682-metered.wasm +0 -0
- package/dist/worker/config.capnp +52 -0
- package/dist/worker/seoul.js +15357 -0
- package/package.json +61 -4
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naru Data SDK 1.0.0: browser SDK for data owned by one Naru site.
|
|
3
|
+
* @packageDocumentation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A Naru operation that could not be completed. Check it with `instanceof`. */
|
|
7
|
+
export class NaruError extends Error {
|
|
8
|
+
constructor(message, code, cause) {
|
|
9
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
10
|
+
this.name = "NaruError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The server names the code. Only the codes this version knows are passed on,
|
|
16
|
+
// so a code added later reads as the nearest one this version has.
|
|
17
|
+
const CODES = new Set([
|
|
18
|
+
"CONFLICT",
|
|
19
|
+
"QUOTA_EXCEEDED",
|
|
20
|
+
"AUTH_REQUIRED",
|
|
21
|
+
"ACCESS_DENIED",
|
|
22
|
+
"NOT_FOUND",
|
|
23
|
+
"RATE_LIMITED",
|
|
24
|
+
"INVALID_REQUEST",
|
|
25
|
+
"UNAVAILABLE",
|
|
26
|
+
]);
|
|
27
|
+
const errorCode = (status, code) => {
|
|
28
|
+
if (CODES.has(code)) return code;
|
|
29
|
+
if (status === 401) return "AUTH_REQUIRED";
|
|
30
|
+
if (status === 403) return "ACCESS_DENIED";
|
|
31
|
+
if (status === 404) return "NOT_FOUND";
|
|
32
|
+
if (status === 429) return "RATE_LIMITED";
|
|
33
|
+
if (status >= 500) return "UNAVAILABLE";
|
|
34
|
+
return "INVALID_REQUEST";
|
|
35
|
+
};
|
|
36
|
+
const CONTROL_PLANE = "https://naru.pub";
|
|
37
|
+
// The data API, served from Seoul (sdk.naru.pub); sign-in stays on naru.pub.
|
|
38
|
+
const DATA_API = "https://sdk.naru.pub";
|
|
39
|
+
const SITE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
40
|
+
const ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
41
|
+
// Where requests go: the data API at sdk.naru.pub, sign-in at naru.pub. A
|
|
42
|
+
// page on alice.naru.pub belongs to the site alice, so only custom domains and
|
|
43
|
+
// local development say which site.
|
|
44
|
+
function target({ site } = {}) {
|
|
45
|
+
site ||= /^([a-z0-9-]+)\.naru\.pub$/.exec(
|
|
46
|
+
globalThis.location?.hostname ?? "",
|
|
47
|
+
)?.[1];
|
|
48
|
+
if (typeof site !== "string" || !SITE.test(site))
|
|
49
|
+
throw new TypeError(
|
|
50
|
+
"Pass { site: <Naru login name> } when the page is not on <site>.naru.pub.",
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
site,
|
|
54
|
+
origin: CONTROL_PLANE,
|
|
55
|
+
root: `${DATA_API}/api/data/v1/${site}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A document ID becomes a path segment. Checking it here keeps "..", which a
|
|
60
|
+
// URL would resolve away, from ever addressing something else.
|
|
61
|
+
function segment(value) {
|
|
62
|
+
if (typeof value !== "string" || !ID.test(value))
|
|
63
|
+
throw new TypeError(
|
|
64
|
+
"Names and IDs are 1-64 ASCII letters, digits, underscores or hyphens.",
|
|
65
|
+
);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Once this module writes a collection, its subsequent reads bypass caches.
|
|
70
|
+
// No cache lifetime is baked into deployed SDKs; the server may change it.
|
|
71
|
+
const written = new Set();
|
|
72
|
+
|
|
73
|
+
// Accept only JSON values, without silently dropping or converting input.
|
|
74
|
+
function jsonValue(value, ancestors = new Set(), path = "data") {
|
|
75
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
76
|
+
return;
|
|
77
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
78
|
+
if (typeof value !== "object" || ancestors.has(value))
|
|
79
|
+
throw new TypeError(
|
|
80
|
+
`${path} must contain only JSON values (no cycles or non-finite numbers).`,
|
|
81
|
+
);
|
|
82
|
+
const array = Array.isArray(value);
|
|
83
|
+
const prototype = Object.getPrototypeOf(value);
|
|
84
|
+
if (
|
|
85
|
+
array
|
|
86
|
+
? prototype !== Array.prototype
|
|
87
|
+
: prototype !== Object.prototype && prototype !== null
|
|
88
|
+
)
|
|
89
|
+
throw new TypeError(
|
|
90
|
+
`${path} must be a plain JSON object; convert dates to strings.`,
|
|
91
|
+
);
|
|
92
|
+
ancestors.add(value);
|
|
93
|
+
const keys = Reflect.ownKeys(value).filter(
|
|
94
|
+
(key) => !(array && key === "length"),
|
|
95
|
+
);
|
|
96
|
+
if (array && keys.length !== value.length)
|
|
97
|
+
throw new TypeError(`${path} must be a dense JSON array.`);
|
|
98
|
+
for (const key of keys) {
|
|
99
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
100
|
+
if (
|
|
101
|
+
typeof key !== "string" ||
|
|
102
|
+
!descriptor.enumerable ||
|
|
103
|
+
!("value" in descriptor) ||
|
|
104
|
+
(array && !/^(0|[1-9][0-9]*)$/.test(key))
|
|
105
|
+
)
|
|
106
|
+
throw new TypeError(
|
|
107
|
+
`${path} must contain only ordinary JSON properties.`,
|
|
108
|
+
);
|
|
109
|
+
jsonValue(descriptor.value, ancestors, `${path}.${key}`);
|
|
110
|
+
}
|
|
111
|
+
ancestors.delete(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// How long a request may go unanswered before it fails as UNAVAILABLE. Naru
|
|
115
|
+
// answers or refuses within about ten seconds, so a request still waiting
|
|
116
|
+
// after this is one that will not arrive: a page can say so instead of
|
|
117
|
+
// waiting on the browser's own limit, which can be minutes.
|
|
118
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
119
|
+
|
|
120
|
+
async function request(
|
|
121
|
+
url,
|
|
122
|
+
{ method = "GET", body, token, signal, touches, renew },
|
|
123
|
+
) {
|
|
124
|
+
const fresh =
|
|
125
|
+
token || method !== "GET" || touches.some((path) => written.has(path));
|
|
126
|
+
// An explicit transport flag makes cache bypass enforceable by the server.
|
|
127
|
+
if (fresh && method === "GET") {
|
|
128
|
+
const target = new URL(url);
|
|
129
|
+
target.searchParams.set("fresh", "1");
|
|
130
|
+
url = target.href;
|
|
131
|
+
}
|
|
132
|
+
// Serialization errors are input errors, never network failures.
|
|
133
|
+
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
137
|
+
response = await fetch(url, {
|
|
138
|
+
method,
|
|
139
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
140
|
+
credentials: "omit",
|
|
141
|
+
redirect: "error",
|
|
142
|
+
cache: fresh ? "no-store" : "default",
|
|
143
|
+
headers: {
|
|
144
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
145
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
146
|
+
},
|
|
147
|
+
body: encoded,
|
|
148
|
+
});
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
// The caller's own abort; a timeout (TimeoutError) is Naru's silence.
|
|
151
|
+
if (cause?.name === "AbortError") throw cause;
|
|
152
|
+
throw new NaruError(
|
|
153
|
+
cause?.name === "TimeoutError"
|
|
154
|
+
? "Naru did not answer in time."
|
|
155
|
+
: "Naru is unavailable.",
|
|
156
|
+
"UNAVAILABLE",
|
|
157
|
+
cause,
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
// Also when the response was lost: the write may still have landed.
|
|
161
|
+
if (method !== "GET") for (const path of touches) written.add(path);
|
|
162
|
+
}
|
|
163
|
+
// A token's lifetime is an idle window the server pushes forward as the
|
|
164
|
+
// token is used, and this is the expiry it now has. It arrives as a duration
|
|
165
|
+
// so this browser's clock, however wrong, only measures it.
|
|
166
|
+
const renewed = response.headers.get("Naru-Owner-Expires-In");
|
|
167
|
+
if (renew && renewed !== null && Number.isFinite(Number(renewed)))
|
|
168
|
+
renew(Date.now() + Number(renewed) * 1000);
|
|
169
|
+
const result = await response.json().catch(() => null);
|
|
170
|
+
if (!response.ok)
|
|
171
|
+
throw new NaruError(
|
|
172
|
+
typeof result?.error?.message === "string"
|
|
173
|
+
? result.error.message
|
|
174
|
+
: `Database request failed (HTTP ${response.status}).`,
|
|
175
|
+
errorCode(response.status, result?.error?.code),
|
|
176
|
+
);
|
|
177
|
+
// A proxy or challenge page can answer 200 with HTML. Handing that back as
|
|
178
|
+
// an empty result would make a missing document look like a present one.
|
|
179
|
+
if (result === null || typeof result !== "object")
|
|
180
|
+
throw new NaruError(
|
|
181
|
+
`The data API did not answer with JSON (HTTP ${response.status}).`,
|
|
182
|
+
"UNAVAILABLE",
|
|
183
|
+
);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function query({ filter, sort, size, after, includeTotal } = {}) {
|
|
188
|
+
const parameters = new URLSearchParams();
|
|
189
|
+
if (filter && Object.keys(filter).length)
|
|
190
|
+
parameters.set("filter", JSON.stringify(filter));
|
|
191
|
+
if (sort) parameters.set("sort", JSON.stringify(sort));
|
|
192
|
+
if (size !== undefined) parameters.set("size", String(size));
|
|
193
|
+
if (after) parameters.set("after", after);
|
|
194
|
+
if (includeTotal) parameters.set("includeTotal", "1");
|
|
195
|
+
const text = String(parameters);
|
|
196
|
+
return text ? `?${text}` : "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Shared by single writes and batches, so both refuse the same mistakes here.
|
|
200
|
+
// A delete conditional on absence could only ever do nothing.
|
|
201
|
+
function checkCondition(value, { deleting = false } = {}) {
|
|
202
|
+
if (value === undefined) return value;
|
|
203
|
+
if (!value || typeof value !== "object")
|
|
204
|
+
throw new TypeError("condition must contain revision or absent.");
|
|
205
|
+
if (Object.keys(value).length !== 1)
|
|
206
|
+
throw new TypeError("condition must contain only revision or absent.");
|
|
207
|
+
if (Object.hasOwn(value, "revision")) {
|
|
208
|
+
const { revision } = value;
|
|
209
|
+
if (typeof revision !== "string" || !revision)
|
|
210
|
+
throw new TypeError("condition.revision must be returned by Naru.");
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
if (value.absent === true && !deleting) return value;
|
|
214
|
+
throw new TypeError(
|
|
215
|
+
deleting
|
|
216
|
+
? "A delete can only be conditional on a revision."
|
|
217
|
+
: "condition must contain revision or absent.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function condition(value, options) {
|
|
222
|
+
checkCondition(value, options);
|
|
223
|
+
if (value === undefined) return "";
|
|
224
|
+
const { revision } = value;
|
|
225
|
+
return revision
|
|
226
|
+
? `?ifRevision=${encodeURIComponent(revision)}`
|
|
227
|
+
: "?ifAbsent=1";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function documents(root, name, send) {
|
|
231
|
+
const path = `${root}/${segment(name)}`;
|
|
232
|
+
const touches = [path];
|
|
233
|
+
const list = (options = {}) =>
|
|
234
|
+
send(`${path}${query(options)}`, { signal: options.signal, touches });
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
async get(id, { signal } = {}) {
|
|
237
|
+
const result = await send(`${path}/${segment(id)}`, { signal, touches });
|
|
238
|
+
return result.document;
|
|
239
|
+
},
|
|
240
|
+
list,
|
|
241
|
+
// A total rides along with a one-document page; the list request is all
|
|
242
|
+
// the server needs to support.
|
|
243
|
+
async count({ filter, signal } = {}) {
|
|
244
|
+
return (await list({ filter, size: 1, includeTotal: true, signal }))
|
|
245
|
+
.totalCount;
|
|
246
|
+
},
|
|
247
|
+
// Each page as it arrives, following nextCursor. Like the pages it walks,
|
|
248
|
+
// this is not a snapshot: writes in between can shift later pages.
|
|
249
|
+
async *pages(options = {}) {
|
|
250
|
+
let after = options.after;
|
|
251
|
+
do {
|
|
252
|
+
const page = await list({ ...options, after });
|
|
253
|
+
yield page;
|
|
254
|
+
after = page.nextCursor;
|
|
255
|
+
} while (after);
|
|
256
|
+
},
|
|
257
|
+
add(data, { signal } = {}) {
|
|
258
|
+
jsonValue(data);
|
|
259
|
+
return send(path, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
body: { data },
|
|
262
|
+
signal,
|
|
263
|
+
touches,
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
set(id, data, { signal, condition: expected } = {}) {
|
|
267
|
+
jsonValue(data);
|
|
268
|
+
return send(`${path}/${segment(id)}${condition(expected)}`, {
|
|
269
|
+
method: "PUT",
|
|
270
|
+
body: { data },
|
|
271
|
+
signal,
|
|
272
|
+
touches,
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
async delete(id, { signal, condition: expected } = {}) {
|
|
276
|
+
await send(
|
|
277
|
+
`${path}/${segment(id)}${condition(expected, { deleting: true })}`,
|
|
278
|
+
{
|
|
279
|
+
method: "DELETE",
|
|
280
|
+
signal,
|
|
281
|
+
touches,
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A collection of the site this page belongs to. Nothing is requested yet. */
|
|
289
|
+
function publicDocuments(root, name) {
|
|
290
|
+
const collection = documents(root, name, request);
|
|
291
|
+
return Object.freeze({
|
|
292
|
+
get: collection.get,
|
|
293
|
+
list: collection.list,
|
|
294
|
+
count: collection.count,
|
|
295
|
+
pages: collection.pages,
|
|
296
|
+
add: collection.add,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Photos straight off a phone are several megabytes and thousands of pixels
|
|
301
|
+
// for an image a page shows at a fraction of that. Uploads go straight to
|
|
302
|
+
// object storage, so the browser is the only place to shrink them: before the
|
|
303
|
+
// upload is authorized, so quota counts what is stored, and by re-encoding,
|
|
304
|
+
// which drops EXIF so a photo's location never reaches the public URL.
|
|
305
|
+
const MAX_EDGE = 2048;
|
|
306
|
+
// Naru never stores HEIC, so a photo in it is converted or refused here. The
|
|
307
|
+
// other types Naru stores are the server's to say, and may grow.
|
|
308
|
+
const HEIC = /^image\/hei[cf]$/;
|
|
309
|
+
const HEIC_NAME = /\.hei[cf]$/i;
|
|
310
|
+
const SMALL_BYTES = 512 * 1024;
|
|
311
|
+
async function shrink(file) {
|
|
312
|
+
const heic = HEIC.test(file.type);
|
|
313
|
+
if (
|
|
314
|
+
!(heic || /^image\/(jpeg|png|webp)$/.test(file.type)) ||
|
|
315
|
+
typeof createImageBitmap !== "function"
|
|
316
|
+
)
|
|
317
|
+
return file;
|
|
318
|
+
let bitmap;
|
|
319
|
+
try {
|
|
320
|
+
// Canvas discards EXIF, so orientation is baked into the pixels here.
|
|
321
|
+
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
322
|
+
} catch {
|
|
323
|
+
return file;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
327
|
+
if (scale === 1 && !heic && file.size <= SMALL_BYTES) return file;
|
|
328
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
329
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
330
|
+
for (const type of ["image/webp", "image/jpeg"]) {
|
|
331
|
+
const canvas =
|
|
332
|
+
typeof OffscreenCanvas === "function"
|
|
333
|
+
? new OffscreenCanvas(width, height)
|
|
334
|
+
: Object.assign(document.createElement("canvas"), { width, height });
|
|
335
|
+
const context = canvas.getContext("2d");
|
|
336
|
+
if (!context) return file;
|
|
337
|
+
// JPEG has no transparency; paint white rather than let it go black.
|
|
338
|
+
if (type === "image/jpeg") {
|
|
339
|
+
context.fillStyle = "#fff";
|
|
340
|
+
context.fillRect(0, 0, width, height);
|
|
341
|
+
}
|
|
342
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
343
|
+
const blob = await ("convertToBlob" in canvas
|
|
344
|
+
? canvas.convertToBlob({ type, quality: 0.82 })
|
|
345
|
+
: new Promise((resolve) => canvas.toBlob(resolve, type, 0.82)));
|
|
346
|
+
// A browser that cannot encode a type quietly returns PNG instead.
|
|
347
|
+
if (blob?.type !== type) continue;
|
|
348
|
+
// HEIC is not accepted as is, so its conversion is kept even if larger.
|
|
349
|
+
if (!heic && blob.size >= file.size) return file;
|
|
350
|
+
const base = (file.name || "upload").replace(/\.[^.]*$/, "");
|
|
351
|
+
return new File([blob], `${base}.${type.slice(6)}`, { type });
|
|
352
|
+
}
|
|
353
|
+
return file;
|
|
354
|
+
} catch {
|
|
355
|
+
return file;
|
|
356
|
+
} finally {
|
|
357
|
+
bitmap.close?.();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The bytes go from the browser straight to object storage on a signed URL.
|
|
362
|
+
// fetch reports nothing of a body it sends, and streaming one to count it
|
|
363
|
+
// would drop the Content-Length a signed PUT needs, so a caller who asked for
|
|
364
|
+
// progress gets the same PUT through XMLHttpRequest. It answers like fetch:
|
|
365
|
+
// { ok, status }, a TypeError for a network failure, and the signal's reason
|
|
366
|
+
// for an abort.
|
|
367
|
+
function put(url, headers, file, signal, uploading) {
|
|
368
|
+
if (!uploading)
|
|
369
|
+
return fetch(url, {
|
|
370
|
+
method: "PUT",
|
|
371
|
+
headers,
|
|
372
|
+
body: file,
|
|
373
|
+
signal,
|
|
374
|
+
credentials: "omit",
|
|
375
|
+
redirect: "error",
|
|
376
|
+
});
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
const aborted = () =>
|
|
379
|
+
signal?.reason ??
|
|
380
|
+
new DOMException("The upload was aborted.", "AbortError");
|
|
381
|
+
if (signal?.aborted) return reject(aborted());
|
|
382
|
+
const request = new XMLHttpRequest();
|
|
383
|
+
const abort = () => request.abort();
|
|
384
|
+
const settle = (finish) => {
|
|
385
|
+
signal?.removeEventListener("abort", abort);
|
|
386
|
+
finish();
|
|
387
|
+
};
|
|
388
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
389
|
+
request.open("PUT", url);
|
|
390
|
+
for (const [name, value] of Object.entries(headers))
|
|
391
|
+
request.setRequestHeader(name, value);
|
|
392
|
+
request.upload.onprogress = (event) =>
|
|
393
|
+
uploading(Math.min(event.loaded, file.size));
|
|
394
|
+
// XMLHttpRequest follows redirects it cannot be told to refuse. Storage
|
|
395
|
+
// never redirects a signed upload, so one that moved is not trusted.
|
|
396
|
+
request.onload = () =>
|
|
397
|
+
settle(() =>
|
|
398
|
+
resolve({
|
|
399
|
+
ok:
|
|
400
|
+
request.status >= 200 &&
|
|
401
|
+
request.status < 300 &&
|
|
402
|
+
request.responseURL === new URL(url).href,
|
|
403
|
+
status: request.status,
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
request.onerror = request.ontimeout = () =>
|
|
407
|
+
settle(() => reject(new TypeError("The upload connection failed.")));
|
|
408
|
+
request.onabort = () => settle(() => reject(aborted()));
|
|
409
|
+
request.send(file);
|
|
410
|
+
// After send: an abort from this first report must reach a sent request,
|
|
411
|
+
// which fires abort, rather than an unsent one, which would not.
|
|
412
|
+
uploading(0);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Each callback page keeps its own session in its own tab.
|
|
417
|
+
const sessionKey = ({ origin, site }) =>
|
|
418
|
+
`naru:owner:${origin}:${site}:${location.origin}${location.pathname}`;
|
|
419
|
+
|
|
420
|
+
// An older client must not erase or outlive a newer sign-in made on the same
|
|
421
|
+
// page, so both of these only touch a stored session that is still this one.
|
|
422
|
+
function mine(key, token) {
|
|
423
|
+
try {
|
|
424
|
+
return (
|
|
425
|
+
JSON.parse(sessionStorage.getItem(key) ?? "null")?.accessToken === token
|
|
426
|
+
);
|
|
427
|
+
} catch {
|
|
428
|
+
return false; /* unreadable, so not this session */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function forget(key, token) {
|
|
433
|
+
if (mine(key, token)) sessionStorage.removeItem(key);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function remember(key, token, expiresAt) {
|
|
437
|
+
if (mine(key, token))
|
|
438
|
+
sessionStorage.setItem(
|
|
439
|
+
key,
|
|
440
|
+
JSON.stringify({ accessToken: token, expiresAt }),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function admin(context, token, expiresAt) {
|
|
445
|
+
const { origin, root } = context;
|
|
446
|
+
const key = sessionKey(context);
|
|
447
|
+
// The server renews the token as it is used; a tab left open while its owner
|
|
448
|
+
// keeps working is not signed out mid-edit.
|
|
449
|
+
const renew = (at) => {
|
|
450
|
+
if (!(at > expiresAt)) return;
|
|
451
|
+
expiresAt = at;
|
|
452
|
+
remember(key, token, at);
|
|
453
|
+
};
|
|
454
|
+
// After signOut this handle refuses to send, whatever became of the revoke.
|
|
455
|
+
let signedOut = false;
|
|
456
|
+
const send = async (url, init) => {
|
|
457
|
+
if (signedOut || Date.now() >= expiresAt) {
|
|
458
|
+
forget(key, token);
|
|
459
|
+
throw new NaruError("Sign in again.", "AUTH_REQUIRED");
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
return await request(url, { ...init, token, renew });
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error.code === "AUTH_REQUIRED") forget(key, token);
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
return Object.freeze({
|
|
469
|
+
collection: (name) => documents(root, name, send),
|
|
470
|
+
async batch(writes, { signal } = {}) {
|
|
471
|
+
const operations = writes.map(({ collection, set, delete: remove }) => {
|
|
472
|
+
const write = set ?? remove;
|
|
473
|
+
if (!write || Boolean(set) === Boolean(remove))
|
|
474
|
+
throw new TypeError("Each batch write must set or delete.");
|
|
475
|
+
if (set) jsonValue(set.data);
|
|
476
|
+
segment(collection);
|
|
477
|
+
segment(write.id);
|
|
478
|
+
checkCondition(write.condition, { deleting: !set });
|
|
479
|
+
return {
|
|
480
|
+
type: set ? "set" : "delete",
|
|
481
|
+
collection,
|
|
482
|
+
id: write.id,
|
|
483
|
+
...(set ? { data: set.data } : {}),
|
|
484
|
+
...(write.condition ? { condition: write.condition } : {}),
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
const touches = [
|
|
488
|
+
...new Set(operations.map((item) => `${root}/${item.collection}`)),
|
|
489
|
+
];
|
|
490
|
+
const { results } = await send(`${root}/_batch`, {
|
|
491
|
+
method: "POST",
|
|
492
|
+
body: { operations },
|
|
493
|
+
signal,
|
|
494
|
+
touches,
|
|
495
|
+
});
|
|
496
|
+
// In the order written: what each set stored, and null for a delete.
|
|
497
|
+
return results.map((result) =>
|
|
498
|
+
result
|
|
499
|
+
? {
|
|
500
|
+
id: result.id,
|
|
501
|
+
revision: result.revision,
|
|
502
|
+
createdAt: result.createdAt,
|
|
503
|
+
updatedAt: result.updatedAt,
|
|
504
|
+
}
|
|
505
|
+
: null,
|
|
506
|
+
);
|
|
507
|
+
},
|
|
508
|
+
media: Object.freeze({
|
|
509
|
+
async upload(source, { signal, onProgress } = {}) {
|
|
510
|
+
if (onProgress !== undefined && typeof onProgress !== "function")
|
|
511
|
+
throw new TypeError("onProgress must be a function.");
|
|
512
|
+
// A caller's mistake in its progress handler is reported, never
|
|
513
|
+
// allowed to fail an upload partway through.
|
|
514
|
+
const report = (progress) => {
|
|
515
|
+
try {
|
|
516
|
+
onProgress?.(progress);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (typeof reportError === "function") reportError(error);
|
|
519
|
+
else
|
|
520
|
+
setTimeout(() => {
|
|
521
|
+
throw error;
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
report({ phase: "preparing" });
|
|
526
|
+
// Some browsers leave an iPhone photo's type empty.
|
|
527
|
+
const file = await shrink(
|
|
528
|
+
!source.type && HEIC_NAME.test(source.name ?? "")
|
|
529
|
+
? new File([source], source.name, { type: "image/heic" })
|
|
530
|
+
: source,
|
|
531
|
+
);
|
|
532
|
+
if (HEIC.test(file.type))
|
|
533
|
+
throw new TypeError(
|
|
534
|
+
"This browser cannot convert HEIC photos. Upload from Safari, or convert the photo to JPEG first.",
|
|
535
|
+
);
|
|
536
|
+
const authorization = await send(`${root}/_files`, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: {
|
|
539
|
+
name: file.name || "upload",
|
|
540
|
+
contentType: file.type,
|
|
541
|
+
size: file.size,
|
|
542
|
+
},
|
|
543
|
+
signal,
|
|
544
|
+
touches: [],
|
|
545
|
+
});
|
|
546
|
+
let upload;
|
|
547
|
+
try {
|
|
548
|
+
upload = await put(
|
|
549
|
+
authorization.uploadUrl,
|
|
550
|
+
authorization.headers,
|
|
551
|
+
file,
|
|
552
|
+
signal,
|
|
553
|
+
onProgress &&
|
|
554
|
+
((loaded) =>
|
|
555
|
+
report({ phase: "uploading", loaded, total: file.size })),
|
|
556
|
+
);
|
|
557
|
+
} catch (cause) {
|
|
558
|
+
if (cause?.name === "AbortError") throw cause;
|
|
559
|
+
throw new NaruError("File upload failed.", "UNAVAILABLE", cause);
|
|
560
|
+
}
|
|
561
|
+
// An upload that never finishes is removed by the server within an
|
|
562
|
+
// hour. Storage refusing the bytes (an expired authorization, say) is
|
|
563
|
+
// fixed by uploading again, which authorizes afresh.
|
|
564
|
+
if (!upload.ok)
|
|
565
|
+
throw new NaruError(
|
|
566
|
+
`File upload failed (HTTP ${upload.status}).`,
|
|
567
|
+
"UNAVAILABLE",
|
|
568
|
+
);
|
|
569
|
+
report({ phase: "finishing" });
|
|
570
|
+
const finished = await send(
|
|
571
|
+
`${root}/_files/${segment(authorization.id)}`,
|
|
572
|
+
{ method: "PUT", body: {}, signal, touches: [] },
|
|
573
|
+
);
|
|
574
|
+
// What was stored, which shrinking may have renamed and re-encoded.
|
|
575
|
+
const { url, name, contentType, size } = finished.file;
|
|
576
|
+
return { url, name, contentType, size };
|
|
577
|
+
},
|
|
578
|
+
}),
|
|
579
|
+
/** Forgets the session here first, then asks Naru to revoke it. */
|
|
580
|
+
async signOut() {
|
|
581
|
+
signedOut = true;
|
|
582
|
+
forget(key, token);
|
|
583
|
+
await request(`${origin}/api/data-auth/v1/revoke`, {
|
|
584
|
+
method: "POST",
|
|
585
|
+
token,
|
|
586
|
+
touches: [],
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const base64url = (bytes) =>
|
|
593
|
+
btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
594
|
+
.replace(/\+/g, "-")
|
|
595
|
+
.replace(/\//g, "_")
|
|
596
|
+
.replace(/=+$/, "");
|
|
597
|
+
const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
598
|
+
// The exact address this tab is at. Naru matches it to the registered page and
|
|
599
|
+
// sends the owner back to it, so the session below is found where it was left.
|
|
600
|
+
const callback = () => location.origin + location.pathname;
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Leaves for Naru, where the owner approves access to `collections`, and comes
|
|
604
|
+
* back to this page. Register this page's URL as an administrator callback in
|
|
605
|
+
* the control panel first; Naru says so on its own page if it is not.
|
|
606
|
+
*/
|
|
607
|
+
async function signIn(context, collections) {
|
|
608
|
+
const { site, origin } = context;
|
|
609
|
+
// Checked here so a bad name fails on this page, not on Naru's consent page.
|
|
610
|
+
collections.forEach(segment);
|
|
611
|
+
const verifier = random();
|
|
612
|
+
const state = random();
|
|
613
|
+
const challenge = base64url(
|
|
614
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)),
|
|
615
|
+
);
|
|
616
|
+
// The PKCE transaction has to survive the round trip through Naru.
|
|
617
|
+
sessionStorage.setItem(
|
|
618
|
+
`${sessionKey(context)}:pending`,
|
|
619
|
+
JSON.stringify({ verifier, state, startedAt: Date.now() }),
|
|
620
|
+
);
|
|
621
|
+
const approval = new URL("/database/authorize", origin);
|
|
622
|
+
approval.search = String(
|
|
623
|
+
new URLSearchParams({
|
|
624
|
+
site,
|
|
625
|
+
redirectUri: callback(),
|
|
626
|
+
challenge,
|
|
627
|
+
state,
|
|
628
|
+
collections: collections.join(","),
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
location.assign(approval.href);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The signed-in admin client for this page, or null. Finishes a sign-in when
|
|
636
|
+
* Naru has just redirected back, and otherwise restores this tab's session.
|
|
637
|
+
* Call it before rendering: it removes the one-time code from the address bar.
|
|
638
|
+
*
|
|
639
|
+
* A sign-in that came back without completing (denied, stale, or a failed
|
|
640
|
+
* exchange) is an ordinary signed-out visit, never a page failure. The owner
|
|
641
|
+
* who denied it knows; anything else is fixed by signing in again.
|
|
642
|
+
*/
|
|
643
|
+
async function adminSession(context) {
|
|
644
|
+
try {
|
|
645
|
+
return await finishSignIn(context);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (error instanceof NaruError) return null;
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function finishSignIn(context) {
|
|
653
|
+
const key = sessionKey(context);
|
|
654
|
+
const url = new URL(location.href);
|
|
655
|
+
const code = url.searchParams.get("code");
|
|
656
|
+
const denied = url.searchParams.get("error");
|
|
657
|
+
const state = url.searchParams.get("state");
|
|
658
|
+
let pending = null;
|
|
659
|
+
try {
|
|
660
|
+
pending = JSON.parse(sessionStorage.getItem(`${key}:pending`) ?? "null");
|
|
661
|
+
} catch {
|
|
662
|
+
/* unreadable, so no sign-in to finish */
|
|
663
|
+
}
|
|
664
|
+
// Only a page this tab sent to Naru is coming back from sign-in. A ?code= or
|
|
665
|
+
// ?error= anywhere else belongs to the page and is left alone.
|
|
666
|
+
if (!pending || state === null || (code === null && denied === null)) {
|
|
667
|
+
try {
|
|
668
|
+
const saved = JSON.parse(sessionStorage.getItem(key) ?? "null");
|
|
669
|
+
if (saved && saved.expiresAt > Date.now())
|
|
670
|
+
return admin(context, saved.accessToken, saved.expiresAt);
|
|
671
|
+
} catch {
|
|
672
|
+
/* no usable session */
|
|
673
|
+
}
|
|
674
|
+
sessionStorage.removeItem(key);
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
for (const name of ["code", "state", "error"]) url.searchParams.delete(name);
|
|
678
|
+
history.replaceState(history.state, "", url.href);
|
|
679
|
+
sessionStorage.removeItem(`${key}:pending`);
|
|
680
|
+
if (
|
|
681
|
+
pending.state !== state ||
|
|
682
|
+
!(Date.now() - pending.startedAt < 10 * 60 * 1000)
|
|
683
|
+
)
|
|
684
|
+
throw new NaruError(
|
|
685
|
+
"Sign-in expired or did not start here.",
|
|
686
|
+
"AUTH_REQUIRED",
|
|
687
|
+
);
|
|
688
|
+
if (denied !== null)
|
|
689
|
+
throw new NaruError("Sign-in was denied.", "ACCESS_DENIED");
|
|
690
|
+
const startedAt = Date.now();
|
|
691
|
+
const token = await request(`${context.origin}/api/data-auth/v1/token`, {
|
|
692
|
+
method: "POST",
|
|
693
|
+
body: {
|
|
694
|
+
code,
|
|
695
|
+
verifier: pending.verifier,
|
|
696
|
+
redirectUri: callback(),
|
|
697
|
+
},
|
|
698
|
+
touches: [],
|
|
699
|
+
});
|
|
700
|
+
// The lifetime is measured on this browser's clock from before the request,
|
|
701
|
+
// so a clock set wrong can neither end the session early nor extend it.
|
|
702
|
+
const expiresAt = Number.isFinite(token.expiresIn)
|
|
703
|
+
? startedAt + token.expiresIn * 1000
|
|
704
|
+
: token.expiresAt;
|
|
705
|
+
sessionStorage.setItem(
|
|
706
|
+
key,
|
|
707
|
+
JSON.stringify({ accessToken: token.accessToken, expiresAt }),
|
|
708
|
+
);
|
|
709
|
+
return admin(context, token.accessToken, expiresAt);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Creates a client bound to one site. */
|
|
713
|
+
export function createNaru(options) {
|
|
714
|
+
const context = target(options);
|
|
715
|
+
return Object.freeze({
|
|
716
|
+
collection: (name) => publicDocuments(context.root, name),
|
|
717
|
+
auth: Object.freeze({
|
|
718
|
+
session: () => adminSession(context),
|
|
719
|
+
signIn: ({ collections }) => signIn(context, collections),
|
|
720
|
+
}),
|
|
721
|
+
});
|
|
722
|
+
}
|