naro-js 0.4.0 → 0.5.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 CHANGED
@@ -483,15 +483,49 @@ await avatars.remove([`${user.id}/old.png`]);
483
483
  const { data: link } = await naro.storage
484
484
  .from("docs")
485
485
  .createSignedUrl("team-1/plan.pdf", 600);
486
+ const { data: links } = await naro.storage
487
+ .from("docs")
488
+ .createSignedUrls(["team-1/a.pdf", "team-1/b.pdf"], 600);
489
+ const { data: plans } = await naro.storage
490
+ .from("docs")
491
+ .list("team-1", { search: "plan" });
486
492
  ```
487
493
 
488
494
  - `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`,
489
- `createSignedUrl` and `getPublicUrl` take supabase-js's arguments and
490
- answer its shapes (`FileObject` in snake_case), as `{ data, error }`.
495
+ `createSignedUrl`, `createSignedUrls` and `getPublicUrl` take
496
+ supabase-js's arguments and answer its shapes (`FileObject` in
497
+ snake_case), as `{ data, error }`.
491
498
  One exception: supabase-js's `info` answers a camelCase object
492
499
  (`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
493
500
  answers the same snake_case `FileObject` as `list`, with the size and type
494
501
  in `metadata.size` and `metadata.mimetype`.
502
+ - `createSignedUrls(paths, expiresIn, { download })` signs 1 to 100 paths in
503
+ one request (naro-js 0.5.0) and answers one `{ path, signedUrl, error }`
504
+ per path, in the order given — a path given twice is answered twice. A
505
+ file that is not there, or that the caller cannot read, has
506
+ `signedUrl: null` and the `error`
507
+ `Object <path> was not found in bucket <bucket>.` — the same words for
508
+ both. `signedUrl` is absolute, as `createSignedUrl`'s. The paths go in the
509
+ body unchecked: a bad one fails the call with `INVALID_REQUEST`
510
+ (`details.path` `paths[<i>]`). Unlike supabase-js, `download` goes in the
511
+ request — the token covers it — so adding `&download` to a URL afterwards
512
+ answers `400 INVALID_SIGNATURE`.
513
+ - `list(prefix, { search })` keeps the names directly below `prefix` that
514
+ start with `search`. As in Supabase, `%` and `_` are plain characters, not
515
+ wildcards. Unlike Supabase, only ASCII letters ignore case (Postgres folds
516
+ case by the database's locale) and `search` takes up to 256 characters. A
517
+ folder shows when a name under it starts with `search`.
518
+ - A backend whose Storage was turned on before these two came answers
519
+ `createSignedUrls` with `404 NOT_FOUND` and a `search` with
520
+ `400 INVALID_REQUEST` (`details.path` `search`, `details.reason`
521
+ `search is not supported yet`): update it once with `naro backend enable`.
522
+ - A public file is kept at Naro's edge for up to 5 minutes (or for its own
523
+ `cacheControl`, when that is shorter): after it is deleted, its bucket is
524
+ made private or it is replaced, `getPublicUrl`'s URL can still serve the
525
+ old bytes that long. Put a version in names that change
526
+ (`avatar-<time>.png`) or keep `cacheControl` short. Signed URLs are not
527
+ kept at the edge: replacing or deleting a file ends its signed links at
528
+ once.
495
529
  - An upload sends the file's own bytes, typed as the file is (`Blob.type`,
496
530
  or `application/octet-stream`; a string is `text/plain;charset=utf-8`).
497
531
  The body is a `Blob`, `File`, `ArrayBuffer`, typed array or string — not
@@ -540,6 +574,38 @@ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
540
574
  });
