naro-js 0.1.0 → 0.3.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/README.md +187 -3
- package/dist/auth/admin.d.ts +23 -0
- package/dist/auth/client.d.ts +24 -5
- package/dist/auth/flows.d.ts +1 -1
- package/dist/client.d.ts +11 -1
- package/dist/errors.d.ts +15 -5
- package/dist/http.d.ts +27 -4
- package/dist/index.d.ts +4 -1
- package/dist/index.js +571 -18
- package/dist/storage/client.d.ts +65 -0
- package/dist/storage/path.d.ts +76 -0
- package/dist/storage/types.d.ts +58 -0
- package/dist/types.d.ts +86 -4
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -49,6 +49,12 @@ function invalidProvider() {
|
|
|
49
49
|
message: "provider must be google or github."
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
function serviceKeyRequired() {
|
|
53
|
+
return {
|
|
54
|
+
code: "PERMISSION_DENIED",
|
|
55
|
+
message: "Only the service key can manage users."
|
|
56
|
+
};
|
|
57
|
+
}
|
|
52
58
|
function cryptoUnavailable() {
|
|
53
59
|
return {
|
|
54
60
|
code: "CRYPTO_UNAVAILABLE",
|
|
@@ -128,9 +134,10 @@ async function call(config, options) {
|
|
|
128
134
|
const init = {
|
|
129
135
|
method: options.method,
|
|
130
136
|
headers,
|
|
131
|
-
// Identity is the bearer token alone.
|
|
132
|
-
// cookie (spec §13); a request that carried it would let any site
|
|
133
|
-
// as the user. Workers accept the field (measured on workerd
|
|
137
|
+
// Identity is the bearer token alone. The sign-in routes also set a
|
|
138
|
+
// session cookie (spec §13); a request that carried it would let any site
|
|
139
|
+
// act as the user. Workers accept the field (measured on workerd
|
|
140
|
+
// 1.20260815).
|
|
134
141
|
credentials: "omit"
|
|
135
142
|
};
|
|
136
143
|
if (payload !== void 0) {
|
|
@@ -151,15 +158,65 @@ async function call(config, options) {
|
|
|
151
158
|
const answer = { status, headers: responseHeaders, body, token };
|
|
152
159
|
const error = errorFromBody(status, body);
|
|
153
160
|
if (error === null) return { error: null, answer };
|
|
161
|
+
return {
|
|
162
|
+
error: await settled(config, error, responseHeaders, token),
|
|
163
|
+
answer
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function settled(config, error, headers, token) {
|
|
154
167
|
if (error.code === "INVALID_SESSION" && token !== null)
|
|
155
168
|
await config.tokens.clearIf(token);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
169
|
+
return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
|
|
170
|
+
}
|
|
171
|
+
async function send(config, options) {
|
|
172
|
+
const token = options.withToken ? await config.tokens.get() : null;
|
|
173
|
+
const headers = {
|
|
174
|
+
...options.headers,
|
|
175
|
+
apikey: config.apiKey
|
|
176
|
+
};
|
|
177
|
+
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
|
178
|
+
const init = {
|
|
179
|
+
method: options.method,
|
|
180
|
+
headers,
|
|
181
|
+
credentials: "omit"
|
|
182
|
+
};
|
|
183
|
+
if (options.body !== void 0) init.body = options.body;
|
|
184
|
+
let response;
|
|
185
|
+
try {
|
|
186
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
187
|
+
response = await fetchImpl(
|
|
188
|
+
`${config.baseUrl}/_naro/v1${options.path}`,
|
|
189
|
+
init
|
|
190
|
+
);
|
|
191
|
+
} catch (error2) {
|
|
192
|
+
return { error: networkError(error2) };
|
|
193
|
+
}
|
|
194
|
+
if (response.status >= 200 && response.status < 300)
|
|
195
|
+
return { error: null, response };
|
|
196
|
+
let text;
|
|
197
|
+
try {
|
|
198
|
+
text = await response.text();
|
|
199
|
+
} catch (error2) {
|
|
200
|
+
return { error: networkError(error2) };
|
|
201
|
+
}
|
|
202
|
+
let body;
|
|
203
|
+
if (text.trim() !== "") {
|
|
204
|
+
try {
|
|
205
|
+
body = JSON.parse(text);
|
|
206
|
+
} catch {
|
|
207
|
+
return { error: invalidResponse(response.status) };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
|
|
211
|
+
return { error: await settled(config, error, response.headers, token) };
|
|
159
212
|
}
|
|
160
213
|
var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
161
214
|
"RATE_LIMITED",
|
|
162
|
-
"WRITE_QUOTA_EXCEEDED"
|
|
215
|
+
"WRITE_QUOTA_EXCEEDED",
|
|
216
|
+
// Storage (original spec §13): one object written twice within a second
|
|
217
|
+
// (`x-retry-after: 1`), and the daily operation limit (3-1d).
|
|
218
|
+
"OBJECT_RATE_LIMITED",
|
|
219
|
+
"STORAGE_OPS_QUOTA_EXCEEDED"
|
|
163
220
|
]);
|
|
164
221
|
function withRetryAfter(error, headers) {
|
|
165
222
|
const header = headers.get("x-retry-after")?.trim() ?? "";
|
|
@@ -196,6 +253,90 @@ function isKnownServer(scope = globalThis) {
|
|
|
196
253
|
return hasServerSignal(g) && !hasDeviceSignal(g);
|
|
197
254
|
}
|
|
198
255
|
|
|
256
|
+
// src/auth/admin.ts
|
|
257
|
+
var USERS_PATH = "/auth/admin/users";
|
|
258
|
+
function dataOf(result) {
|
|
259
|
+
if (result.error !== null) return null;
|
|
260
|
+
const { body } = result.answer;
|
|
261
|
+
return isRecord(body) && isRecord(body.data) ? body.data : null;
|
|
262
|
+
}
|
|
263
|
+
function errorOf(result) {
|
|
264
|
+
if (result.error !== null) return result.error;
|
|
265
|
+
return invalidResponse(result.answer.status);
|
|
266
|
+
}
|
|
267
|
+
function userOf(value) {
|
|
268
|
+
return isRecord(value) && typeof value.id === "string" ? value : null;
|
|
269
|
+
}
|
|
270
|
+
var AuthAdminApi = class {
|
|
271
|
+
#http;
|
|
272
|
+
#serviceKey;
|
|
273
|
+
constructor(http, serviceKey) {
|
|
274
|
+
this.#http = http;
|
|
275
|
+
this.#serviceKey = serviceKey;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Creates a user with a password: an email, a username or both. It is
|
|
279
|
+
* made while sign-ups are closed too, and is confirmed unless
|
|
280
|
+
* `emailConfirm: false`. A username-only user answers `email: null` and
|
|
281
|
+
* signs in with `signIn({ username, password })`. Refusals come as the
|
|
282
|
+
* worker says them: USERNAME_TAKEN (409),
|
|
283
|
+
* USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (422), PASSWORD_TOO_SHORT ("Use at
|
|
284
|
+
* least {n} characters."), VALIDATION_ERROR, WRITE_QUOTA_EXCEEDED (429).
|
|
285
|
+
*/
|
|
286
|
+
async createUser(attributes) {
|
|
287
|
+
if (!this.#serviceKey)
|
|
288
|
+
return { data: { user: null }, error: serviceKeyRequired() };
|
|
289
|
+
const result = await call(this.#http, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
path: USERS_PATH,
|
|
292
|
+
body: attributes,
|
|
293
|
+
withToken: false
|
|
294
|
+
});
|
|
295
|
+
const user = userOf(dataOf(result)?.user);
|
|
296
|
+
if (user === null) return { data: { user: null }, error: errorOf(result) };
|
|
297
|
+
return { data: { user }, error: null };
|
|
298
|
+
}
|
|
299
|
+
/** The project's users, newest first: page 1 and 50 a page unless given. */
|
|
300
|
+
async listUsers(params = {}) {
|
|
301
|
+
const none = { users: [], total: 0 };
|
|
302
|
+
if (!this.#serviceKey) return { data: none, error: serviceKeyRequired() };
|
|
303
|
+
const query = new URLSearchParams();
|
|
304
|
+
if (params.page !== void 0) query.set("page", String(params.page));
|
|
305
|
+
if (params.perPage !== void 0)
|
|
306
|
+
query.set("perPage", String(params.perPage));
|
|
307
|
+
const search = query.toString();
|
|
308
|
+
const result = await call(this.#http, {
|
|
309
|
+
method: "GET",
|
|
310
|
+
path: search === "" ? USERS_PATH : `${USERS_PATH}?${search}`,
|
|
311
|
+
withToken: false
|
|
312
|
+
});
|
|
313
|
+
const data = dataOf(result);
|
|
314
|
+
const users = Array.isArray(data?.users) ? data.users.map(userOf) : null;
|
|
315
|
+
if (!data || users === null || users.includes(null) || typeof data.total !== "number")
|
|
316
|
+
return { data: none, error: errorOf(result) };
|
|
317
|
+
return {
|
|
318
|
+
data: { users, total: data.total },
|
|
319
|
+
error: null
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Deletes a user and its sign-in methods, and ends its sessions. Answers
|
|
324
|
+
* the user it was; USER_NOT_FOUND (404) for an id that is no user.
|
|
325
|
+
*/
|
|
326
|
+
async deleteUser(id) {
|
|
327
|
+
if (!this.#serviceKey)
|
|
328
|
+
return { data: { user: null }, error: serviceKeyRequired() };
|
|
329
|
+
const result = await call(this.#http, {
|
|
330
|
+
method: "DELETE",
|
|
331
|
+
path: `${USERS_PATH}/${encodeURIComponent(String(id))}`,
|
|
332
|
+
withToken: false
|
|
333
|
+
});
|
|
334
|
+
const user = userOf(dataOf(result)?.user);
|
|
335
|
+
if (user === null) return { data: { user: null }, error: errorOf(result) };
|
|
336
|
+
return { data: { user }, error: null };
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
199
340
|
// src/auth/flow-errors.ts
|
|
200
341
|
var CODE = /^[A-Z0-9_]{1,64}$/;
|
|
201
342
|
var INVALID_CONFIRMATION = "The confirmation link is not valid. Ask for a new one.";
|
|
@@ -207,6 +348,9 @@ var MESSAGES = {
|
|
|
207
348
|
OAUTH_ACCOUNT_NOT_LINKED: "An account with this email already exists and is not linked to this provider. Sign in the way you signed up.",
|
|
208
349
|
PROVIDER_NOT_CONFIGURED: "Signing in with this provider is not set up for this project.",
|
|
209
350
|
RATE_LIMITED: "Too many sign-in attempts. Wait a few seconds and try again.",
|
|
351
|
+
// A first Google or GitHub sign-in while the project's sign-ups are
|
|
352
|
+
// closed (spec 2026-09-26 §2.1): the worker's own words.
|
|
353
|
+
SIGNUPS_DISABLED: "Sign-ups are closed for this project.",
|
|
210
354
|
INTERNAL_ERROR: "Something went wrong. Try again.",
|
|
211
355
|
PASSWORD_RESET_REQUIRED: "Your email is confirmed. Someone else also signed up with it before it was confirmed, so its password was cleared: choose a new one with a password reset.",
|
|
212
356
|
TOKEN_EXPIRED: "The confirmation link has expired. Ask for a new one.",
|
|
@@ -326,6 +470,12 @@ function share(shared, pending) {
|
|
|
326
470
|
});
|
|
327
471
|
}
|
|
328
472
|
var AuthClient = class {
|
|
473
|
+
/**
|
|
474
|
+
* The service key's user management — createUser, listUsers, deleteUser,
|
|
475
|
+
* as in supabase-js (spec 2026-09-26 §2.2). Only on a client made with
|
|
476
|
+
* `serviceKey`, on a server.
|
|
477
|
+
*/
|
|
478
|
+
admin;
|
|
329
479
|
#http;
|
|
330
480
|
#flow;
|
|
331
481
|
/** The last `getSessionFromUrl()`, for callers after detection ran it. */
|
|
@@ -333,6 +483,7 @@ var AuthClient = class {
|
|
|
333
483
|
constructor(http, flow) {
|
|
334
484
|
this.#http = http;
|
|
335
485
|
this.#flow = flow;
|
|
486
|
+
this.admin = new AuthAdminApi(http, flow.serviceKey);
|
|
336
487
|
if (flow.detectSessionInUrl) {
|
|
337
488
|
if (naroFragment() !== null) void this.getSessionFromUrl();
|
|
338
489
|
else this.#fromUrl = sharedInFlight(flow.shared);
|
|
@@ -342,18 +493,37 @@ var AuthClient = class {
|
|
|
342
493
|
* Creates the account and signs in; the token is stored for later calls.
|
|
343
494
|
* When the project requires a confirmed email there is no session yet —
|
|
344
495
|
* `user` comes back, and after the confirmation link the user signs in.
|
|
496
|
+
* While the project's sign-ups are closed every sign-up answers 403
|
|
497
|
+
* SIGNUPS_DISABLED; while they are open, a taken `username` answers 409
|
|
498
|
+
* USERNAME_TAKEN.
|
|
345
499
|
*/
|
|
346
500
|
async signUp(credentials) {
|
|
347
|
-
const { email, password } = credentials;
|
|
348
|
-
const name = credentials.name ?? defaultName(email);
|
|
501
|
+
const { email, password, username } = credentials;
|
|
502
|
+
const name = credentials.name ?? username ?? defaultName(email);
|
|
349
503
|
const body = { email, password, name };
|
|
504
|
+
if (username !== void 0) body.username = username;
|
|
350
505
|
if (credentials.redirectTo !== void 0)
|
|
351
506
|
body.callbackURL = credentials.redirectTo;
|
|
352
507
|
return this.#signIn("/auth/sign-up/email", body, false);
|
|
353
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* Signs in with an email and password, or a username and password (spec
|
|
511
|
+
* 2026-09-26 §2.3); the token is stored for later calls. A wrong password
|
|
512
|
+
* answers 401 INVALID_EMAIL_OR_PASSWORD, or INVALID_USERNAME_OR_PASSWORD.
|
|
513
|
+
*/
|
|
354
514
|
async signIn(credentials) {
|
|
355
|
-
const {
|
|
356
|
-
|
|
515
|
+
const { password } = credentials;
|
|
516
|
+
if (credentials.username !== void 0)
|
|
517
|
+
return this.#signIn(
|
|
518
|
+
"/auth/sign-in/username",
|
|
519
|
+
{ username: credentials.username, password },
|
|
520
|
+
true
|
|
521
|
+
);
|
|
522
|
+
return this.#signIn(
|
|
523
|
+
"/auth/sign-in/email",
|
|
524
|
+
{ email: credentials.email, password },
|
|
525
|
+
true
|
|
526
|
+
);
|
|
357
527
|
}
|
|
358
528
|
/**
|
|
359
529
|
* Starts a Google or GitHub sign-in: sends the browser, with a top-level
|
|
@@ -1636,6 +1806,379 @@ var TableBuilder = class {
|
|
|
1636
1806
|
}
|
|
1637
1807
|
};
|
|
1638
1808
|
|
|
1809
|
+
// src/storage/path.ts
|
|
1810
|
+
var MAX_SINGLE_UPLOAD_BYTES = 52428800;
|
|
1811
|
+
var STORAGE_MESSAGES = {
|
|
1812
|
+
bucket: "The bucket name is not valid.",
|
|
1813
|
+
tooLarge: "Uploads over 50 MiB are not supported yet.",
|
|
1814
|
+
request: "The storage request is not valid.",
|
|
1815
|
+
body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
|
|
1816
|
+
};
|
|
1817
|
+
var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
|
|
1818
|
+
"authenticated",
|
|
1819
|
+
"copy",
|
|
1820
|
+
"info",
|
|
1821
|
+
"list",
|
|
1822
|
+
"move",
|
|
1823
|
+
"public",
|
|
1824
|
+
"render",
|
|
1825
|
+
"sign",
|
|
1826
|
+
"upload"
|
|
1827
|
+
]);
|
|
1828
|
+
var DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
|
|
1829
|
+
var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
1830
|
+
var OCTET_STREAM = "application/octet-stream";
|
|
1831
|
+
var UTF8_TEXT = "text/plain;charset=utf-8";
|
|
1832
|
+
function invalidPath(reason) {
|
|
1833
|
+
return {
|
|
1834
|
+
code: "INVALID_PATH",
|
|
1835
|
+
message: `The object path is not valid: it ${reason}.`
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
function objectTooLarge() {
|
|
1839
|
+
return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
|
|
1840
|
+
}
|
|
1841
|
+
function encodePath(path) {
|
|
1842
|
+
if (typeof path !== "string" || path === "")
|
|
1843
|
+
return { ok: false, error: invalidPath("is empty") };
|
|
1844
|
+
if (LONE_SURROGATE.test(path))
|
|
1845
|
+
return { ok: false, error: invalidPath("is not valid UTF-8") };
|
|
1846
|
+
if (path.startsWith("/") || path.endsWith("/"))
|
|
1847
|
+
return { ok: false, error: invalidPath("starts or ends with /") };
|
|
1848
|
+
if (path.includes("//"))
|
|
1849
|
+
return { ok: false, error: invalidPath("has an empty segment (//)") };
|
|
1850
|
+
const segments = path.split("/");
|
|
1851
|
+
if (segments.some((segment) => segment === "." || segment === ".."))
|
|
1852
|
+
return { ok: false, error: invalidPath("has a . or .. segment") };
|
|
1853
|
+
return { ok: true, encoded: segments.map(encodeURIComponent).join("/") };
|
|
1854
|
+
}
|
|
1855
|
+
function downloadQuery(download) {
|
|
1856
|
+
if (download === true) return { ok: true, encoded: "?download" };
|
|
1857
|
+
if (typeof download !== "string") return { ok: true, encoded: "" };
|
|
1858
|
+
if (LONE_SURROGATE.test(download))
|
|
1859
|
+
return {
|
|
1860
|
+
ok: false,
|
|
1861
|
+
error: {
|
|
1862
|
+
code: "INVALID_REQUEST",
|
|
1863
|
+
message: STORAGE_MESSAGES.request,
|
|
1864
|
+
details: { path: "download", reason: DOWNLOAD_NAME_REASON }
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
return { ok: true, encoded: `?download=${encodeURIComponent(download)}` };
|
|
1868
|
+
}
|
|
1869
|
+
function encodeBucket(bucket) {
|
|
1870
|
+
if (typeof bucket !== "string" || bucket === "" || bucket === "." || bucket === ".." || LONE_SURROGATE.test(bucket) || RESERVED_BUCKETS.has(bucket))
|
|
1871
|
+
return {
|
|
1872
|
+
ok: false,
|
|
1873
|
+
error: { code: "INVALID_PATH", message: STORAGE_MESSAGES.bucket }
|
|
1874
|
+
};
|
|
1875
|
+
return { ok: true, encoded: encodeURIComponent(bucket) };
|
|
1876
|
+
}
|
|
1877
|
+
var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
|
|
1878
|
+
function isDetached(buffer) {
|
|
1879
|
+
if (!isArrayBuffer(buffer)) return false;
|
|
1880
|
+
const flag = buffer.detached;
|
|
1881
|
+
if (typeof flag === "boolean") return flag;
|
|
1882
|
+
if (buffer.byteLength > 0) return false;
|
|
1883
|
+
try {
|
|
1884
|
+
buffer.slice(0);
|
|
1885
|
+
return false;
|
|
1886
|
+
} catch {
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
var CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
|
|
1891
|
+
var MAX_TYPE_IN_MESSAGE = 256;
|
|
1892
|
+
function unsendable(value) {
|
|
1893
|
+
return /[\0\r\n]|[^\0-\xff]/.test(
|
|
1894
|
+
value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "")
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
function headerProblem(contentType, cacheControl) {
|
|
1898
|
+
if (typeof contentType === "string" && unsendable(contentType)) {
|
|
1899
|
+
let shown = contentType;
|
|
1900
|
+
if (shown.length > MAX_TYPE_IN_MESSAGE) {
|
|
1901
|
+
shown = shown.slice(0, MAX_TYPE_IN_MESSAGE);
|
|
1902
|
+
if (/[\ud800-\udbff]$/.test(shown)) shown = shown.slice(0, -1);
|
|
1903
|
+
}
|
|
1904
|
+
return {
|
|
1905
|
+
code: "INVALID_CONTENT_TYPE",
|
|
1906
|
+
message: `Content-Type ${shown} is not a media type like image/png.`
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
if (unsendable(cacheControl))
|
|
1910
|
+
return {
|
|
1911
|
+
code: "INVALID_REQUEST",
|
|
1912
|
+
message: STORAGE_MESSAGES.request,
|
|
1913
|
+
details: { path: "cache-control", reason: CACHE_CONTROL_REASON }
|
|
1914
|
+
};
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
function payloadOf(body, contentType) {
|
|
1918
|
+
if (typeof body === "string") {
|
|
1919
|
+
const bytes = new TextEncoder().encode(body);
|
|
1920
|
+
return {
|
|
1921
|
+
ok: true,
|
|
1922
|
+
body: bytes,
|
|
1923
|
+
size: bytes.byteLength,
|
|
1924
|
+
type: contentType ?? UTF8_TEXT
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
if (typeof Blob === "function" && body instanceof Blob)
|
|
1928
|
+
return {
|
|
1929
|
+
ok: true,
|
|
1930
|
+
body,
|
|
1931
|
+
size: body.size,
|
|
1932
|
+
type: contentType ?? (body.type === "" ? OCTET_STREAM : body.type)
|
|
1933
|
+
};
|
|
1934
|
+
if (isArrayBuffer(body) && !isDetached(body))
|
|
1935
|
+
return {
|
|
1936
|
+
ok: true,
|
|
1937
|
+
body,
|
|
1938
|
+
size: body.byteLength,
|
|
1939
|
+
type: contentType ?? OCTET_STREAM
|
|
1940
|
+
};
|
|
1941
|
+
if (ArrayBuffer.isView(body) && !isDetached(body.buffer)) {
|
|
1942
|
+
const { buffer, byteOffset, byteLength } = body;
|
|
1943
|
+
const bytes = isArrayBuffer(buffer) ? new Uint8Array(buffer, byteOffset, byteLength) : Uint8Array.from(new Uint8Array(buffer, byteOffset, byteLength));
|
|
1944
|
+
return {
|
|
1945
|
+
ok: true,
|
|
1946
|
+
body: bytes,
|
|
1947
|
+
size: byteLength,
|
|
1948
|
+
type: contentType ?? OCTET_STREAM
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
return {
|
|
1952
|
+
ok: false,
|
|
1953
|
+
error: { code: "INVALID_BODY", message: STORAGE_MESSAGES.body }
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// src/storage/client.ts
|
|
1958
|
+
var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
|
|
1959
|
+
function fail(error) {
|
|
1960
|
+
return { data: null, error };
|
|
1961
|
+
}
|
|
1962
|
+
var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
|
|
1963
|
+
var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
|
|
1964
|
+
var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
|
|
1965
|
+
var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
|
|
1966
|
+
function listOf(guard) {
|
|
1967
|
+
return (value) => Array.isArray(value) && value.every(guard);
|
|
1968
|
+
}
|
|
1969
|
+
function dataOf2(status, body, guard) {
|
|
1970
|
+
if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
|
|
1971
|
+
return { data: body.data, error: null };
|
|
1972
|
+
return fail(invalidResponse(status));
|
|
1973
|
+
}
|
|
1974
|
+
async function readJson(response, guard) {
|
|
1975
|
+
let text;
|
|
1976
|
+
try {
|
|
1977
|
+
text = await response.text();
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
return fail(networkError(error));
|
|
1980
|
+
}
|
|
1981
|
+
let body;
|
|
1982
|
+
try {
|
|
1983
|
+
body = JSON.parse(text);
|
|
1984
|
+
} catch {
|
|
1985
|
+
return fail(invalidResponse(response.status));
|
|
1986
|
+
}
|
|
1987
|
+
return dataOf2(response.status, body, guard);
|
|
1988
|
+
}
|
|
1989
|
+
var StorageFileApi = class {
|
|
1990
|
+
#http;
|
|
1991
|
+
#auth;
|
|
1992
|
+
#bucket;
|
|
1993
|
+
constructor(http, auth, bucket) {
|
|
1994
|
+
this.#http = http;
|
|
1995
|
+
this.#auth = auth;
|
|
1996
|
+
this.#bucket = bucket;
|
|
1997
|
+
}
|
|
1998
|
+
/** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
|
|
1999
|
+
#objectPath(route, path) {
|
|
2000
|
+
const bucket = encodeBucket(this.#bucket);
|
|
2001
|
+
if (!bucket.ok) return bucket;
|
|
2002
|
+
const name = encodePath(path);
|
|
2003
|
+
if (!name.ok) return name;
|
|
2004
|
+
return {
|
|
2005
|
+
ok: true,
|
|
2006
|
+
path: `/storage/object/${route}${bucket.encoded}/${name.encoded}`
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
/** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
|
|
2010
|
+
#bucketPath(route) {
|
|
2011
|
+
const bucket = encodeBucket(this.#bucket);
|
|
2012
|
+
if (!bucket.ok) return bucket;
|
|
2013
|
+
return { ok: true, path: `/storage/object/${route}${bucket.encoded}` };
|
|
2014
|
+
}
|
|
2015
|
+
async #json(request, guard) {
|
|
2016
|
+
await this.#auth.ready();
|
|
2017
|
+
const result = await call(this.#http, { ...request, withToken: true });
|
|
2018
|
+
if (result.error !== null) return fail(result.error);
|
|
2019
|
+
return dataOf2(result.answer.status, result.answer.body, guard);
|
|
2020
|
+
}
|
|
2021
|
+
async #write(method, path, body, options, upsert) {
|
|
2022
|
+
const target = this.#objectPath("", path);
|
|
2023
|
+
if (!target.ok) return fail(target.error);
|
|
2024
|
+
const payload = payloadOf(body, options.contentType);
|
|
2025
|
+
if (!payload.ok) return fail(payload.error);
|
|
2026
|
+
const cache = `max-age=${options.cacheControl ?? "3600"}`;
|
|
2027
|
+
const unreadable = headerProblem(options.contentType, cache);
|
|
2028
|
+
if (unreadable !== null) return fail(unreadable);
|
|
2029
|
+
if (payload.size > MAX_SINGLE_UPLOAD_BYTES) return fail(objectTooLarge());
|
|
2030
|
+
await this.#auth.ready();
|
|
2031
|
+
const headers = {
|
|
2032
|
+
"content-type": payload.type,
|
|
2033
|
+
"cache-control": cache
|
|
2034
|
+
};
|
|
2035
|
+
if (upsert) headers["x-upsert"] = "true";
|
|
2036
|
+
const sent = await send(this.#http, {
|
|
2037
|
+
method,
|
|
2038
|
+
path: target.path,
|
|
2039
|
+
headers,
|
|
2040
|
+
body: payload.body,
|
|
2041
|
+
withToken: true
|
|
2042
|
+
});
|
|
2043
|
+
if (sent.error !== null) return fail(sent.error);
|
|
2044
|
+
return readJson(sent.response, isUploadResult);
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
2048
|
+
* `null` options, as a JS caller may pass, are no options.
|
|
2049
|
+
*/
|
|
2050
|
+
upload(path, body, options = {}) {
|
|
2051
|
+
const given = options ?? {};
|
|
2052
|
+
return this.#write("POST", path, body, given, given.upsert === true);
|
|
2053
|
+
}
|
|
2054
|
+
/** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
|
|
2055
|
+
update(path, body, options = {}) {
|
|
2056
|
+
return this.#write("PUT", path, body, options ?? {}, false);
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* The object's bytes, as a Blob of the type the worker serves it under:
|
|
2060
|
+
* the stored type for the images, audio, video, PDF, plain text, CSV,
|
|
2061
|
+
* JSON and SVG it serves as themselves, `application/octet-stream` for
|
|
2062
|
+
* anything else (HTML, scripts, archives, unknown types — `info` and
|
|
2063
|
+
* `list` hold the stored type).
|
|
2064
|
+
*/
|
|
2065
|
+
async download(path) {
|
|
2066
|
+
const target = this.#objectPath("", path);
|
|
2067
|
+
if (!target.ok) return fail(target.error);
|
|
2068
|
+
await this.#auth.ready();
|
|
2069
|
+
const sent = await send(this.#http, {
|
|
2070
|
+
method: "GET",
|
|
2071
|
+
path: target.path,
|
|
2072
|
+
withToken: true
|
|
2073
|
+
});
|
|
2074
|
+
if (sent.error !== null) return fail(sent.error);
|
|
2075
|
+
try {
|
|
2076
|
+
return { data: await sent.response.blob(), error: null };
|
|
2077
|
+
} catch (error) {
|
|
2078
|
+
return fail(networkError(error));
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
async info(path) {
|
|
2082
|
+
const target = this.#objectPath("info/", path);
|
|
2083
|
+
if (!target.ok) return fail(target.error);
|
|
2084
|
+
return this.#json({ method: "GET", path: target.path }, isFileObject);
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
|
|
2088
|
+
* every other error comes back as it is (plan C1 — asked with `info`,
|
|
2089
|
+
* not HEAD, whose answer has no body to tell a missing bucket from a
|
|
2090
|
+
* missing object).
|
|
2091
|
+
*/
|
|
2092
|
+
async exists(path) {
|
|
2093
|
+
const found = await this.info(path);
|
|
2094
|
+
if (found.error === null) return { data: true, error: null };
|
|
2095
|
+
if (found.error.code === "OBJECT_NOT_FOUND")
|
|
2096
|
+
return { data: false, error: null };
|
|
2097
|
+
return fail(found.error);
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* What is directly below `prefix` (the whole bucket by default): folders
|
|
2101
|
+
* first, then files. The options go to the worker as they are given.
|
|
2102
|
+
*/
|
|
2103
|
+
async list(prefix, options = {}) {
|
|
2104
|
+
const target = this.#bucketPath("list/");
|
|
2105
|
+
if (!target.ok) return fail(target.error);
|
|
2106
|
+
return this.#json(
|
|
2107
|
+
{
|
|
2108
|
+
method: "POST",
|
|
2109
|
+
path: target.path,
|
|
2110
|
+
body: { ...prefix === void 0 ? {} : { prefix }, ...options }
|
|
2111
|
+
},
|
|
2112
|
+
listOf(isEntry2)
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
/** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
|
|
2116
|
+
async remove(paths) {
|
|
2117
|
+
const target = this.#bucketPath("");
|
|
2118
|
+
if (!target.ok) return fail(target.error);
|
|
2119
|
+
return this.#json(
|
|
2120
|
+
{ method: "DELETE", path: target.path, body: { prefixes: paths } },
|
|
2121
|
+
listOf(isFileObject)
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* A link to the object for `expiresIn` seconds (1–604,800). It stops
|
|
2126
|
+
* working once the object is replaced or removed.
|
|
2127
|
+
*/
|
|
2128
|
+
async createSignedUrl(path, expiresIn, options = {}) {
|
|
2129
|
+
const target = this.#objectPath("sign/", path);
|
|
2130
|
+
if (!target.ok) return fail(target.error);
|
|
2131
|
+
const { download } = options ?? {};
|
|
2132
|
+
const signed = await this.#json(
|
|
2133
|
+
{
|
|
2134
|
+
method: "POST",
|
|
2135
|
+
path: target.path,
|
|
2136
|
+
body: {
|
|
2137
|
+
expiresIn,
|
|
2138
|
+
...download === void 0 || download === false ? {} : { download }
|
|
2139
|
+
}
|
|
2140
|
+
},
|
|
2141
|
+
isSigned
|
|
2142
|
+
);
|
|
2143
|
+
if (signed.error !== null) return signed;
|
|
2144
|
+
return {
|
|
2145
|
+
data: { signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}` },
|
|
2146
|
+
error: null
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* A public bucket's URL for the object. No request is made, so whether
|
|
2151
|
+
* the bucket is public is not checked (as in supabase-js); a path no URL
|
|
2152
|
+
* can carry is INVALID_PATH (plan C3), and a download name none can carry
|
|
2153
|
+
* is the worker's INVALID_REQUEST for it.
|
|
2154
|
+
*/
|
|
2155
|
+
getPublicUrl(path, options = {}) {
|
|
2156
|
+
const target = this.#objectPath("public/", path);
|
|
2157
|
+
if (!target.ok) return fail(target.error);
|
|
2158
|
+
const { download } = options ?? {};
|
|
2159
|
+
const query = downloadQuery(download);
|
|
2160
|
+
if (!query.ok) return fail(query.error);
|
|
2161
|
+
return {
|
|
2162
|
+
data: {
|
|
2163
|
+
publicUrl: `${this.#http.baseUrl}/_naro/v1${target.path}${query.encoded}`
|
|
2164
|
+
},
|
|
2165
|
+
error: null
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
var StorageClient = class {
|
|
2170
|
+
#http;
|
|
2171
|
+
#auth;
|
|
2172
|
+
constructor(http, auth) {
|
|
2173
|
+
this.#http = http;
|
|
2174
|
+
this.#auth = auth;
|
|
2175
|
+
}
|
|
2176
|
+
/** The objects of one bucket. */
|
|
2177
|
+
from(bucket) {
|
|
2178
|
+
return new StorageFileApi(this.#http, this.#auth, bucket);
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
|
|
1639
2182
|
// src/client.ts
|
|
1640
2183
|
var CONFIG_MESSAGES = {
|
|
1641
2184
|
url: "createClient needs the project url, like https://my-app.naro.sh.",
|
|
@@ -1668,9 +2211,10 @@ function apiKeyOf(options) {
|
|
|
1668
2211
|
if (hasAnon && hasService) throw new Error(CONFIG_MESSAGES.bothKeys);
|
|
1669
2212
|
const key = hasService ? serviceKey : hasAnon ? anonKey : void 0;
|
|
1670
2213
|
if (key === void 0) throw new Error(CONFIG_MESSAGES.key);
|
|
1671
|
-
|
|
2214
|
+
const service = hasService || key.startsWith(SERVICE_KEY_PREFIX);
|
|
2215
|
+
if (service && !isKnownServer())
|
|
1672
2216
|
throw new Error(CONFIG_MESSAGES.serviceKeyOffServer);
|
|
1673
|
-
return key;
|
|
2217
|
+
return { key, service };
|
|
1674
2218
|
}
|
|
1675
2219
|
function queryRunner(http, auth) {
|
|
1676
2220
|
return async (query) => {
|
|
@@ -1689,14 +2233,21 @@ function queryRunner(http, auth) {
|
|
|
1689
2233
|
};
|
|
1690
2234
|
}
|
|
1691
2235
|
var NaroClient = class {
|
|
1692
|
-
/**
|
|
2236
|
+
/**
|
|
2237
|
+
* Sign-up, sign-in, sign-out and the current user (spec §13); with the
|
|
2238
|
+
* service key, `auth.admin` manages users.
|
|
2239
|
+
*/
|
|
1693
2240
|
auth;
|
|
2241
|
+
/**
|
|
2242
|
+
* Files in the project's Naro Storage (first-release design 2026-10-06
|
|
2243
|
+
* §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
|
|
2244
|
+
* them, as supabase-js does.
|
|
2245
|
+
*/
|
|
2246
|
+
storage;
|
|
1694
2247
|
#run;
|
|
1695
|
-
// naro.storage — Phase 3 (spec §18): R2 storage lands here, beside `auth`
|
|
1696
|
-
// and `from()`. Deliberately absent in Phase 1: not implemented, not typed.
|
|
1697
2248
|
constructor(options) {
|
|
1698
2249
|
const baseUrl = baseUrlOf(options.url);
|
|
1699
|
-
const apiKey = apiKeyOf(options);
|
|
2250
|
+
const { key: apiKey, service } = apiKeyOf(options);
|
|
1700
2251
|
if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
|
|
1701
2252
|
throw new Error(CONFIG_MESSAGES.fetch);
|
|
1702
2253
|
const storage = options.auth?.storage ?? defaultStorage();
|
|
@@ -1714,9 +2265,11 @@ var NaroClient = class {
|
|
|
1714
2265
|
new TokenStore(storage, recoveryKey(baseUrl))
|
|
1715
2266
|
),
|
|
1716
2267
|
detectSessionInUrl: options.auth?.detectSessionInUrl ?? true,
|
|
1717
|
-
shared: { storage, key: storageKey(baseUrl) }
|
|
2268
|
+
shared: { storage, key: storageKey(baseUrl) },
|
|
2269
|
+
serviceKey: service
|
|
1718
2270
|
});
|
|
1719
2271
|
this.#run = queryRunner(http, this.auth);
|
|
2272
|
+
this.storage = new StorageClient(http, this.auth);
|
|
1720
2273
|
}
|
|
1721
2274
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
1722
2275
|
from(table) {
|