naro-js 0.2.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 +104 -2
- package/dist/client.d.ts +7 -0
- package/dist/http.d.ts +26 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +434 -6
- 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/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# naro-js
|
|
2
2
|
|
|
3
|
-
Auth and
|
|
3
|
+
Auth, data and files for your [Naro](https://naro.sh) project, from the browser or
|
|
4
4
|
the server. A project's backend brings its own database; this client signs
|
|
5
5
|
users in and reads and writes it through the project's policies. No SQL, no
|
|
6
6
|
database credentials, no dependencies.
|
|
@@ -78,6 +78,45 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
78
78
|
- Admin accounts, closed sign-ups, sign-in by username and the password
|
|
79
79
|
length — see [Admin accounts and closed sign-ups](#admin-accounts-and-closed-sign-ups).
|
|
80
80
|
- Email and social sign-in — see [Sign-in flows](#sign-in-flows).
|
|
81
|
+
- Files — see [Storage](#storage).
|
|
82
|
+
|
|
83
|
+
## Policies
|
|
84
|
+
|
|
85
|
+
What a request may do is decided by the table's policies — Postgres's
|
|
86
|
+
row-level security, as in Supabase. A table without a policy refuses every
|
|
87
|
+
anon and signed-in request (only the service key gets through), so write a
|
|
88
|
+
table's policies in the migration that creates it: `create policy` after the
|
|
89
|
+
`create table`, in the same file. `naro db push` applies them with the table,
|
|
90
|
+
all or nothing.
|
|
91
|
+
|
|
92
|
+
```sql
|
|
93
|
+
-- migrations/0002_todos.sql: each signed-in user reads and changes their own rows.
|
|
94
|
+
create table todos (
|
|
95
|
+
id integer primary key,
|
|
96
|
+
user_id uuid not null,
|
|
97
|
+
title text not null
|
|
98
|
+
);
|
|
99
|
+
create policy "own rows" on todos for all to authenticated
|
|
100
|
+
using ((select auth.uid()) = user_id)
|
|
101
|
+
with check ((select auth.uid()) = user_id);
|
|
102
|
+
|
|
103
|
+
-- migrations/0003_posts.sql: anyone reads, signed in or not.
|
|
104
|
+
create table posts (id integer primary key, title text not null);
|
|
105
|
+
create policy "read" on posts for select to anon, authenticated using (true);
|
|
106
|
+
|
|
107
|
+
-- migrations/0004_reports.sql: an app only its admins sign in to. First run
|
|
108
|
+
-- naro backend auth signups off and create the admin accounts: authenticated
|
|
109
|
+
-- then means exactly those accounts.
|
|
110
|
+
create table reports (id integer primary key, body text not null);
|
|
111
|
+
create policy "admins" on reports for all to authenticated using (true) with check (true);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A request no policy lets through, and a written row that fails a check,
|
|
115
|
+
answer `403 PERMISSION_DENIED`; rows a policy's `using` does not pass are left
|
|
116
|
+
out without an error. The `naro-cli` README lists what a
|
|
117
|
+
migration accepts and refuses (`DISABLE ROW LEVEL SECURITY`, `GRANT`,
|
|
118
|
+
`REVOKE` and `ALTER POLICY` are refused), the warnings it answers with, and
|
|
119
|
+
`naro policy set`, a shorthand for the common rules.
|
|
81
120
|
|
|
82
121
|
## Admin accounts and closed sign-ups
|
|
83
122
|
|
|
@@ -413,4 +452,67 @@ await naro.from("posts").select().contains("tags", ["a", "b"]);
|
|
|
413
452
|
- Limits: groups nest 4 deep (`not` counts); one request holds at most 100
|
|
414
453
|
conditions, 4 of them `contains` / `containedBy` / `overlaps`; one
|
|
415
454
|
statement binds at most 100 values (a select's limit and offset are two of
|
|
416
|
-
them) and compiles to at most 100,000 bytes of SQL.
|
|
455
|
+
them) and compiles to at most 100,000 bytes of SQL. A `like` or `ilike`
|
|
456
|
+
pattern is at most 50 bytes of UTF-8, which is D1's limit; in `like`, a
|
|
457
|
+
literal `?` or `[` counts as 3. A longer one is `400 INVALID_QUERY` before
|
|
458
|
+
the database is asked.
|
|
459
|
+
|
|
460
|
+
## Storage
|
|
461
|
+
|
|
462
|
+
Files your users upload — avatars, attachments, documents — go in the
|
|
463
|
+
project's Storage: Cloudflare R2 behind the same URL, under policies on
|
|
464
|
+
`storage.objects` written as in Supabase. Turn it on with
|
|
465
|
+
`naro storage enable` (the backend must be on), make buckets with
|
|
466
|
+
`naro storage bucket create avatars --public`, and write the policies in a
|
|
467
|
+
migration that `naro db push` applies:
|
|
468
|
+
|
|
469
|
+
```sql
|
|
470
|
+
create policy "avatars are readable" on storage.objects
|
|
471
|
+
for select to public using (bucket_id = 'avatars');
|
|
472
|
+
create policy "upload into own folder" on storage.objects
|
|
473
|
+
for insert to authenticated
|
|
474
|
+
with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
const avatars = naro.storage.from("avatars");
|
|
479
|
+
await avatars.upload(`${user.id}/me.png`, file, { upsert: true });
|
|
480
|
+
const { data: image } = avatars.getPublicUrl(`${user.id}/me.png`); // no request
|
|
481
|
+
const { data: files } = await avatars.list(user.id, { limit: 20 });
|
|
482
|
+
await avatars.remove([`${user.id}/old.png`]);
|
|
483
|
+
const { data: link } = await naro.storage
|
|
484
|
+
.from("docs")
|
|
485
|
+
.createSignedUrl("team-1/plan.pdf", 600);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
- `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 }`.
|
|
491
|
+
One exception: supabase-js's `info` answers a camelCase object
|
|
492
|
+
(`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
|
|
493
|
+
answers the same snake_case `FileObject` as `list`, with the size and type
|
|
494
|
+
in `metadata.size` and `metadata.mimetype`.
|
|
495
|
+
- An upload sends the file's own bytes, 50 MiB at most, typed as the file
|
|
496
|
+
is (`Blob.type`, or `application/octet-stream`; a string is
|
|
497
|
+
`text/plain;charset=utf-8`). The body is a `Blob`, `File`, `ArrayBuffer`,
|
|
498
|
+
typed array or string — not a stream or `FormData`, and not a buffer
|
|
499
|
+
transferred away (detached), which has no bytes left: `INVALID_BODY`.
|
|
500
|
+
- A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
|
|
501
|
+
inside, or a character past U+00FF) is answered before a request, as the
|
|
502
|
+
backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
|
|
503
|
+
`INVALID_REQUEST` with `details.path` `cache-control`.
|
|
504
|
+
- supabase-js's `upload(path, file)` sends `multipart/form-data`, which Naro
|
|
505
|
+
refuses with `400 INVALID_REQUEST`. Without naro-js, send the file's bytes
|
|
506
|
+
as the body of `POST /_naro/v1/storage/object/<bucket>/<path>`.
|
|
507
|
+
- Refused before a request, with the backend's own words (`INVALID_PATH`):
|
|
508
|
+
an empty path, a `/` at either end, `//`, a `.` or `..` segment, a lone
|
|
509
|
+
surrogate (`is not valid UTF-8`: no URL can carry it), and a bucket named
|
|
510
|
+
`""`, `.`, `..`, with a lone surrogate or a word the storage routes use
|
|
511
|
+
(`The bucket name is not valid.`). Everything else is the backend's to
|
|
512
|
+
check.
|
|
513
|
+
- `exists` answers `false` for `OBJECT_NOT_FOUND` and passes every other
|
|
514
|
+
error on. `getPublicUrl` makes no request and answers `{ data, error }`.
|
|
515
|
+
- `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
|
|
516
|
+
`details.retryAfter`, in seconds.
|
|
517
|
+
- Not yet: `move`, `copy`, `createSignedUrls`, `list`'s `search`, files over
|
|
518
|
+
50 MiB, and bucket methods (buckets are made with `naro storage bucket`).
|
package/dist/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { AuthClient } from "./auth/client.js";
|
|
|
2
2
|
import { type AuthStorage } from "./auth/storage.js";
|
|
3
3
|
import { type FetchLike } from "./http.js";
|
|
4
4
|
import { TableBuilder } from "./query/builder.js";
|
|
5
|
+
import { StorageClient } from "./storage/client.js";
|
|
5
6
|
import type { DatabaseShape, InsertOf, RowOf, TableName, TablesOf, UntypedDatabase, UpdateOf } from "./types.js";
|
|
6
7
|
type CommonOptions = {
|
|
7
8
|
/**
|
|
@@ -51,6 +52,12 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
|
|
|
51
52
|
* service key, `auth.admin` manages users.
|
|
52
53
|
*/
|
|
53
54
|
readonly auth: AuthClient;
|
|
55
|
+
/**
|
|
56
|
+
* Files in the project's Naro Storage (first-release design 2026-10-06
|
|
57
|
+
* §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
|
|
58
|
+
* them, as supabase-js does.
|
|
59
|
+
*/
|
|
60
|
+
readonly storage: StorageClient;
|
|
54
61
|
constructor(options: ClientOptions);
|
|
55
62
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
56
63
|
from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
|
package/dist/http.d.ts
CHANGED
|
@@ -53,10 +53,32 @@ export type CallResult = {
|
|
|
53
53
|
* §11.1), but only if it is still the token this request carried.
|
|
54
54
|
*/
|
|
55
55
|
export declare function call(config: HttpConfig, options: CallOptions): Promise<CallResult>;
|
|
56
|
+
export type SendOptions = {
|
|
57
|
+
method: "GET" | "POST" | "PUT";
|
|
58
|
+
/** Below `/_naro/v1`, e.g. `/storage/object/avatars/me.png`. */
|
|
59
|
+
path: string;
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
body?: BodyInit;
|
|
62
|
+
withToken: boolean;
|
|
63
|
+
};
|
|
64
|
+
export type SendResult = {
|
|
65
|
+
error: NaroError;
|
|
66
|
+
} | {
|
|
67
|
+
error: null;
|
|
68
|
+
response: Response;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* A request whose body or answer is not JSON (Storage, first-release design
|
|
72
|
+
* 2026-10-06 §8.1): an upload's bytes go as they are, a download's come
|
|
73
|
+
* back unread. The same `apikey`, bearer token and no cookies as `call`;
|
|
74
|
+
* a failing answer is read the way `call` reads one — the worker's error
|
|
75
|
+
* envelope, or INVALID_RESPONSE — and every case resolves.
|
|
76
|
+
*/
|
|
77
|
+
export declare function send(config: HttpConfig, options: SendOptions): Promise<SendResult>;
|
|
56
78
|
/**
|
|
57
|
-
* The worker's
|
|
58
|
-
* limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the
|
|
59
|
-
* database write limit — Data API writes, sign-up
|
|
60
|
-
* users wait for 00:00 UTC, reads still work.
|
|
79
|
+
* The worker's 429s that say how long to wait. `RATE_LIMITED` is the
|
|
80
|
+
* sign-in routes' per-address limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the
|
|
81
|
+
* project over its daily database write limit — Data API writes, sign-up
|
|
82
|
+
* and creating or deleting users wait for 00:00 UTC, reads still work.
|
|
61
83
|
*/
|
|
62
84
|
export declare const RETRY_AFTER_CODES: ReadonlySet<string>;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type { AuthClient } from "./auth/client.js";
|
|
|
4
4
|
export type { AuthStorage } from "./auth/storage.js";
|
|
5
5
|
export type { NaroError } from "./errors.js";
|
|
6
6
|
export type { FetchLike } from "./http.js";
|
|
7
|
+
export type { StorageClient, StorageFileApi } from "./storage/client.js";
|
|
8
|
+
export type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
|
|
7
9
|
export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
|
|
8
10
|
export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
|
|
9
11
|
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
|
@@ -158,15 +158,65 @@ async function call(config, options) {
|
|
|
158
158
|
const answer = { status, headers: responseHeaders, body, token };
|
|
159
159
|
const error = errorFromBody(status, body);
|
|
160
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) {
|
|
161
167
|
if (error.code === "INVALID_SESSION" && token !== null)
|
|
162
168
|
await config.tokens.clearIf(token);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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) };
|
|
166
212
|
}
|
|
167
213
|
var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
168
214
|
"RATE_LIMITED",
|
|
169
|
-
"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"
|
|
170
220
|
]);
|
|
171
221
|
function withRetryAfter(error, headers) {
|
|
172
222
|
const header = headers.get("x-retry-after")?.trim() ?? "";
|
|
@@ -1756,6 +1806,379 @@ var TableBuilder = class {
|
|
|
1756
1806
|
}
|
|
1757
1807
|
};
|
|
1758
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
|
+
|
|
1759
2182
|
// src/client.ts
|
|
1760
2183
|
var CONFIG_MESSAGES = {
|
|
1761
2184
|
url: "createClient needs the project url, like https://my-app.naro.sh.",
|
|
@@ -1815,9 +2238,13 @@ var NaroClient = class {
|
|
|
1815
2238
|
* service key, `auth.admin` manages users.
|
|
1816
2239
|
*/
|
|
1817
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;
|
|
1818
2247
|
#run;
|
|
1819
|
-
// naro.storage — Phase 3 (spec §18): R2 storage lands here, beside `auth`
|
|
1820
|
-
// and `from()`. Deliberately absent in Phase 1: not implemented, not typed.
|
|
1821
2248
|
constructor(options) {
|
|
1822
2249
|
const baseUrl = baseUrlOf(options.url);
|
|
1823
2250
|
const { key: apiKey, service } = apiKeyOf(options);
|
|
@@ -1842,6 +2269,7 @@ var NaroClient = class {
|
|
|
1842
2269
|
serviceKey: service
|
|
1843
2270
|
});
|
|
1844
2271
|
this.#run = queryRunner(http, this.auth);
|
|
2272
|
+
this.storage = new StorageClient(http, this.auth);
|
|
1845
2273
|
}
|
|
1846
2274
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
1847
2275
|
from(table) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AuthClient } from "../auth/client.js";
|
|
2
|
+
import { type HttpConfig } from "../http.js";
|
|
3
|
+
import type { NaroResponse } from "../types.js";
|
|
4
|
+
import type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
|
|
5
|
+
/** One bucket's objects: supabase-js's StorageFileApi. */
|
|
6
|
+
export declare class StorageFileApi {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(http: HttpConfig, auth: AuthClient, bucket: string);
|
|
9
|
+
/**
|
|
10
|
+
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
11
|
+
* `null` options, as a JS caller may pass, are no options.
|
|
12
|
+
*/
|
|
13
|
+
upload(path: string, body: UploadBody, options?: UploadOptions): Promise<NaroResponse<UploadResult>>;
|
|
14
|
+
/** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
|
|
15
|
+
update(path: string, body: UploadBody, options?: UpdateOptions): Promise<NaroResponse<UploadResult>>;
|
|
16
|
+
/**
|
|
17
|
+
* The object's bytes, as a Blob of the type the worker serves it under:
|
|
18
|
+
* the stored type for the images, audio, video, PDF, plain text, CSV,
|
|
19
|
+
* JSON and SVG it serves as themselves, `application/octet-stream` for
|
|
20
|
+
* anything else (HTML, scripts, archives, unknown types — `info` and
|
|
21
|
+
* `list` hold the stored type).
|
|
22
|
+
*/
|
|
23
|
+
download(path: string): Promise<NaroResponse<Blob>>;
|
|
24
|
+
info(path: string): Promise<NaroResponse<FileObject>>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
|
|
27
|
+
* every other error comes back as it is (plan C1 — asked with `info`,
|
|
28
|
+
* not HEAD, whose answer has no body to tell a missing bucket from a
|
|
29
|
+
* missing object).
|
|
30
|
+
*/
|
|
31
|
+
exists(path: string): Promise<NaroResponse<boolean>>;
|
|
32
|
+
/**
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
list(prefix?: string, options?: ListOptions): Promise<NaroResponse<(FileObject | FolderEntry)[]>>;
|
|
37
|
+
/** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
|
|
38
|
+
remove(paths: string[]): Promise<NaroResponse<FileObject[]>>;
|
|
39
|
+
/**
|
|
40
|
+
* A link to the object for `expiresIn` seconds (1–604,800). It stops
|
|
41
|
+
* working once the object is replaced or removed.
|
|
42
|
+
*/
|
|
43
|
+
createSignedUrl(path: string, expiresIn: number, options?: DownloadOption): Promise<NaroResponse<{
|
|
44
|
+
signedUrl: string;
|
|
45
|
+
}>>;
|
|
46
|
+
/**
|
|
47
|
+
* A public bucket's URL for the object. No request is made, so whether
|
|
48
|
+
* the bucket is public is not checked (as in supabase-js); a path no URL
|
|
49
|
+
* can carry is INVALID_PATH (plan C3), and a download name none can carry
|
|
50
|
+
* is the worker's INVALID_REQUEST for it.
|
|
51
|
+
*/
|
|
52
|
+
getPublicUrl(path: string, options?: DownloadOption): NaroResponse<{
|
|
53
|
+
publicUrl: string;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `naro.storage`. Buckets are made with `naro storage bucket create` or the
|
|
58
|
+
* MCP server, never from a client (original spec §6).
|
|
59
|
+
*/
|
|
60
|
+
export declare class StorageClient {
|
|
61
|
+
#private;
|
|
62
|
+
constructor(http: HttpConfig, auth: AuthClient);
|
|
63
|
+
/** The objects of one bucket. */
|
|
64
|
+
from(bucket: string): StorageFileApi;
|
|
65
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { NaroError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The most one upload request carries (original spec §9.1): 50 MiB.
|
|
4
|
+
* data-core's MAX_SINGLE_UPLOAD_BYTES — storage/wire.test.ts holds them
|
|
5
|
+
* equal. Over it, the edge cuts the body off and the browser often cannot
|
|
6
|
+
* read the answer: the SDK refuses first.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_SINGLE_UPLOAD_BYTES = 52428800;
|
|
9
|
+
/** The worker's words (data-core's catalog) for what the SDK refuses itself. */
|
|
10
|
+
export declare const STORAGE_MESSAGES: {
|
|
11
|
+
readonly bucket: "The bucket name is not valid.";
|
|
12
|
+
readonly tooLarge: "Uploads over 50 MiB are not supported yet.";
|
|
13
|
+
readonly request: "The storage request is not valid.";
|
|
14
|
+
readonly body: "upload takes a Blob, File, ArrayBuffer, typed array or string.";
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The words the worker's routes take as the first segment after `/object/`
|
|
18
|
+
* (data-core's STORAGE_RESERVED_WORDS): as a bucket, the URL would name
|
|
19
|
+
* another route. No bucket can have one of these names.
|
|
20
|
+
*/
|
|
21
|
+
export declare const RESERVED_BUCKETS: ReadonlySet<string>;
|
|
22
|
+
/** Why the worker refuses a `download` name (serve.ts's DOWNLOAD_NAME_REASON). */
|
|
23
|
+
export declare const DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
|
|
24
|
+
export type Encoded = {
|
|
25
|
+
ok: true;
|
|
26
|
+
encoded: string;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: NaroError;
|
|
30
|
+
};
|
|
31
|
+
export declare function invalidPath(reason: string): NaroError;
|
|
32
|
+
export declare function objectTooLarge(): NaroError;
|
|
33
|
+
/**
|
|
34
|
+
* An object path as a URL carries it, each segment encoded once and `/`
|
|
35
|
+
* kept as the separator — or the worker's INVALID_PATH for the paths no
|
|
36
|
+
* URL can carry as they are (original spec §13, plan C2): an empty name, a
|
|
37
|
+
* `/` at either end, `//`, a `.` or `..` segment (WHATWG URL parsing folds
|
|
38
|
+
* `..` and `%2E%2E` alike, so the worker would get another path), and a
|
|
39
|
+
* lone surrogate (`encodeURIComponent` throws on one). Every other rule is
|
|
40
|
+
* the worker's alone — one set of rules, in one place.
|
|
41
|
+
*/
|
|
42
|
+
export declare function encodePath(path: string): Encoded;
|
|
43
|
+
/**
|
|
44
|
+
* `?download` for the object's own name, `?download=<name>` for another, or
|
|
45
|
+
* the worker's own INVALID_REQUEST for a name `encodeURIComponent` throws on
|
|
46
|
+
* (a lone surrogate: the sign route refuses the same name in its body). Every
|
|
47
|
+
* other rule on the name is the worker's alone.
|
|
48
|
+
*/
|
|
49
|
+
export declare function downloadQuery(download: boolean | string | undefined): Encoded;
|
|
50
|
+
/**
|
|
51
|
+
* A bucket name as the URL's segment — encoded (a valid name is unchanged
|
|
52
|
+
* by it), or INVALID_PATH for the names that would not reach a bucket:
|
|
53
|
+
* empty, `.`, `..`, a lone surrogate and the routes' own words.
|
|
54
|
+
*/
|
|
55
|
+
export declare function encodeBucket(bucket: string): Encoded;
|
|
56
|
+
/** Why the worker refuses a cache-control it cannot read (its upload.ts). */
|
|
57
|
+
export declare const CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
|
|
58
|
+
/**
|
|
59
|
+
* The upload's `contentType` and `cacheControl` as headers, or the worker's
|
|
60
|
+
* answer for a value it cannot read when no header can carry it (PR #90
|
|
61
|
+
* review 1): INVALID_CONTENT_TYPE with data-core's words (the type cut at
|
|
62
|
+
* 256 UTF-16 units, as `storageName` cuts it), and INVALID_REQUEST
|
|
63
|
+
* `cache-control`. Every other value is the worker's to judge.
|
|
64
|
+
*/
|
|
65
|
+
export declare function headerProblem(contentType: string | undefined, cacheControl: string): NaroError | null;
|
|
66
|
+
export type Payload = {
|
|
67
|
+
ok: true;
|
|
68
|
+
body: Blob | ArrayBuffer | Uint8Array<ArrayBuffer>;
|
|
69
|
+
size: number;
|
|
70
|
+
type: string;
|
|
71
|
+
} | {
|
|
72
|
+
ok: false;
|
|
73
|
+
error: NaroError;
|
|
74
|
+
};
|
|
75
|
+
/** The upload's body, its bytes and its type — or INVALID_BODY. */
|
|
76
|
+
export declare function payloadOf(body: unknown, contentType: string | undefined): Payload;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** An object as the worker answers it. */
|
|
2
|
+
export type FileObject = {
|
|
3
|
+
id: string;
|
|
4
|
+
/** The full path from `info` and `remove`; the name below the prefix in `list`. */
|
|
5
|
+
name: string;
|
|
6
|
+
bucket_id: string;
|
|
7
|
+
/** Who uploaded it; null for the anon or the service key. */
|
|
8
|
+
owner: string | null;
|
|
9
|
+
owner_id: string | null;
|
|
10
|
+
/** The bytes' etag. */
|
|
11
|
+
version: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
/** `eTag`, `size`, `mimetype`, `cacheControl`, `lastModified`, `contentLength`, `httpStatusCode`. */
|
|
15
|
+
metadata: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
/** A folder in a listing: a name and nothing else. Folders come first. */
|
|
18
|
+
export type FolderEntry = {
|
|
19
|
+
name: string;
|
|
20
|
+
id: null;
|
|
21
|
+
created_at: null;
|
|
22
|
+
updated_at: null;
|
|
23
|
+
metadata: null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* What an upload sends: bytes whose length `fetch` knows, so it sets
|
|
27
|
+
* `Content-Length`. A string goes as UTF-8. Not a stream, not `FormData`.
|
|
28
|
+
*/
|
|
29
|
+
export type UploadBody = Blob | ArrayBuffer | ArrayBufferView | string;
|
|
30
|
+
export type UploadOptions = {
|
|
31
|
+
/** Default: the Blob's type, `text/plain;charset=utf-8` for a string, else `application/octet-stream`. */
|
|
32
|
+
contentType?: string;
|
|
33
|
+
/** Seconds a browser may keep it, as text. Default `"3600"`. */
|
|
34
|
+
cacheControl?: string;
|
|
35
|
+
/** Replace an object of the same name. Default false. */
|
|
36
|
+
upsert?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export type UpdateOptions = Omit<UploadOptions, "upsert">;
|
|
39
|
+
export type UploadResult = {
|
|
40
|
+
id: string;
|
|
41
|
+
path: string;
|
|
42
|
+
fullPath: string;
|
|
43
|
+
};
|
|
44
|
+
export type ListOptions = {
|
|
45
|
+
/** 1–1,000; 100 by default. */
|
|
46
|
+
limit?: number;
|
|
47
|
+
/** 0–100,000. */
|
|
48
|
+
offset?: number;
|
|
49
|
+
/** Folders come first whatever the sort. Default name ascending. */
|
|
50
|
+
sortBy?: {
|
|
51
|
+
column?: "name" | "created_at" | "updated_at";
|
|
52
|
+
order?: "asc" | "desc";
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** `true` downloads under the object's own name, a string under that name. */
|
|
56
|
+
export type DownloadOption = {
|
|
57
|
+
download?: boolean | string;
|
|
58
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naro-js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Auth and
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Auth, data and files for your Naro project, from the browser or the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"files": [
|