541
575
  ```
542
576
 
577
+ - `createSignedUploadUrl(path, { upsert })` and
578
+ `uploadToSignedUrl(path, token, body, options)` are supabase-js's signed
579
+ upload (naro-js 0.5.0). A server with the service key — or anyone the
580
+ policies let upload there, anon or a signed-in user — makes a token for
581
+ one path; a page with only the anon key uploads by it. The token stands
582
+ in for the policies; the bucket's and the plan's limits and the
583
+ project's usage stops still apply. It works for 2 hours. Made with
584
+ `upsert: true` it may replace the file until then; without it a second
585
+ upload answers `409 OBJECT_EXISTS`.
586
+ `uploadToSignedUrl` sends no session, takes `contentType`,
587
+ `cacheControl`, `onProgress` and `signal` as `upload` does, and answers
588
+ `{ path, fullPath }`. Over 50 MiB it goes in parts with the token on
589
+ every step (the `x-naro-upload-token` header), and once started may
590
+ finish after the 2 hours. A token that is not the path's answers
591
+ `400 INVALID_SIGNATURE` (one no URL can carry is refused so before a
592
+ request), an expired one `400 SIGNED_URL_EXPIRED`. A token is neither
593
+ used up nor revocable: until its 2 hours end it can create its file again
594
+ after the file is deleted, and it keeps working if the user it was made
595
+ for is deleted or banned.
596
+
597
+ ```ts
598
+ // On the server, with the service key:
599
+ const { data, error } = await admin.storage
600
+ .from("avatars")
601
+ .createSignedUploadUrl(`${userId}/me.png`, { upsert: true });
602
+ if (error) throw new Error(error.message);
603
+ // In the browser, with the anon key:
604
+ await naro.storage
605
+ .from("avatars")
606
+ .uploadToSignedUrl(data.path, data.token, file);
607
+ ```
608
+
543
609
  - A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
544
610
  inside, or a character past U+00FF) is answered before a request, as the
545
611
  backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
@@ -564,6 +630,5 @@ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
564
630
  downloads that way.
565
631
  - `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
566
632
  `details.retryAfter`, in seconds.
567
- - Not yet: `move`, `copy`, `createSignedUrls`, `list`'s `search`, resuming
568
- an upload in another session, and bucket methods (buckets are made with
569
- `naro storage bucket`).
633
+ - Not yet: `move`, `copy`, resuming an upload in another session, and bucket
634
+ methods (buckets are made with `naro storage bucket`).
@@ -18,6 +18,8 @@ export declare function memoryStorage(): AuthStorage;
18
18
  export declare function defaultStorage(): AuthStorage;
19
19
  /** One token per project URL, so two clients on one page do not collide. */
20
20
  export declare function storageKey(baseUrl: string): string;
21
+ /** The access JWT of the signed-in session, beside the session token. */
22
+ export declare function accessJwtKey(baseUrl: string): string;
21
23
  /**
22
24
  * The PKCE verifiers of the provider sign-ins this browser began, one per
23
25
  * flow (P2-6 §6, `flows.ts`), kept where the token is.
package/dist/http.d.ts CHANGED
@@ -13,6 +13,12 @@ export type HttpConfig = {
13
13
  apiKey: string;
14
14
  fetch: FetchLike | undefined;
15
15
  tokens: TokenStore;
16
+ /**
17
+ * The short-lived access JWT the sign-in answers carry. Data and Storage
18
+ * requests send it; the auth routes keep the session token in `tokens`.
19
+ * Without it (not signed in) those requests go as anon.
20
+ */
21
+ accessJwt?: TokenStore;
16
22
  };
