@nofra/sdk 0.0.0-stage → 0.1.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,3 +1,27 @@
1
- # Temporary Holding Version
1
+ # @nofra/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The JavaScript and TypeScript client for [nøfra](https://nofra.net/).
4
+
5
+ ```sh
6
+ npm install @nofra/sdk
7
+ ```
8
+
9
+ ```ts
10
+ import { createClient } from "@nofra/sdk";
11
+
12
+ const nofra = createClient({
13
+ app: "my-app",
14
+ subdomain: "my-app-account-example",
15
+ });
16
+ ```
17
+
18
+ The subdomain is needed for authentication on localhost or outside a browser;
19
+ in a browser the SDK normally uses the current hostname.
20
+
21
+ For database and storage access, call a server-side Function from the browser
22
+ and create a separate SDK client in the Function using the automatically
23
+ injected `APP_SERVER_TOKEN`. Never expose this token in browser code. Validate
24
+ the caller and enforce access to private data in your Function.
25
+
26
+ See the [SDK guide](https://nofra.net/docs/sdk) and
27
+ [Functions guide](https://nofra.net/docs/functions) for examples.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * @nofra/sdk — Official JavaScript & TypeScript SDK for Nofra
3
+ * "No infrastructure. Just apps."
4
+ */
5
+ export interface NofraClientOptions {
6
+ /** Name of the Nofra application (e.g. "todo-app") */
7
+ app: string;
8
+ /**
9
+ * App subdomain (`<app>-<account>-<hash>`) or full hostname, used for
10
+ * auth. Defaults to the current hostname in browsers; required on
11
+ * localhost and outside browsers.
12
+ */
13
+ subdomain?: string;
14
+ /** Control plane API URL (defaults to "https://api.nofra.net") */
15
+ apiUrl?: string;
16
+ /** Optional API token for authenticated requests */
17
+ apiKey?: string;
18
+ }
19
+ export type AppStatus = "running" | "deploying" | "stopped";
20
+ export interface ServiceStatus {
21
+ name: "Compute" | "Database" | "Storage" | "Auth" | "Functions" | "Domains";
22
+ enabled: boolean;
23
+ }
24
+ export interface UsageMetric {
25
+ label: "Requests" | "Database" | "Storage" | "Bandwidth";
26
+ used: number;
27
+ limit: number;
28
+ unit: "req" | "GB";
29
+ }
30
+ export interface AppInfo {
31
+ id: string;
32
+ name: string;
33
+ url: string;
34
+ status: AppStatus;
35
+ last_deploy: string;
36
+ services: ServiceStatus[];
37
+ usage: UsageMetric[];
38
+ }
39
+ /** Creates an app with managed services available on demand. */
40
+ export interface CreateAppOptions {
41
+ name: string;
42
+ }
43
+ /** Only the display URL is editable through this SDK method. */
44
+ export interface UpdateAppOptions {
45
+ url?: string;
46
+ }
47
+ export interface LogEntry {
48
+ id: string;
49
+ app_name: string | null;
50
+ deployment_id: string | null;
51
+ level: "info" | "success" | "warn" | "error";
52
+ scope: "deploy" | "account" | "runtime";
53
+ message: string;
54
+ timestamp: string;
55
+ }
56
+ export interface UserInfo {
57
+ account: string;
58
+ username: string;
59
+ plan: string;
60
+ limits: {
61
+ requests: number;
62
+ database_gb: number;
63
+ storage_gb: number;
64
+ bandwidth_gb: number;
65
+ };
66
+ }
67
+ export interface DatabaseStatus {
68
+ app: string;
69
+ enabled: boolean;
70
+ used_gb: number;
71
+ limit_gb: number;
72
+ }
73
+ export interface CollectionRecord<T = Record<string, unknown>> {
74
+ id: string;
75
+ collection: string;
76
+ data: T;
77
+ created_at: string;
78
+ }
79
+ export interface CollectionFilter {
80
+ /** A top-level field in record data, not an SQL expression or nested path. */
81
+ field: string;
82
+ op: "eq" | "ne" | "gt" | "gte" | "lt" | "lte";
83
+ value: string | number | boolean | null;
84
+ }
85
+ export interface CollectionListOptions {
86
+ limit?: number;
87
+ cursor?: string;
88
+ filters?: CollectionFilter[];
89
+ sort?: "created_at" | "id" | `data.${string}`;
90
+ order?: "asc" | "desc";
91
+ }
92
+ export interface CollectionPage<T = Record<string, unknown>> {
93
+ records: CollectionRecord<T>[];
94
+ next_cursor: string | null;
95
+ }
96
+ export interface StorageObjectInfo {
97
+ key: string;
98
+ size: number;
99
+ uploaded: string;
100
+ /** Stored content type; absent for objects written before file support. */
101
+ contentType?: string;
102
+ }
103
+ export interface DatabaseRecovery {
104
+ id: string;
105
+ status: "queued" | "ready" | "failed";
106
+ timestamp: string | null;
107
+ created_at: string;
108
+ expires_at: string;
109
+ previous_available: boolean;
110
+ error_message: string | null;
111
+ }
112
+ export interface DatabaseRecoveryStatus {
113
+ platform_enabled: boolean;
114
+ recovery_days: number;
115
+ enabled: boolean;
116
+ active_recovery_id: string | null;
117
+ recoveries: DatabaseRecovery[];
118
+ }
119
+ export interface DeletedStorageObject {
120
+ id: string;
121
+ key: string;
122
+ size: number;
123
+ contentType?: string;
124
+ deleted_at: string;
125
+ expires_at: string;
126
+ }
127
+ export interface StorageTrashPage {
128
+ objects: DeletedStorageObject[];
129
+ cursor: string | null;
130
+ recovery_days: number;
131
+ }
132
+ /** Binary payload accepted by `storage.putFile`. */
133
+ export type StorageFileData = Blob | ArrayBuffer | Uint8Array;
134
+ export interface AppAuthUser {
135
+ id: string;
136
+ name: string;
137
+ email: string;
138
+ emailVerified: boolean;
139
+ createdAt: string;
140
+ disabled?: boolean;
141
+ deletion_pending?: boolean;
142
+ }
143
+ export interface AppAuthUsersPage {
144
+ enabled: boolean;
145
+ users: AppAuthUser[];
146
+ next_offset: number | null;
147
+ deletion_webhook: boolean;
148
+ }
149
+ export interface AppAuthSession {
150
+ id: string;
151
+ token: string;
152
+ userId: string;
153
+ expiresAt: string;
154
+ }
155
+ export declare class NofraError extends Error {
156
+ readonly status: number;
157
+ constructor(message: string, status: number);
158
+ }
159
+ export declare class NofraClient {
160
+ readonly appName: string;
161
+ readonly apiUrl: string;
162
+ private readonly apiKey?;
163
+ private readonly subdomain?;
164
+ readonly app: {
165
+ getInfo: () => Promise<AppInfo>;
166
+ getLogs: () => Promise<LogEntry[]>;
167
+ deploy: () => Promise<{
168
+ id: string;
169
+ app: string;
170
+ status: string;
171
+ url: string;
172
+ }>;
173
+ };
174
+ readonly apps: {
175
+ create: (options: CreateAppOptions) => Promise<AppInfo>;
176
+ update: (name: string, options: UpdateAppOptions) => Promise<AppInfo>;
177
+ };
178
+ readonly db: {
179
+ /** Pro/Team owners and admins only. Use an administrative token server-side. */
180
+ recovery: () => Promise<DatabaseRecoveryStatus>;
181
+ restore: (timestamp: string, confirm: string) => Promise<{
182
+ id: string;
183
+ status: "queued";
184
+ }>;
185
+ restorePrevious: (id: string, confirm: string) => Promise<{
186
+ id: string;
187
+ status: "queued";
188
+ }>;
189
+ status: () => Promise<DatabaseStatus>;
190
+ list: <T = Record<string, unknown>>(collection: string) => Promise<CollectionRecord<T>[]>;
191
+ listPage: <T = Record<string, unknown>>(collection: string, options?: CollectionListOptions) => Promise<CollectionPage<T>>;
192
+ get: <T = Record<string, unknown>>(collection: string, id: string) => Promise<CollectionRecord<T>>;
193
+ /** Replace the entire data value; keeps the record ID and created_at. */
194
+ update: <T = Record<string, unknown>>(collection: string, id: string, data: T) => Promise<CollectionRecord<T>>;
195
+ /** Atomically merge top-level fields. Nested values are replaced; null is stored. */
196
+ patch: <T = Record<string, unknown>>(collection: string, id: string, data: Partial<T>) => Promise<CollectionRecord<T>>;
197
+ insert: <T = Record<string, unknown>>(collection: string, data: T) => Promise<CollectionRecord<T>>;
198
+ delete: (collection: string, id: string) => Promise<{
199
+ deleted: boolean;
200
+ id: string;
201
+ }>;
202
+ };
203
+ readonly storage: {
204
+ /** Pro/Team owners and admins only. Pass the returned cursor to read more. */
205
+ trash: (cursor?: string) => Promise<StorageTrashPage>;
206
+ restore: (id: string) => Promise<{
207
+ restored: boolean;
208
+ key: string;
209
+ }>;
210
+ list: () => Promise<StorageObjectInfo[]>;
211
+ listPage: (cursor?: string) => Promise<{
212
+ objects: StorageObjectInfo[];
213
+ cursor: string | null;
214
+ }>;
215
+ putText: (key: string, content: string) => Promise<{
216
+ key: string;
217
+ size: number;
218
+ }>;
219
+ getText: (key: string) => Promise<string>;
220
+ putFile: (key: string, data: StorageFileData, contentType?: string) => Promise<{
221
+ key: string;
222
+ size: number;
223
+ contentType: string;
224
+ }>;
225
+ getFile: (key: string) => Promise<Blob>;
226
+ delete: (key: string) => Promise<{
227
+ deleted: boolean;
228
+ key: string;
229
+ recovery?: {
230
+ id: string;
231
+ expires_at: string;
232
+ };
233
+ }>;
234
+ };
235
+ readonly auth: {
236
+ me: () => Promise<UserInfo>;
237
+ signUp: (params: {
238
+ email: string;
239
+ password: string;
240
+ name?: string;
241
+ }) => Promise<{
242
+ token: string | null;
243
+ user: AppAuthUser;
244
+ }>;
245
+ signIn: (params: {
246
+ email: string;
247
+ password: string;
248
+ }) => Promise<{
249
+ token: string | null;
250
+ user: AppAuthUser;
251
+ }>;
252
+ signOut: (token?: string) => Promise<{
253
+ signed_out: boolean;
254
+ }>;
255
+ getSession: (token?: string) => Promise<{
256
+ session: AppAuthSession | null;
257
+ user: AppAuthUser | null;
258
+ }>;
259
+ listUsers: (query?: string) => Promise<AppAuthUser[]>;
260
+ listUsersPage: (query?: string, offset?: number) => Promise<AppAuthUsersPage>;
261
+ setUserDisabled: (id: string, disabled: boolean) => Promise<{
262
+ disabled: boolean;
263
+ }>;
264
+ deleteUser: (id: string) => Promise<{
265
+ deleted: boolean;
266
+ }>;
267
+ /** Login types currently usable on the app (email + credentialed socials). */
268
+ getLoginTypes: () => Promise<{
269
+ email: boolean;
270
+ social: string[];
271
+ }>;
272
+ /** Plain-link URL that starts a social login for `provider`. */
273
+ getSocialSignInUrl: (provider: string, callbackURL: string) => string;
274
+ /** Redirects the browser to start a social login (defaults to this page). */
275
+ signInWithSocial: (provider: string, callbackURL?: string) => void;
276
+ /**
277
+ * Reads the social-login result (`token` or `error`) from the current URL
278
+ * and strips it from the address bar. Returns nulls outside the browser.
279
+ */
280
+ consumeSocialResult: () => {
281
+ token: string | null;
282
+ error: string | null;
283
+ };
284
+ };
285
+ constructor(options: NofraClientOptions);
286
+ /**
287
+ * Identifier for the public auth endpoints: the explicit `subdomain`
288
+ * option, or the current hostname in browsers (the server matches it
289
+ * against subdomains and custom domains). App names are only unique per
290
+ * account, so they cannot identify an app on account-less routes.
291
+ */
292
+ private authAppId;
293
+ private request;
294
+ private requestRaw;
295
+ }
296
+ export declare function createClient(options: NofraClientOptions): NofraClient;
package/dist/index.js ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * @nofra/sdk — Official JavaScript & TypeScript SDK for Nofra
3
+ * "No infrastructure. Just apps."
4
+ */
5
+ export class NofraError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.name = "NofraError";
10
+ this.status = status;
11
+ }
12
+ }
13
+ export class NofraClient {
14
+ appName;
15
+ apiUrl;
16
+ apiKey;
17
+ subdomain;
18
+ app;
19
+ apps;
20
+ db;
21
+ storage;
22
+ auth;
23
+ constructor(options) {
24
+ if (!options.app) {
25
+ throw new Error("NofraClient requires an 'app' name.");
26
+ }
27
+ this.appName = options.app;
28
+ this.apiUrl = (options.apiUrl ?? "https://api.nofra.net").replace(/\/+$/, "");
29
+ this.apiKey = options.apiKey;
30
+ this.subdomain = options.subdomain;
31
+ const encodedApp = encodeURIComponent(this.appName);
32
+ this.apps = {
33
+ create: async (options) => {
34
+ const res = await this.request("/v1/apps", {
35
+ method: "POST",
36
+ body: JSON.stringify(options),
37
+ });
38
+ return res.app;
39
+ },
40
+ update: async (name, options) => {
41
+ const res = await this.request(`/v1/apps/${encodeURIComponent(name)}`, {
42
+ method: "PATCH",
43
+ body: JSON.stringify(options),
44
+ });
45
+ return res.app;
46
+ },
47
+ };
48
+ this.app = {
49
+ getInfo: async () => {
50
+ const res = await this.request(`/v1/apps/${encodedApp}`);
51
+ return res.app;
52
+ },
53
+ getLogs: async () => {
54
+ const res = await this.request(`/v1/apps/${encodedApp}/logs`);
55
+ return res.logs;
56
+ },
57
+ deploy: async () => {
58
+ const res = await this.request(`/v1/apps/${encodedApp}/deployments`, {
59
+ method: "POST",
60
+ body: JSON.stringify({}),
61
+ });
62
+ return res.deployment;
63
+ },
64
+ };
65
+ this.db = {
66
+ recovery: () => this.request(`/v1/apps/${encodedApp}/db/recovery`),
67
+ restore: async (timestamp, confirm) => {
68
+ const response = await this.request(`/v1/apps/${encodedApp}/db/recovery`, { method: "POST", body: JSON.stringify({ timestamp, confirm }) });
69
+ return response.recovery;
70
+ },
71
+ restorePrevious: async (id, confirm) => {
72
+ const response = await this.request(`/v1/apps/${encodedApp}/db/recovery`, { method: "POST", body: JSON.stringify({ previous_id: id, confirm }) });
73
+ return response.recovery;
74
+ },
75
+ status: async () => {
76
+ const res = await this.request(`/v1/apps/${encodedApp}/db/status`);
77
+ return res.database;
78
+ },
79
+ list: async (collection) => {
80
+ const col = encodeURIComponent(collection);
81
+ const res = await this.request(`/v1/apps/${encodedApp}/db/collections/${col}`);
82
+ return res.records;
83
+ },
84
+ listPage: (collection, options = {}) => {
85
+ const params = new URLSearchParams({ limit: String(options.limit ?? 100) });
86
+ if (options.cursor !== undefined)
87
+ params.set("cursor", options.cursor);
88
+ if (options.filters !== undefined)
89
+ params.set("filters", JSON.stringify(options.filters));
90
+ if (options.sort !== undefined)
91
+ params.set("sort", options.sort);
92
+ if (options.order !== undefined)
93
+ params.set("order", options.order);
94
+ return this.request(`/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}?${params}`);
95
+ },
96
+ get: async (collection, id) => {
97
+ const res = await this.request(`/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
98
+ return res.record;
99
+ },
100
+ update: async (collection, id, data) => {
101
+ const res = await this.request(`/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ data }) });
102
+ return res.record;
103
+ },
104
+ patch: async (collection, id, data) => {
105
+ const res = await this.request(`/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ data }) });
106
+ return res.record;
107
+ },
108
+ insert: async (collection, data) => {
109
+ const col = encodeURIComponent(collection);
110
+ const res = await this.request(`/v1/apps/${encodedApp}/db/collections/${col}`, {
111
+ method: "POST",
112
+ body: JSON.stringify({ data }),
113
+ });
114
+ return res.record;
115
+ },
116
+ delete: async (collection, id) => {
117
+ const col = encodeURIComponent(collection);
118
+ const recId = encodeURIComponent(id);
119
+ return this.request(`/v1/apps/${encodedApp}/db/collections/${col}/${recId}`, { method: "DELETE" });
120
+ },
121
+ };
122
+ this.storage = {
123
+ listPage: (cursor) => this.request(`/v1/apps/${encodedApp}/storage${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`),
124
+ trash: (cursor) => this.request(`/v1/apps/${encodedApp}/storage-trash${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`),
125
+ restore: (id) => this.request(`/v1/apps/${encodedApp}/storage-trash/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({}) }),
126
+ list: async () => {
127
+ const res = await this.request(`/v1/apps/${encodedApp}/storage`);
128
+ return res.objects;
129
+ },
130
+ putFile: async (key, data, contentType) => {
131
+ const encodedKey = encodeURIComponent(key);
132
+ const blobType = data instanceof Blob ? data.type : "";
133
+ const resolvedType = contentType || blobType || "application/octet-stream";
134
+ // Copy views into a plain buffer: fetch types only accept ArrayBuffer-backed bodies.
135
+ const body = data instanceof Uint8Array ? data.slice().buffer : data;
136
+ return this.request(`/v1/apps/${encodedApp}/storage/${encodedKey}?raw=1`, {
137
+ method: "PUT",
138
+ headers: { "Content-Type": resolvedType },
139
+ body,
140
+ });
141
+ },
142
+ getFile: async (key) => {
143
+ const encodedKey = encodeURIComponent(key);
144
+ const res = await this.requestRaw(`/v1/apps/${encodedApp}/storage/${encodedKey}?raw=1`);
145
+ return res.blob();
146
+ },
147
+ putText: async (key, content) => {
148
+ const encodedKey = encodeURIComponent(key);
149
+ return this.request(`/v1/apps/${encodedApp}/storage/${encodedKey}`, {
150
+ method: "PUT",
151
+ body: JSON.stringify({ content }),
152
+ });
153
+ },
154
+ getText: async (key) => {
155
+ const encodedKey = encodeURIComponent(key);
156
+ const res = await this.request(`/v1/apps/${encodedApp}/storage/${encodedKey}`);
157
+ return res.content;
158
+ },
159
+ delete: async (key) => {
160
+ const encodedKey = encodeURIComponent(key);
161
+ return this.request(`/v1/apps/${encodedApp}/storage/${encodedKey}`, { method: "DELETE" });
162
+ },
163
+ };
164
+ this.auth = {
165
+ me: async () => {
166
+ return this.request("/v1/me");
167
+ },
168
+ signUp: async (params) => {
169
+ const authApp = encodeURIComponent(this.authAppId());
170
+ return this.request(`/v1/apps/${authApp}/auth/sign-up`, {
171
+ method: "POST",
172
+ body: JSON.stringify(params),
173
+ });
174
+ },
175
+ signIn: async (params) => {
176
+ const authApp = encodeURIComponent(this.authAppId());
177
+ return this.request(`/v1/apps/${authApp}/auth/sign-in`, {
178
+ method: "POST",
179
+ body: JSON.stringify(params),
180
+ });
181
+ },
182
+ signOut: async (token) => {
183
+ const authApp = encodeURIComponent(this.authAppId());
184
+ return this.request(`/v1/apps/${authApp}/auth/sign-out`, {
185
+ method: "POST",
186
+ headers: token ? { "X-Nofra-App-Token": token } : undefined,
187
+ body: JSON.stringify({}),
188
+ });
189
+ },
190
+ getSession: async (token) => {
191
+ const authApp = encodeURIComponent(this.authAppId());
192
+ return this.request(`/v1/apps/${authApp}/auth/session`, {
193
+ headers: token ? { "X-Nofra-App-Token": token } : undefined,
194
+ });
195
+ },
196
+ listUsers: async (query = "") => {
197
+ const res = await this.request(`/v1/apps/${encodedApp}/auth/users?q=${encodeURIComponent(query)}&offset=0`);
198
+ return res.users;
199
+ },
200
+ listUsersPage: (query = "", offset = 0) => this.request(`/v1/apps/${encodedApp}/auth/users?q=${encodeURIComponent(query)}&offset=${offset}`),
201
+ setUserDisabled: (id, disabled) => this.request(`/v1/apps/${encodedApp}/auth/users/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ disabled }) }),
202
+ deleteUser: (id) => this.request(`/v1/apps/${encodedApp}/auth/users/${encodeURIComponent(id)}`, { method: "DELETE", body: JSON.stringify({ confirm: id }) }),
203
+ getLoginTypes: async () => {
204
+ const authApp = encodeURIComponent(this.authAppId());
205
+ return this.request(`/v1/apps/${authApp}/auth/config`);
206
+ },
207
+ getSocialSignInUrl: (provider, callbackURL) => {
208
+ const authApp = encodeURIComponent(this.authAppId());
209
+ return (`${this.apiUrl}/v1/apps/${authApp}/auth/social-start` +
210
+ `?provider=${encodeURIComponent(provider)}` +
211
+ `&to=${encodeURIComponent(callbackURL)}`);
212
+ },
213
+ signInWithSocial: (provider, callbackURL) => {
214
+ if (typeof window === "undefined" || !window.location) {
215
+ throw new Error("signInWithSocial needs a browser. Use getSocialSignInUrl() to build the link instead.");
216
+ }
217
+ const target = callbackURL ?? window.location.origin + window.location.pathname;
218
+ window.location.href = this.auth.getSocialSignInUrl(provider, target);
219
+ },
220
+ consumeSocialResult: () => {
221
+ if (typeof window === "undefined" || !window.location) {
222
+ return { token: null, error: null };
223
+ }
224
+ const url = new URL(window.location.href);
225
+ const token = url.searchParams.get("token");
226
+ const error = url.searchParams.get("error");
227
+ if (token || error) {
228
+ url.searchParams.delete("token");
229
+ url.searchParams.delete("error");
230
+ url.searchParams.delete("error_description");
231
+ window.history.replaceState(null, "", url.toString());
232
+ }
233
+ return { token, error };
234
+ },
235
+ };
236
+ }
237
+ /**
238
+ * Identifier for the public auth endpoints: the explicit `subdomain`
239
+ * option, or the current hostname in browsers (the server matches it
240
+ * against subdomains and custom domains). App names are only unique per
241
+ * account, so they cannot identify an app on account-less routes.
242
+ */
243
+ authAppId() {
244
+ if (this.subdomain) {
245
+ return this.subdomain;
246
+ }
247
+ const hostname = typeof window !== "undefined" ? window.location?.hostname ?? "" : "";
248
+ if (!hostname ||
249
+ hostname === "localhost" ||
250
+ hostname === "127.0.0.1" ||
251
+ hostname === "::1" ||
252
+ hostname === "[::1]") {
253
+ throw new Error(`Cannot derive the auth app from '${hostname || "this environment"}'. ` +
254
+ `Pass 'subdomain' explicitly (the <app>-<account>-<hash> part of your app URL).`);
255
+ }
256
+ return hostname;
257
+ }
258
+ async request(path, init) {
259
+ const headers = new Headers(init?.headers);
260
+ if (!headers.has("Content-Type") && init?.body) {
261
+ headers.set("Content-Type", "application/json");
262
+ }
263
+ const response = await this.requestRaw(path, { ...init, headers });
264
+ const text = await response.text();
265
+ let parsed = null;
266
+ if (text) {
267
+ try {
268
+ parsed = JSON.parse(text);
269
+ }
270
+ catch {
271
+ parsed = { error: text };
272
+ }
273
+ }
274
+ return parsed;
275
+ }
276
+ async requestRaw(path, init) {
277
+ const headers = new Headers(init?.headers);
278
+ if (this.apiKey) {
279
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
280
+ }
281
+ const response = await fetch(`${this.apiUrl}${path}`, {
282
+ ...init,
283
+ headers,
284
+ });
285
+ if (!response.ok) {
286
+ const text = await response.text();
287
+ let parsed = null;
288
+ if (text) {
289
+ try {
290
+ parsed = JSON.parse(text);
291
+ }
292
+ catch {
293
+ parsed = { error: text };
294
+ }
295
+ }
296
+ const errMessage = parsed &&
297
+ typeof parsed === "object" &&
298
+ "error" in parsed &&
299
+ typeof parsed.error === "string"
300
+ ? parsed.error
301
+ : `HTTP ${response.status}`;
302
+ throw new NofraError(errMessage, response.status);
303
+ }
304
+ return response;
305
+ }
306
+ }
307
+ export function createClient(options) {
308
+ return new NofraClient(options);
309
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@nofra/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Official JavaScript & TypeScript SDK for Nofra — No infrastructure. Just apps.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src/index.ts"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "node --test src/index.test.ts"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.8.2"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * @nofra/sdk — Official JavaScript & TypeScript SDK for Nofra
3
+ * "No infrastructure. Just apps."
4
+ */
5
+
6
+ export interface NofraClientOptions {
7
+ /** Name of the Nofra application (e.g. "todo-app") */
8
+ app: string;
9
+ /**
10
+ * App subdomain (`<app>-<account>-<hash>`) or full hostname, used for
11
+ * auth. Defaults to the current hostname in browsers; required on
12
+ * localhost and outside browsers.
13
+ */
14
+ subdomain?: string;
15
+ /** Control plane API URL (defaults to "https://api.nofra.net") */
16
+ apiUrl?: string;
17
+ /** Optional API token for authenticated requests */
18
+ apiKey?: string;
19
+ }
20
+
21
+ export type AppStatus = "running" | "deploying" | "stopped";
22
+
23
+ export interface ServiceStatus {
24
+ name: "Compute" | "Database" | "Storage" | "Auth" | "Functions" | "Domains";
25
+ enabled: boolean;
26
+ }
27
+
28
+ export interface UsageMetric {
29
+ label: "Requests" | "Database" | "Storage" | "Bandwidth";
30
+ used: number;
31
+ limit: number;
32
+ unit: "req" | "GB";
33
+ }
34
+
35
+ export interface AppInfo {
36
+ id: string;
37
+ name: string;
38
+ url: string;
39
+ status: AppStatus;
40
+ last_deploy: string;
41
+ services: ServiceStatus[];
42
+ usage: UsageMetric[];
43
+ }
44
+
45
+ /** Creates an app with managed services available on demand. */
46
+ export interface CreateAppOptions {
47
+ name: string;
48
+ }
49
+
50
+ /** Only the display URL is editable through this SDK method. */
51
+ export interface UpdateAppOptions {
52
+ url?: string;
53
+ }
54
+
55
+ export interface LogEntry {
56
+ id: string;
57
+ app_name: string | null;
58
+ deployment_id: string | null;
59
+ level: "info" | "success" | "warn" | "error";
60
+ scope: "deploy" | "account" | "runtime";
61
+ message: string;
62
+ timestamp: string;
63
+ }
64
+
65
+ export interface UserInfo {
66
+ account: string;
67
+ username: string;
68
+ plan: string;
69
+ limits: {
70
+ requests: number;
71
+ database_gb: number;
72
+ storage_gb: number;
73
+ bandwidth_gb: number;
74
+ };
75
+ }
76
+
77
+ export interface DatabaseStatus {
78
+ app: string;
79
+ enabled: boolean;
80
+ used_gb: number;
81
+ limit_gb: number;
82
+ }
83
+
84
+ export interface CollectionRecord<T = Record<string, unknown>> {
85
+ id: string;
86
+ collection: string;
87
+ data: T;
88
+ created_at: string;
89
+ }
90
+
91
+ export interface CollectionFilter {
92
+ /** A top-level field in record data, not an SQL expression or nested path. */
93
+ field: string;
94
+ op: "eq" | "ne" | "gt" | "gte" | "lt" | "lte";
95
+ value: string | number | boolean | null;
96
+ }
97
+
98
+ export interface CollectionListOptions {
99
+ limit?: number;
100
+ cursor?: string;
101
+ filters?: CollectionFilter[];
102
+ sort?: "created_at" | "id" | `data.${string}`;
103
+ order?: "asc" | "desc";
104
+ }
105
+
106
+ export interface CollectionPage<T = Record<string, unknown>> {
107
+ records: CollectionRecord<T>[];
108
+ next_cursor: string | null;
109
+ }
110
+
111
+ export interface StorageObjectInfo {
112
+ key: string;
113
+ size: number;
114
+ uploaded: string;
115
+ /** Stored content type; absent for objects written before file support. */
116
+ contentType?: string;
117
+ }
118
+
119
+ export interface DatabaseRecovery {
120
+ id: string;
121
+ status: "queued" | "ready" | "failed";
122
+ timestamp: string | null;
123
+ created_at: string;
124
+ expires_at: string;
125
+ previous_available: boolean;
126
+ error_message: string | null;
127
+ }
128
+
129
+ export interface DatabaseRecoveryStatus {
130
+ platform_enabled: boolean;
131
+ recovery_days: number;
132
+ enabled: boolean;
133
+ active_recovery_id: string | null;
134
+ recoveries: DatabaseRecovery[];
135
+ }
136
+
137
+ export interface DeletedStorageObject {
138
+ id: string;
139
+ key: string;
140
+ size: number;
141
+ contentType?: string;
142
+ deleted_at: string;
143
+ expires_at: string;
144
+ }
145
+
146
+ export interface StorageTrashPage {
147
+ objects: DeletedStorageObject[];
148
+ cursor: string | null;
149
+ recovery_days: number;
150
+ }
151
+
152
+ /** Binary payload accepted by `storage.putFile`. */
153
+ export type StorageFileData = Blob | ArrayBuffer | Uint8Array;
154
+
155
+ export interface AppAuthUser {
156
+ id: string;
157
+ name: string;
158
+ email: string;
159
+ emailVerified: boolean;
160
+ createdAt: string;
161
+ disabled?: boolean;
162
+ deletion_pending?: boolean;
163
+ }
164
+
165
+ export interface AppAuthUsersPage {
166
+ enabled: boolean;
167
+ users: AppAuthUser[];
168
+ next_offset: number | null;
169
+ deletion_webhook: boolean;
170
+ }
171
+
172
+ export interface AppAuthSession {
173
+ id: string;
174
+ token: string;
175
+ userId: string;
176
+ expiresAt: string;
177
+ }
178
+
179
+ export class NofraError extends Error {
180
+ readonly status: number;
181
+
182
+ constructor(message: string, status: number) {
183
+ super(message);
184
+ this.name = "NofraError";
185
+ this.status = status;
186
+ }
187
+ }
188
+
189
+ export class NofraClient {
190
+ readonly appName: string;
191
+ readonly apiUrl: string;
192
+ private readonly apiKey?: string;
193
+ private readonly subdomain?: string;
194
+
195
+ readonly app: {
196
+ getInfo: () => Promise<AppInfo>;
197
+ getLogs: () => Promise<LogEntry[]>;
198
+ deploy: () => Promise<{ id: string; app: string; status: string; url: string }>;
199
+ };
200
+
201
+ readonly apps: {
202
+ create: (options: CreateAppOptions) => Promise<AppInfo>;
203
+ update: (name: string, options: UpdateAppOptions) => Promise<AppInfo>;
204
+ };
205
+
206
+ readonly db: {
207
+ /** Pro/Team owners and admins only. Use an administrative token server-side. */
208
+ recovery: () => Promise<DatabaseRecoveryStatus>;
209
+ restore: (timestamp: string, confirm: string) => Promise<{ id: string; status: "queued" }>;
210
+ restorePrevious: (id: string, confirm: string) => Promise<{ id: string; status: "queued" }>;
211
+ status: () => Promise<DatabaseStatus>;
212
+ list: <T = Record<string, unknown>>(collection: string) => Promise<CollectionRecord<T>[]>;
213
+ listPage: <T = Record<string, unknown>>(collection: string, options?: CollectionListOptions) => Promise<CollectionPage<T>>;
214
+ get: <T = Record<string, unknown>>(collection: string, id: string) => Promise<CollectionRecord<T>>;
215
+ /** Replace the entire data value; keeps the record ID and created_at. */
216
+ update: <T = Record<string, unknown>>(collection: string, id: string, data: T) => Promise<CollectionRecord<T>>;
217
+ /** Atomically merge top-level fields. Nested values are replaced; null is stored. */
218
+ patch: <T = Record<string, unknown>>(collection: string, id: string, data: Partial<T>) => Promise<CollectionRecord<T>>;
219
+ insert: <T = Record<string, unknown>>(
220
+ collection: string,
221
+ data: T
222
+ ) => Promise<CollectionRecord<T>>;
223
+ delete: (collection: string, id: string) => Promise<{ deleted: boolean; id: string }>;
224
+ };
225
+
226
+ readonly storage: {
227
+ /** Pro/Team owners and admins only. Pass the returned cursor to read more. */
228
+ trash: (cursor?: string) => Promise<StorageTrashPage>;
229
+ restore: (id: string) => Promise<{ restored: boolean; key: string }>;
230
+ list: () => Promise<StorageObjectInfo[]>;
231
+ listPage: (cursor?: string) => Promise<{ objects: StorageObjectInfo[]; cursor: string | null }>;
232
+ putText: (key: string, content: string) => Promise<{ key: string; size: number }>;
233
+ getText: (key: string) => Promise<string>;
234
+ putFile: (
235
+ key: string,
236
+ data: StorageFileData,
237
+ contentType?: string
238
+ ) => Promise<{ key: string; size: number; contentType: string }>;
239
+ getFile: (key: string) => Promise<Blob>;
240
+ delete: (key: string) => Promise<{ deleted: boolean; key: string; recovery?: { id: string; expires_at: string } }>;
241
+ };
242
+
243
+ readonly auth: {
244
+ me: () => Promise<UserInfo>;
245
+ signUp: (params: {
246
+ email: string;
247
+ password: string;
248
+ name?: string;
249
+ }) => Promise<{ token: string | null; user: AppAuthUser }>;
250
+ signIn: (params: {
251
+ email: string;
252
+ password: string;
253
+ }) => Promise<{ token: string | null; user: AppAuthUser }>;
254
+ signOut: (token?: string) => Promise<{ signed_out: boolean }>;
255
+ getSession: (token?: string) => Promise<{
256
+ session: AppAuthSession | null;
257
+ user: AppAuthUser | null;
258
+ }>;
259
+ listUsers: (query?: string) => Promise<AppAuthUser[]>;
260
+ listUsersPage: (query?: string, offset?: number) => Promise<AppAuthUsersPage>;
261
+ setUserDisabled: (id: string, disabled: boolean) => Promise<{ disabled: boolean }>;
262
+ deleteUser: (id: string) => Promise<{ deleted: boolean }>;
263
+ /** Login types currently usable on the app (email + credentialed socials). */
264
+ getLoginTypes: () => Promise<{ email: boolean; social: string[] }>;
265
+ /** Plain-link URL that starts a social login for `provider`. */
266
+ getSocialSignInUrl: (provider: string, callbackURL: string) => string;
267
+ /** Redirects the browser to start a social login (defaults to this page). */
268
+ signInWithSocial: (provider: string, callbackURL?: string) => void;
269
+ /**
270
+ * Reads the social-login result (`token` or `error`) from the current URL
271
+ * and strips it from the address bar. Returns nulls outside the browser.
272
+ */
273
+ consumeSocialResult: () => {
274
+ token: string | null;
275
+ error: string | null;
276
+ };
277
+ };
278
+
279
+ constructor(options: NofraClientOptions) {
280
+ if (!options.app) {
281
+ throw new Error("NofraClient requires an 'app' name.");
282
+ }
283
+ this.appName = options.app;
284
+ this.apiUrl = (
285
+ options.apiUrl ?? "https://api.nofra.net"
286
+ ).replace(/\/+$/, "");
287
+ this.apiKey = options.apiKey;
288
+ this.subdomain = options.subdomain;
289
+
290
+ const encodedApp = encodeURIComponent(this.appName);
291
+
292
+ this.apps = {
293
+ create: async (options) => {
294
+ const res = await this.request<{ app: AppInfo }>("/v1/apps", {
295
+ method: "POST",
296
+ body: JSON.stringify(options),
297
+ });
298
+ return res.app;
299
+ },
300
+ update: async (name, options) => {
301
+ const res = await this.request<{ app: AppInfo }>(`/v1/apps/${encodeURIComponent(name)}`, {
302
+ method: "PATCH",
303
+ body: JSON.stringify(options),
304
+ });
305
+ return res.app;
306
+ },
307
+ };
308
+
309
+ this.app = {
310
+ getInfo: async () => {
311
+ const res = await this.request<{ app: AppInfo }>(`/v1/apps/${encodedApp}`);
312
+ return res.app;
313
+ },
314
+ getLogs: async () => {
315
+ const res = await this.request<{ logs: LogEntry[] }>(`/v1/apps/${encodedApp}/logs`);
316
+ return res.logs;
317
+ },
318
+ deploy: async () => {
319
+ const res = await this.request<{
320
+ deployment: { id: string; app: string; status: string; url: string };
321
+ }>(`/v1/apps/${encodedApp}/deployments`, {
322
+ method: "POST",
323
+ body: JSON.stringify({}),
324
+ });
325
+ return res.deployment;
326
+ },
327
+ };
328
+
329
+ this.db = {
330
+ recovery: () => this.request<DatabaseRecoveryStatus>(`/v1/apps/${encodedApp}/db/recovery`),
331
+ restore: async (timestamp, confirm) => {
332
+ const response = await this.request<{ recovery: { id: string; status: "queued" } }>(
333
+ `/v1/apps/${encodedApp}/db/recovery`, { method: "POST", body: JSON.stringify({ timestamp, confirm }) });
334
+ return response.recovery;
335
+ },
336
+ restorePrevious: async (id, confirm) => {
337
+ const response = await this.request<{ recovery: { id: string; status: "queued" } }>(
338
+ `/v1/apps/${encodedApp}/db/recovery`, { method: "POST", body: JSON.stringify({ previous_id: id, confirm }) });
339
+ return response.recovery;
340
+ },
341
+ status: async () => {
342
+ const res = await this.request<{ database: DatabaseStatus }>(
343
+ `/v1/apps/${encodedApp}/db/status`
344
+ );
345
+ return res.database;
346
+ },
347
+ list: async <T = Record<string, unknown>>(collection: string) => {
348
+ const col = encodeURIComponent(collection);
349
+ const res = await this.request<{ records: CollectionRecord<T>[] }>(
350
+ `/v1/apps/${encodedApp}/db/collections/${col}`
351
+ );
352
+ return res.records;
353
+ },
354
+ listPage: <T = Record<string, unknown>>(collection: string, options: CollectionListOptions = {}) => {
355
+ const params = new URLSearchParams({ limit: String(options.limit ?? 100) });
356
+ if (options.cursor !== undefined) params.set("cursor", options.cursor);
357
+ if (options.filters !== undefined) params.set("filters", JSON.stringify(options.filters));
358
+ if (options.sort !== undefined) params.set("sort", options.sort);
359
+ if (options.order !== undefined) params.set("order", options.order);
360
+ return this.request<CollectionPage<T>>(`/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}?${params}`);
361
+ },
362
+ get: async <T = Record<string, unknown>>(collection: string, id: string) => {
363
+ const res = await this.request<{ record: CollectionRecord<T> }>(
364
+ `/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
365
+ return res.record;
366
+ },
367
+ update: async <T = Record<string, unknown>>(collection: string, id: string, data: T) => {
368
+ const res = await this.request<{ record: CollectionRecord<T> }>(
369
+ `/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
370
+ { method: "PUT", body: JSON.stringify({ data }) });
371
+ return res.record;
372
+ },
373
+ patch: async <T = Record<string, unknown>>(collection: string, id: string, data: Partial<T>) => {
374
+ const res = await this.request<{ record: CollectionRecord<T> }>(
375
+ `/v1/apps/${encodedApp}/db/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
376
+ { method: "PATCH", body: JSON.stringify({ data }) });
377
+ return res.record;
378
+ },
379
+ insert: async <T = Record<string, unknown>>(collection: string, data: T) => {
380
+ const col = encodeURIComponent(collection);
381
+ const res = await this.request<{ record: CollectionRecord<T> }>(
382
+ `/v1/apps/${encodedApp}/db/collections/${col}`,
383
+ {
384
+ method: "POST",
385
+ body: JSON.stringify({ data }),
386
+ }
387
+ );
388
+ return res.record;
389
+ },
390
+ delete: async (collection: string, id: string) => {
391
+ const col = encodeURIComponent(collection);
392
+ const recId = encodeURIComponent(id);
393
+ return this.request<{ deleted: boolean; id: string }>(
394
+ `/v1/apps/${encodedApp}/db/collections/${col}/${recId}`,
395
+ { method: "DELETE" }
396
+ );
397
+ },
398
+ };
399
+
400
+ this.storage = {
401
+ listPage: (cursor) => this.request(`/v1/apps/${encodedApp}/storage${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`),
402
+ trash: (cursor) => this.request<StorageTrashPage>(
403
+ `/v1/apps/${encodedApp}/storage-trash${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`),
404
+ restore: (id) => this.request<{ restored: boolean; key: string }>(
405
+ `/v1/apps/${encodedApp}/storage-trash/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({}) }),
406
+ list: async () => {
407
+ const res = await this.request<{ objects: StorageObjectInfo[] }>(
408
+ `/v1/apps/${encodedApp}/storage`
409
+ );
410
+ return res.objects;
411
+ },
412
+ putFile: async (key: string, data: StorageFileData, contentType?: string) => {
413
+ const encodedKey = encodeURIComponent(key);
414
+ const blobType = data instanceof Blob ? data.type : "";
415
+ const resolvedType =
416
+ contentType || blobType || "application/octet-stream";
417
+ // Copy views into a plain buffer: fetch types only accept ArrayBuffer-backed bodies.
418
+ const body: BodyInit =
419
+ data instanceof Uint8Array ? (data.slice().buffer as ArrayBuffer) : data;
420
+ return this.request<{ key: string; size: number; contentType: string }>(
421
+ `/v1/apps/${encodedApp}/storage/${encodedKey}?raw=1`,
422
+ {
423
+ method: "PUT",
424
+ headers: { "Content-Type": resolvedType },
425
+ body,
426
+ }
427
+ );
428
+ },
429
+ getFile: async (key: string) => {
430
+ const encodedKey = encodeURIComponent(key);
431
+ const res = await this.requestRaw(
432
+ `/v1/apps/${encodedApp}/storage/${encodedKey}?raw=1`
433
+ );
434
+ return res.blob();
435
+ },
436
+ putText: async (key: string, content: string) => {
437
+ const encodedKey = encodeURIComponent(key);
438
+ return this.request<{ key: string; size: number }>(
439
+ `/v1/apps/${encodedApp}/storage/${encodedKey}`,
440
+ {
441
+ method: "PUT",
442
+ body: JSON.stringify({ content }),
443
+ }
444
+ );
445
+ },
446
+ getText: async (key: string) => {
447
+ const encodedKey = encodeURIComponent(key);
448
+ const res = await this.request<{ key: string; content: string }>(
449
+ `/v1/apps/${encodedApp}/storage/${encodedKey}`
450
+ );
451
+ return res.content;
452
+ },
453
+ delete: async (key: string) => {
454
+ const encodedKey = encodeURIComponent(key);
455
+ return this.request<{ deleted: boolean; key: string; recovery?: { id: string; expires_at: string } }>(
456
+ `/v1/apps/${encodedApp}/storage/${encodedKey}`,
457
+ { method: "DELETE" }
458
+ );
459
+ },
460
+ };
461
+
462
+ this.auth = {
463
+ me: async () => {
464
+ return this.request<UserInfo>("/v1/me");
465
+ },
466
+ signUp: async (params) => {
467
+ const authApp = encodeURIComponent(this.authAppId());
468
+ return this.request<{ token: string | null; user: AppAuthUser }>(
469
+ `/v1/apps/${authApp}/auth/sign-up`,
470
+ {
471
+ method: "POST",
472
+ body: JSON.stringify(params),
473
+ }
474
+ );
475
+ },
476
+ signIn: async (params) => {
477
+ const authApp = encodeURIComponent(this.authAppId());
478
+ return this.request<{ token: string | null; user: AppAuthUser }>(
479
+ `/v1/apps/${authApp}/auth/sign-in`,
480
+ {
481
+ method: "POST",
482
+ body: JSON.stringify(params),
483
+ }
484
+ );
485
+ },
486
+ signOut: async (token) => {
487
+ const authApp = encodeURIComponent(this.authAppId());
488
+ return this.request<{ signed_out: boolean }>(
489
+ `/v1/apps/${authApp}/auth/sign-out`,
490
+ {
491
+ method: "POST",
492
+ headers: token ? { "X-Nofra-App-Token": token } : undefined,
493
+ body: JSON.stringify({}),
494
+ }
495
+ );
496
+ },
497
+ getSession: async (token) => {
498
+ const authApp = encodeURIComponent(this.authAppId());
499
+ return this.request<{
500
+ session: AppAuthSession | null;
501
+ user: AppAuthUser | null;
502
+ }>(`/v1/apps/${authApp}/auth/session`, {
503
+ headers: token ? { "X-Nofra-App-Token": token } : undefined,
504
+ });
505
+ },
506
+ listUsers: async (query = "") => {
507
+ const res = await this.request<{ enabled: boolean; users: AppAuthUser[] }>(
508
+ `/v1/apps/${encodedApp}/auth/users?q=${encodeURIComponent(query)}&offset=0`
509
+ );
510
+ return res.users;
511
+ },
512
+ listUsersPage: (query = "", offset = 0) =>
513
+ this.request<AppAuthUsersPage>(`/v1/apps/${encodedApp}/auth/users?q=${encodeURIComponent(query)}&offset=${offset}`),
514
+ setUserDisabled: (id, disabled) =>
515
+ this.request(`/v1/apps/${encodedApp}/auth/users/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ disabled }) }),
516
+ deleteUser: (id) =>
517
+ this.request(`/v1/apps/${encodedApp}/auth/users/${encodeURIComponent(id)}`, { method: "DELETE", body: JSON.stringify({ confirm: id }) }),
518
+ getLoginTypes: async () => {
519
+ const authApp = encodeURIComponent(this.authAppId());
520
+ return this.request<{ email: boolean; social: string[] }>(
521
+ `/v1/apps/${authApp}/auth/config`
522
+ );
523
+ },
524
+ getSocialSignInUrl: (provider, callbackURL) => {
525
+ const authApp = encodeURIComponent(this.authAppId());
526
+ return (
527
+ `${this.apiUrl}/v1/apps/${authApp}/auth/social-start` +
528
+ `?provider=${encodeURIComponent(provider)}` +
529
+ `&to=${encodeURIComponent(callbackURL)}`
530
+ );
531
+ },
532
+ signInWithSocial: (provider, callbackURL) => {
533
+ if (typeof window === "undefined" || !window.location) {
534
+ throw new Error(
535
+ "signInWithSocial needs a browser. Use getSocialSignInUrl() to build the link instead."
536
+ );
537
+ }
538
+ const target =
539
+ callbackURL ?? window.location.origin + window.location.pathname;
540
+ window.location.href = this.auth.getSocialSignInUrl(provider, target);
541
+ },
542
+ consumeSocialResult: () => {
543
+ if (typeof window === "undefined" || !window.location) {
544
+ return { token: null, error: null };
545
+ }
546
+ const url = new URL(window.location.href);
547
+ const token = url.searchParams.get("token");
548
+ const error = url.searchParams.get("error");
549
+ if (token || error) {
550
+ url.searchParams.delete("token");
551
+ url.searchParams.delete("error");
552
+ url.searchParams.delete("error_description");
553
+ window.history.replaceState(null, "", url.toString());
554
+ }
555
+ return { token, error };
556
+ },
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Identifier for the public auth endpoints: the explicit `subdomain`
562
+ * option, or the current hostname in browsers (the server matches it
563
+ * against subdomains and custom domains). App names are only unique per
564
+ * account, so they cannot identify an app on account-less routes.
565
+ */
566
+ private authAppId(): string {
567
+ if (this.subdomain) {
568
+ return this.subdomain;
569
+ }
570
+ const hostname =
571
+ typeof window !== "undefined" ? window.location?.hostname ?? "" : "";
572
+ if (
573
+ !hostname ||
574
+ hostname === "localhost" ||
575
+ hostname === "127.0.0.1" ||
576
+ hostname === "::1" ||
577
+ hostname === "[::1]"
578
+ ) {
579
+ throw new Error(
580
+ `Cannot derive the auth app from '${hostname || "this environment"}'. ` +
581
+ `Pass 'subdomain' explicitly (the <app>-<account>-<hash> part of your app URL).`
582
+ );
583
+ }
584
+ return hostname;
585
+ }
586
+
587
+ private async request<T>(path: string, init?: RequestInit): Promise<T> {
588
+ const headers = new Headers(init?.headers);
589
+ if (!headers.has("Content-Type") && init?.body) {
590
+ headers.set("Content-Type", "application/json");
591
+ }
592
+
593
+ const response = await this.requestRaw(path, { ...init, headers });
594
+
595
+ const text = await response.text();
596
+ let parsed: unknown = null;
597
+ if (text) {
598
+ try {
599
+ parsed = JSON.parse(text);
600
+ } catch {
601
+ parsed = { error: text };
602
+ }
603
+ }
604
+
605
+ return parsed as T;
606
+ }
607
+
608
+ private async requestRaw(path: string, init?: RequestInit): Promise<Response> {
609
+ const headers = new Headers(init?.headers);
610
+ if (this.apiKey) {
611
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
612
+ }
613
+
614
+ const response = await fetch(`${this.apiUrl}${path}`, {
615
+ ...init,
616
+ headers,
617
+ });
618
+
619
+ if (!response.ok) {
620
+ const text = await response.text();
621
+ let parsed: unknown = null;
622
+ if (text) {
623
+ try {
624
+ parsed = JSON.parse(text);
625
+ } catch {
626
+ parsed = { error: text };
627
+ }
628
+ }
629
+ const errMessage =
630
+ parsed &&
631
+ typeof parsed === "object" &&
632
+ "error" in parsed &&
633
+ typeof (parsed as { error: unknown }).error === "string"
634
+ ? (parsed as { error: string }).error
635
+ : `HTTP ${response.status}`;
636
+ throw new NofraError(errMessage, response.status);
637
+ }
638
+
639
+ return response;
640
+ }
641
+ }
642
+
643
+ export function createClient(options: NofraClientOptions): NofraClient {
644
+ return new NofraClient(options);
645
+ }