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 +70 -5
- package/dist/auth/storage.d.ts +2 -0
- package/dist/http.d.ts +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +213 -23
- package/dist/storage/client.d.ts +41 -2
- package/dist/storage/multipart.d.ts +5 -0
- package/dist/storage/path.d.ts +1 -0
- package/dist/storage/types.d.ts +40 -0
- package/package.json +2 -2
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
|
|
490
|
-
answer its shapes (`FileObject` in
|
|
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`,
|
|
568
|
-
|
|
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`).
|
package/dist/auth/storage.d.ts
CHANGED
|
@@ -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
|
|
138
|
-
const
|
|
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(
|
|
176
|
+
error: await settled(store, error, responseHeaders, token),
|
|
169
177
|
answer
|
|
170
178
|
};
|
|
171
179
|
}
|
|
172
|
-
async function settled(
|
|
173
|
-
if (error.code === "INVALID_SESSION" && token !== null)
|
|
174
|
-
await
|
|
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
|
|
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(
|
|
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.#
|
|
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)
|
|
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.#
|
|
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
|
-
|
|
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, {
|
|
1921
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
package/dist/storage/client.d.ts
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/storage/path.d.ts
CHANGED
|
@@ -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/`
|
package/dist/storage/types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
},
|