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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # naro-js
2
2
 
3
- Auth and data for your [Naro](https://naro.sh) project, from the browser or
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 two 429s. `RATE_LIMITED` is the sign-in routes' per-address
58
- * limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the project over its daily
59
- * database write limit — Data API writes, sign-up and creating or deleting
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
- if (RETRY_AFTER_CODES.has(error.code))
164
- return { error: withRetryAfter(error, responseHeaders), answer };
165
- return { error, answer };
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.2.0",
4
- "description": "Auth and data for your Naro project, from the browser or the server.",
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": [