17
23
  export type CallOptions = {
18
24
  method: "GET" | "POST" | "PUT" | "DELETE";
@@ -20,6 +26,11 @@ export type CallOptions = {
20
26
  path: string;
21
27
  /** Sent as JSON. */
22
28
  body?: unknown;
29
+ /**
30
+ * Headers beside `apikey` and the JSON type — a signed upload's
31
+ * `x-naro-upload-token` (Storage 3-1e-2). `apikey` is always the client's.
32
+ */
33
+ headers?: Record<string, string>;
23
34
  /** Whether to send the stored session token. */
24
35
  withToken: boolean;
25
36
  /**
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { NaroError } from "./errors.js";
6
6
  export type { FetchLike } from "./http.js";
7
7
  export type { StorageClient, StorageFileApi } from "./storage/client.js";
8
8
  export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
9
- export type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
9
+ export type { CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
10
10
  export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
11
11
  export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
12
12
  export type { AdminUser, AdminUserAttributes, AdminUserListResponse, AdminUserResponse, AuthActionResponse, AuthErrorCode, AuthFlowType, AuthResponse, DatabaseShape, EmailRedirectOptions, OAuthProvider, OAuthResponse, SignInWithOAuthOptions, UrlSessionResponse, UserAttributes, GenericTable, GenericView, NaroResponse, OrderOptions, PageParams, Session, SessionResponse, SignInCredentials, SignOutResponse, SignUpCredentials, UntypedDatabase, User, UserResponse, } from "./types.js";
package/dist/index.js CHANGED
@@ -91,6 +91,10 @@ function errorFromBody(status, body) {
91
91
  }
92
92
 
93
93
  // src/http.ts
94
+ var AUTH_PREFIX = "/auth/";
95
+ function storeFor(config, path) {
96
+ return path.startsWith(AUTH_PREFIX) ? config.tokens : config.accessJwt ?? null;
97
+ }
94
98
  async function receive(config, options, init) {
95
99
  const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
96
100
  if (controller) init.signal = controller.signal;
@@ -134,8 +138,12 @@ async function call(config, options) {
134
138
  return { error: invalidQuery("", "is not JSON"), answer: null };
135
139
  }
136
140
  }
137
- const token = options.withToken ? await config.tokens.get() : null;
138
- const headers = { apikey: config.apiKey };
141
+ const store = storeFor(config, options.path);
142
+ const token = options.withToken && store ? await store.get() : null;
143
+ const headers = {
144
+ ...options.headers,
145
+ apikey: config.apiKey
146
+ };
139
147
  if (token !== null) headers.Authorization = `Bearer ${token}`;
140
148
  const init = {
141
149
  method: options.method,
@@ -165,17 +173,18 @@ async function call(config, options) {
165
173
  const error = errorFromBody(status, body);
166
174
  if (error === null) return { error: null, answer };
167
175
  return {
168
- error: await settled(config, error, responseHeaders, token),
176
+ error: await settled(store, error, responseHeaders, token),
169
177
  answer
170
178
  };
171
179
  }
172
- async function settled(config, error, headers, token) {
173
- if (error.code === "INVALID_SESSION" && token !== null)
174
- await config.tokens.clearIf(token);
180
+ async function settled(store, error, headers, token) {
181
+ if (error.code === "INVALID_SESSION" && token !== null && store)
182
+ await store.clearIf(token);
175
183
  return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
176
184
  }
177
185
  async function send(config, options) {
178
- const token = options.withToken ? await config.tokens.get() : null;
186
+ const store = storeFor(config, options.path);
187
+ const token = options.withToken && store ? await store.get() : null;
179
188
  const headers = {
180
189
  ...options.headers,
181
190
  apikey: config.apiKey
@@ -226,7 +235,7 @@ async function send(config, options) {
226
235
  }
227
236
  const error = errorFromBody(status, body) ?? invalidResponse(status);
228
237
  return {
229
- error: await settled(config, error, response.headers, token),
238
+ error: await settled(store, error, response.headers, token),
230
239
  status
231
240
  };
232
241
  }
@@ -421,6 +430,7 @@ async function createPkcePair() {
421
430
 
422
431
  // src/auth/client.ts
423
432
  var TOKEN_HEADER = "set-auth-token";
433
+ var JWT_HEADER = "set-auth-jwt";
424
434
  var FRAGMENT_PREFIX = "naro_";
425
435
  var OAUTH_PROVIDERS = ["google", "github"];
426
436
  var FLOW_TYPES = [
@@ -439,6 +449,9 @@ function nonEmpty(value) {
439
449
  function tokenFrom(answer) {
440
450
  return nonEmpty(answer.headers.get(TOKEN_HEADER));
441
451
  }
452
+ function jwtFrom(answer) {
453
+ return nonEmpty(answer.headers.get(JWT_HEADER));
454
+ }
442
455
  function userFrom(body) {
443
456
  return isRecord(body) && isRecord(body.user) ? body.user : null;
444
457
  }
@@ -601,7 +614,7 @@ var AuthClient = class {
601
614
  const user = userFrom(body);
602
615
  if (token === null || user === null || !isRecord(body) || !isRecord(body.session))
603
616
  return { data: signedOut, error: invalidResponse(result.answer.status) };
604
- await this.#http.tokens.set(token);
617
+ await this.#storeSession(token, jwtFrom(result.answer));
605
618
  await this.#flow.recovery.forget();
606
619
  return { data: { session: body.session, user }, error: null };
607
620
  }
@@ -637,6 +650,7 @@ var AuthClient = class {
637
650
  }
638
651
  if (type === "recovery" && fragment.naro_token) {
639
652
  await this.#http.tokens.clear();
653
+ await this.#http.accessJwt?.clear();
640
654
  await this.#flow.recovery.keep(fragment.naro_token);
641
655
  return { data: none, error: null };
642
656
  }
@@ -727,6 +741,7 @@ var AuthClient = class {
727
741
  withToken: true
728
742
  });
729
743
  await tokens.clearIf(result.answer?.token ?? token);
744
+ await this.#http.accessJwt?.clear();
730
745
  return { data: null, error: result.error };
731
746
  }
732
747
  /**
@@ -748,7 +763,10 @@ var AuthClient = class {
748
763
  if (result.error !== null) return { data: signedOut, error: result.error };
749
764
  const { body, token } = result.answer;
750
765
  if (body === null) {
751
- if (token !== null) await tokens.clearIf(token);
766
+ if (token !== null) {
767
+ await tokens.clearIf(token);
768
+ await this.#http.accessJwt?.clear();
769
+ }
752
770
  return { data: signedOut, error: invalidSession() };
753
771
  }
754
772
  const user = userFrom(body);
@@ -779,6 +797,15 @@ var AuthClient = class {
779
797
  async #pendingUrl() {
780
798
  if (this.#fromUrl) await this.#fromUrl;
781
799
  }
800
+ /**
801
+ * Keeps a new session: its token, and the access JWT beside it. An answer
802
+ * without a JWT drops the old one — it belonged to the previous session.
803
+ */
804
+ async #storeSession(token, jwt) {
805
+ await this.#http.tokens.set(token);
806
+ if (jwt !== null) await this.#http.accessJwt?.set(jwt);
807
+ else await this.#http.accessJwt?.clear();
808
+ }
782
809
  async #signIn(path, body, needsToken) {
783
810
  await this.#flow.recovery.forget();
784
811
  const result = await call(this.#http, {
@@ -798,7 +825,7 @@ var AuthClient = class {
798
825
  data: { user: null },
799
826
  error: invalidResponse(result.answer.status)
800
827
  };
801
- if (token !== null) await this.#http.tokens.set(token);
828
+ if (token !== null) await this.#storeSession(token, jwtFrom(result.answer));
802
829
  return { data: { user }, error: null };
803
830
  }
804
831
  };
@@ -952,6 +979,9 @@ function defaultStorage() {
952
979
  function storageKey(baseUrl) {
953
980
  return `naro-auth-token:${baseUrl}`;
954
981
  }
982
+ function accessJwtKey(baseUrl) {
983
+ return `naro-auth-jwt:${baseUrl}`;
984
+ }
955
985
  function verifierKey(baseUrl) {
956
986
  return `naro-auth-code-verifiers:${baseUrl}`;
957
987
  }
@@ -1854,6 +1884,7 @@ async function readJson(response, guard) {
1854
1884
 
1855
1885
  // src/storage/multipart.ts
1856
1886
  var ROUTE = "/storage/upload/multipart";
1887
+ var UPLOAD_TOKEN_HEADER = "x-naro-upload-token";
1857
1888
  var CONCURRENCY = 4;
1858
1889
  var ATTEMPTS = 4;
1859
1890
  var BACKOFF_MS = [1e3, 2e3, 4e3];
@@ -1884,6 +1915,8 @@ var sleepFor = (ms, signal) => new Promise((done) => {
1884
1915
  async function uploadInParts(a) {
1885
1916
  const { signal } = a;
1886
1917
  const sleep = a.sleep ?? sleepFor;
1918
+ const signed = a.uploadToken === void 0 ? {} : { [UPLOAD_TOKEN_HEADER]: a.uploadToken };
1919
+ const withToken = a.uploadToken === void 0;
1887
1920
  if (signal?.aborted) return fail(uploadAborted());
1888
1921
  const started = await call(a.http, {
1889
1922
  method: a.method,
@@ -1897,7 +1930,8 @@ async function uploadInParts(a) {
1897
1930
  // PUT always replaces: the worker refuses `upsert` on it.
1898
1931
  ...a.method === "POST" && a.upsert ? { upsert: true } : {}
1899
1932
  },
1900
- withToken: true
1933
+ headers: signed,
1934
+ withToken
1901
1935
  });
1902
1936
  if (started.error !== null) return fail(started.error);
1903
1937
  const plan = dataOf2(
@@ -1917,9 +1951,12 @@ async function uploadInParts(a) {
1917
1951
  }
1918
1952
  };
1919
1953
  const callOff = async () => {
1920
- await call(a.http, { method: "DELETE", path: base, withToken: true }).catch(
1921
- () => void 0
1922
- );
1954
+ await call(a.http, {
1955
+ method: "DELETE",
1956
+ path: base,
1957
+ headers: signed,
1958
+ withToken
1959
+ }).catch(() => void 0);
1923
1960
  };
1924
1961
  const etags = [];
1925
1962
  const failure = {
@@ -1934,9 +1971,9 @@ async function uploadInParts(a) {
1934
1971
  const sent = await send(a.http, {
1935
1972
  method: "PUT",
1936
1973
  path: `${base}/${String(n)}`,
1937
- headers: { "content-type": "application/octet-stream" },
1974
+ headers: { "content-type": "application/octet-stream", ...signed },
1938
1975
  body: bytes,
1939
- withToken: true,
1976
+ withToken,
1940
1977
  signal
1941
1978
  });
1942
1979
  if (sent.error !== null) return sent;
@@ -1986,7 +2023,8 @@ async function uploadInParts(a) {
1986
2023
  body: {
1987
2024
  parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
1988
2025
  },
1989
- withToken: true
2026
+ headers: signed,
2027
+ withToken
1990
2028
  });
1991
2029
  if (completed.error !== null) {
1992
2030
  if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
@@ -2002,7 +2040,8 @@ var MAX_SINGLE_UPLOAD_BYTES = 52428800;
2002
2040
  var STORAGE_MESSAGES = {
2003
2041
  bucket: "The bucket name is not valid.",
2004
2042
  request: "The storage request is not valid.",
2005
- body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
2043
+ body: "upload takes a Blob, File, ArrayBuffer, typed array or string.",
2044
+ signature: "The signed URL is not valid."
2006
2045
  };
2007
2046
  var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
2008
2047
  "authenticated",
@@ -2153,9 +2192,23 @@ function payloadOf(body, contentType) {
2153
2192
 
2154
2193
  // src/storage/client.ts
2155
2194
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
2195
+ var SIGNED_UPLOAD_PREFIX = "/_naro/v1/storage/object/upload/sign/";
2196
+ var TOKEN_TEXT = /^[\x21-\x7e]+$/;
2197
+ var TOKEN_PARAMETER = /([?&]token=)[^&#\s"'<>]*/g;
2198
+ function withoutToken(error, token) {
2199
+ const { details } = error;
2200
+ if (!isRecord(details) || typeof details.cause !== "string") return error;
2201
+ let cause = details.cause.replace(TOKEN_PARAMETER, "$1[redacted]");
2202
+ for (const form of [encodeURIComponent(token), token])
2203
+ cause = cause.replaceAll(form, "[redacted]");
2204
+ return { ...error, details: { ...details, cause } };
2205
+ }
2156
2206
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
2157
2207
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
2158
2208
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
2209
+ var isSignedEntry = (value) => isRecord(value) && typeof value.path === "string" && (value.signedUrl === null || typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX)) && (value.error === null || typeof value.error === "string");
2210
+ var isSignedUpload = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_UPLOAD_PREFIX) && typeof value.token === "string" && value.token !== "" && typeof value.path === "string";
2211
+ var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
2159
2212
  function listOf(guard) {
2160
2213
  return (value) => Array.isArray(value) && value.every(guard);
2161
2214
  }
@@ -2203,8 +2256,7 @@ var StorageFileApi = class {
2203
2256
  method,
2204
2257
  bucket: this.#bucket,
2205
2258
  path,
2206
- // No copy: a Blob stays lazy, a Uint8Array is viewed, an ArrayBuffer wrapped.
2207
- body: isArrayBuffer(payload.body) ? new Uint8Array(payload.body) : payload.body,
2259
+ body: partsBody(payload.body),
2208
2260
  size: payload.size,
2209
2261
  contentType: payload.type,
2210
2262
  cacheControl: cache,
@@ -2284,7 +2336,11 @@ var StorageFileApi = class {
2284
2336
  }
2285
2337
  /**
2286
2338
  * What is directly below `prefix` (the whole bucket by default): folders
2287
- * first, then files. The options go to the worker as they are given.
2339
+ * first, then files. `search` keeps the names below the prefix that start
2340
+ * with it, ASCII case ignored (Storage 3-1g); a backend whose Storage was
2341
+ * turned on before it answers `INVALID_REQUEST` (`details.path`
2342
+ * `search`) — update it with `naro backend enable`. The options go to the
2343
+ * worker as they are given.
2288
2344
  */
2289
2345
  async list(prefix, options = {}) {
2290
2346
  const target = this.#bucketPath("list/");
@@ -2332,6 +2388,139 @@ var StorageFileApi = class {
2332
2388
  error: null
2333
2389
  };
2334
2390
  }
2391
+ /**
2392
+ * Links to several objects at once (Storage 3-1g): up to 100 paths, each
2393
+ * for `expiresIn` seconds (1–604,800), with the same `download`. One entry
2394
+ * per path, in the order given — a path given twice is answered twice.
2395
+ * A path that is not there, or that the caller cannot read, has
2396
+ * `signedUrl: null` and `error` "Object <path> was not found in bucket
2397
+ * <bucket>." — the same words for both. The paths go in the body, so the
2398
+ * backend checks them: a bad one fails the call with `INVALID_REQUEST`
2399
+ * (`details.path` `paths[<i>]`). A backend whose Storage was turned on
2400
+ * before this method came answers `NOT_FOUND`: update it with
2401
+ * `naro backend enable`.
2402
+ */
2403
+ async createSignedUrls(paths, expiresIn, options = {}) {
2404
+ const target = this.#bucketPath("sign/");
2405
+ if (!target.ok) return fail(target.error);
2406
+ const { download } = options ?? {};
2407
+ const signed = await this.#json(
2408
+ {
2409
+ method: "POST",
2410
+ path: target.path,
2411
+ body: {
2412
+ expiresIn,
2413
+ paths,
2414
+ ...download === void 0 || download === false ? {} : { download }
2415
+ }
2416
+ },
2417
+ listOf(isSignedEntry)
2418
+ );
2419
+ if (signed.error !== null) return signed;
2420
+ return {
2421
+ data: signed.data.map((entry) => ({
2422
+ path: entry.path,
2423
+ signedUrl: entry.signedUrl === null ? null : `${this.#http.baseUrl}${entry.signedUrl}`,
2424
+ error: entry.error
2425
+ })),
2426
+ error: null
2427
+ };
2428
+ }
2429
+ /**
2430
+ * A token that lets whoever holds it upload `path` for two hours (Storage
2431
+ * 3-1e-2), as supabase-js's: made where the key is — a server with the
2432
+ * service key for any path, anon or a signed-in user only where the
2433
+ * policies let them upload. With `upsert: true` it may replace a file
2434
+ * that is there.
2435
+ * The token goes to `uploadToSignedUrl`; `signedUrl` is where a client
2436
+ * without naro-js PUTs the bytes.
2437
+ */
2438
+ async createSignedUploadUrl(path, options = {}) {
2439
+ const target = this.#objectPath("upload/sign/", path);
2440
+ if (!target.ok) return fail(target.error);
2441
+ const upsert = (options ?? {}).upsert === true;
2442
+ const signed = await this.#json(
2443
+ {
2444
+ method: "POST",
2445
+ path: target.path,
2446
+ body: upsert ? { upsert: true } : {}
2447
+ },
2448
+ isSignedUpload
2449
+ );
2450
+ if (signed.error !== null) return signed;
2451
+ return {
2452
+ data: {
2453
+ signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}`,
2454
+ token: signed.data.token,
2455
+ path: signed.data.path
2456
+ },
2457
+ error: null
2458
+ };
2459
+ }
2460
+ /**
2461
+ * Uploads `body` to `path` with a token from `createSignedUploadUrl`
2462
+ * (Storage 3-1e-2). No session is sent, or needed: the token is the
2463
+ * permission, so a page's anon-key client uploads where only a server
2464
+ * could sign. It works until the token expires, two hours from when it
2465
+ * was made; one made without `upsert` uploads one new file
2466
+ * (`OBJECT_EXISTS` for a name that is there). A body over 50 MiB goes in
2467
+ * parts with the token on every step, and once started may finish after
2468
+ * the two hours. `contentType`, `cacheControl`, `onProgress` and `signal`
2469
+ * are `upload`'s.
2470
+ */
2471
+ async uploadToSignedUrl(path, token, body, options = {}) {
2472
+ const given = options ?? {};
2473
+ const target = this.#objectPath("upload/sign/", path);
2474
+ if (!target.ok) return fail(target.error);
2475
+ if (typeof token !== "string" || !TOKEN_TEXT.test(token))
2476
+ return fail({
2477
+ code: "INVALID_SIGNATURE",
2478
+ message: STORAGE_MESSAGES.signature
2479
+ });
2480
+ const payload = payloadOf(body, given.contentType);
2481
+ if (!payload.ok) return fail(payload.error);
2482
+ const cache = `max-age=${given.cacheControl ?? "3600"}`;
2483
+ const unreadable = headerProblem(given.contentType, cache);
2484
+ if (unreadable !== null) return fail(unreadable);
2485
+ const { signal } = given;
2486
+ if (signal?.aborted) return fail(uploadAborted());
2487
+ const uploaded = payload.size > MAX_SINGLE_UPLOAD_BYTES ? await uploadInParts({
2488
+ http: this.#http,
2489
+ method: "POST",
2490
+ bucket: this.#bucket,
2491
+ path,
2492
+ body: partsBody(payload.body),
2493
+ size: payload.size,
2494
+ contentType: payload.type,
2495
+ cacheControl: cache,
2496
+ upsert: false,
2497
+ uploadToken: token,
2498
+ ...given.onProgress ? { onProgress: given.onProgress } : {},
2499
+ signal
2500
+ }) : await this.#putSigned(target.path, token, payload, cache, signal);
2501
+ if (uploaded.error !== null) return fail(uploaded.error);
2502
+ return {
2503
+ data: { path: uploaded.data.path, fullPath: uploaded.data.fullPath },
2504
+ error: null
2505
+ };
2506
+ }
2507
+ /**
2508
+ * The one-request signed upload: the token in the query, no session — it
2509
+ * is the permission. A failure's cause never carries the token.
2510
+ */
2511
+ async #putSigned(path, token, payload, cache, signal) {
2512
+ const sent = await send(this.#http, {
2513
+ method: "PUT",
2514
+ path: `${path}?token=${encodeURIComponent(token)}`,
2515
+ headers: { "content-type": payload.type, "cache-control": cache },
2516
+ body: payload.body,
2517
+ withToken: false,
2518
+ signal
2519
+ });
2520
+ if (sent.error !== null) return fail(withoutToken(sent.error, token));
2521
+ const read = await readJson(sent.response, isUploadResult);
2522
+ return read.error === null ? read : fail(withoutToken(read.error, token));
2523
+ }
2335
2524
  /**
2336
2525
  * A public bucket's URL for the object. No request is made, so whether
2337
2526
  * the bucket is public is not checked (as in supabase-js); a path no URL
@@ -2441,7 +2630,8 @@ var NaroClient = class {
2441
2630
  baseUrl,
2442
2631
  apiKey,
2443
2632
  fetch: options.fetch,
2444
- tokens: new TokenStore(storage, storageKey(baseUrl))
2633
+ tokens: new TokenStore(storage, storageKey(baseUrl)),
2634
+ accessJwt: new TokenStore(storage, accessJwtKey(baseUrl))
2445
2635
  };
2446
2636
  this.auth = new AuthClient(http, {
2447
2637
  verifiers: new FlowVerifiers(
@@ -1,7 +1,7 @@
1
1
  import type { AuthClient } from "../auth/client.js";
2
2
  import { type HttpConfig } from "../http.js";
3
3
  import type { NaroResponse } from "../types.js";
4
- import type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
4
+ import type { CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
5
5
  /** One bucket's objects: supabase-js's StorageFileApi. */
6
6
  export declare class StorageFileApi {
7
7
  #private;
@@ -31,7 +31,11 @@ export declare class StorageFileApi {
31
31
  exists(path: string): Promise<NaroResponse<boolean>>;
32
32
  /**
33
33
  * What is directly below `prefix` (the whole bucket by default): folders
34
- * first, then files. The options go to the worker as they are given.
34
+ * first, then files. `search` keeps the names below the prefix that start
35
+ * with it, ASCII case ignored (Storage 3-1g); a backend whose Storage was
36
+ * turned on before it answers `INVALID_REQUEST` (`details.path`
37
+ * `search`) — update it with `naro backend enable`. The options go to the
38
+ * worker as they are given.
35
39
  */
36
40
  list(prefix?: string, options?: ListOptions): Promise<NaroResponse<(FileObject | FolderEntry)[]>>;
37
41
  /** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
@@ -43,6 +47,41 @@ export declare class StorageFileApi {
43
47
  createSignedUrl(path: string, expiresIn: number, options?: DownloadOption): Promise<NaroResponse<{
44
48
  signedUrl: string;
45
49
  }>>;
50
+ /**
51
+ * Links to several objects at once (Storage 3-1g): up to 100 paths, each
52
+ * for `expiresIn` seconds (1–604,800), with the same `download`. One entry
53
+ * per path, in the order given — a path given twice is answered twice.
54
+ * A path that is not there, or that the caller cannot read, has
55
+ * `signedUrl: null` and `error` "Object <path> was not found in bucket
56
+ * <bucket>." — the same words for both. The paths go in the body, so the
57
+ * backend checks them: a bad one fails the call with `INVALID_REQUEST`
58
+ * (`details.path` `paths[<i>]`). A backend whose Storage was turned on
59
+ * before this method came answers `NOT_FOUND`: update it with
60
+ * `naro backend enable`.
61
+ */
62
+ createSignedUrls(paths: string[], expiresIn: number, options?: DownloadOption): Promise<NaroResponse<SignedUrlEntry[]>>;
63
+ /**
64
+ * A token that lets whoever holds it upload `path` for two hours (Storage
65
+ * 3-1e-2), as supabase-js's: made where the key is — a server with the
66
+ * service key for any path, anon or a signed-in user only where the
67
+ * policies let them upload. With `upsert: true` it may replace a file
68
+ * that is there.
69
+ * The token goes to `uploadToSignedUrl`; `signedUrl` is where a client
70
+ * without naro-js PUTs the bytes.
71
+ */
72
+ createSignedUploadUrl(path: string, options?: CreateSignedUploadUrlOptions): Promise<NaroResponse<SignedUploadUrl>>;
73
+ /**
74
+ * Uploads `body` to `path` with a token from `createSignedUploadUrl`
75
+ * (Storage 3-1e-2). No session is sent, or needed: the token is the
76
+ * permission, so a page's anon-key client uploads where only a server
77
+ * could sign. It works until the token expires, two hours from when it
78
+ * was made; one made without `upsert` uploads one new file
79
+ * (`OBJECT_EXISTS` for a name that is there). A body over 50 MiB goes in
80
+ * parts with the token on every step, and once started may finish after
81
+ * the two hours. `contentType`, `cacheControl`, `onProgress` and `signal`
82
+ * are `upload`'s.
83
+ */
84
+ uploadToSignedUrl(path: string, token: string, body: UploadBody, options?: UpdateOptions): Promise<NaroResponse<SignedUploadResult>>;
46
85
  /**
47
86
  * A public bucket's URL for the object. No request is made, so whether
48
87
  * the bucket is public is not checked (as in supabase-js); a path no URL
@@ -14,6 +14,11 @@ export type PartsArgs = {
14
14
  /** As the upload's header says it: `max-age=<seconds>`. */
15
15
  cacheControl: string;
16
16
  upsert: boolean;
17
+ /**
18
+ * A signed upload's token (Storage 3-1e-2): every step carries it and
19
+ * none carries the session.
20
+ */
21
+ uploadToken?: string | undefined;
17
22
  onProgress?: (sentBytes: number, totalBytes: number) => void;
18
23
  /** The caller's: stops the upload until the complete is sent. */
19
24
  signal?: AbortSignal | undefined;
@@ -11,6 +11,7 @@ export declare const STORAGE_MESSAGES: {
11
11
  readonly bucket: "The bucket name is not valid.";
12
12
  readonly request: "The storage request is not valid.";
13
13
  readonly body: "upload takes a Blob, File, ArrayBuffer, typed array or string.";
14
+ readonly signature: "The signed URL is not valid.";
14
15
  };
15
16
  /**
16
17
  * The words the worker's routes take as the first segment after `/object/`
@@ -70,8 +70,48 @@ export type ListOptions = {
70
70
  column?: "name" | "created_at" | "updated_at";
71
71
  order?: "asc" | "desc";
72
72
  };
73
+ /**
74
+ * Names below the prefix that start with it, ASCII case ignored; % and _
75
+ * are plain characters. Up to 256 characters. A folder shows when a name
76
+ * under it starts with it. A backend whose Storage was turned on before
77
+ * search came answers INVALID_REQUEST (`details.path` "search"): update
78
+ * it with `naro backend enable`.
79
+ */
80
+ search?: string;
73
81
  };
74
82
  /** `true` downloads under the object's own name, a string under that name. */
75
83
  export type DownloadOption = {
76
84
  download?: boolean | string;
77
85
  };
86
+ /** `createSignedUploadUrl`'s options (Storage 3-1e-2). */
87
+ export type CreateSignedUploadUrlOptions = {
88
+ /** Let the token replace a file of the same name. Default false: a second upload is `OBJECT_EXISTS`. */
89
+ upsert?: boolean;
90
+ };
91
+ /**
92
+ * A signed upload's token (Storage 3-1e-2): `token` for `uploadToSignedUrl`,
93
+ * and `signedUrl` — on the project's URL — where a client without naro-js
94
+ * PUTs the file's bytes with no key. It works for two hours from when it
95
+ * was made.
96
+ */
97
+ export type SignedUploadUrl = {
98
+ signedUrl: string;
99
+ token: string;
100
+ path: string;
101
+ };
102
+ /** What `uploadToSignedUrl` answers: supabase-js's shape. */
103
+ export type SignedUploadResult = {
104
+ path: string;
105
+ fullPath: string;
106
+ };
107
+ /**
108
+ * One path's answer of `createSignedUrls` (Storage 3-1g): supabase-js's
109
+ * shape without its `signedURL` copy. `signedUrl` is null, and `error`
110
+ * says `Object <path> was not found in bucket <bucket>.`, for a file that
111
+ * is not there or that the caller cannot read — the same words for both.
112
+ */
113
+ export type SignedUrlEntry = {
114
+ path: string;
115
+ signedUrl: string | null;
116
+ error: string | null;
117
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-js",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Auth, data and files for your Naro project, from the browser or the server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -23,8 +23,8 @@
23
23
  "tsup": "8.5.0",
24
24
  "typescript": "7.0.2",
25
25
  "vitest": "3.2.7",
26
- "@next-deploy/backend-worker": "0.1.0",
27
26
  "@next-deploy/config": "0.1.0",
27
+ "@next-deploy/backend-worker": "0.1.0",
28
28
  "@next-deploy/data-core": "0.1.0",
29
29
  "@next-deploy/sdk": "0.1.0"
30
30
  },