@mdrnio/sdk 0.0.0-stage → 0.3.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MDRN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,218 @@
1
- # Temporary Holding Version
1
+ # MDRN SDK for JavaScript
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
+ `@mdrnio/sdk` is the client for the products a deployed MDRN app has (Database, Files, Mail) and
4
+ for who is calling it (Auth), for Workers and Node.js 20+. It has no dependencies. The same
5
+ surface exists for [Python, Go and Rust](https://mdrn.io/docs/sdk/).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @mdrnio/sdk
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```javascript
16
+ import { Mdrn } from "@mdrnio/sdk";
17
+
18
+ export default {
19
+ async fetch(request, env) {
20
+ const mdrn = Mdrn.fromEnv(env);
21
+ const user = mdrn.auth.user(request);
22
+ if (!user) return new Response("Sign in first", { status: 401 });
23
+ const note = await mdrn.db.create("notes", { owner: user.id, text: "hello" });
24
+ const me = await mdrn.auth.me(request);
25
+ if (me?.email) await mdrn.mail.send({ to: me.email, subject: "Saved", text: `Note ${note.id} saved.` });
26
+ return Response.json(note);
27
+ },
28
+ };
29
+ ```
30
+
31
+ ## Where the values come from
32
+
33
+ MDRN injects everything `fromEnv` reads into a release's environment: `MDRN_PROJECT_ID`,
34
+ `MDRN_ENVIRONMENT` (`sandbox` or `production`), one `MDRN_<PRODUCT>_URL` and
35
+ `MDRN_<PRODUCT>_TOKEN` per product switched on for the app (`DATABASE`, `STORAGE` for Files,
36
+ `MAIL`), and the `MDRN_PRODUCTS` service binding, which `fromEnv` sends product requests
37
+ through (a Worker cannot reach its own hostname with a global `fetch`). Anywhere else, give the
38
+ values yourself; requests then go through the global `fetch`:
39
+
40
+ ```javascript
41
+ const mdrn = new Mdrn({ origin: "https://your-app.apps.mdrn.io", project, environment: "sandbox", tokens: { database, storage, mail } });
42
+ ```
43
+
44
+ The tokens are workload credentials, never for a browser. `mdrn.enabled` lists the products
45
+ the client has (`db`, `files`, `mail`); a call on another throws `PRODUCT_NOT_ENABLED` with the
46
+ variable that is missing. Options: `fetcher` (replaces the binding or global fetch),
47
+ `timeoutMs` (10 000).
48
+
49
+ Every write takes `{ idempotencyKey, signal }`. A key is generated when you give none; give
50
+ one when you retry a change yourself and want it to happen once.
51
+
52
+ ## Database
53
+
54
+ A record is a JSON object in a named collection, with an id MDRN assigns and a version that
55
+ grows by one on every change.
56
+
57
+ ```javascript
58
+ const task = await mdrn.db.create("tasks", { title: "Write docs", done: false });
59
+ const same = await mdrn.db.get("tasks", task.id); // null when there is none
60
+ const page = await mdrn.db.list("tasks", { limit: 50, cursor }); // { items, nextCursor }
61
+ const done = await mdrn.db.update("tasks", task.id, { done: true });
62
+ await mdrn.db.delete("tasks", task.id);
63
+ ```
64
+
65
+ `update` merges the fields you give into the record (a shallow merge). Without `version` it
66
+ reads the record first, sends the change with the version it read as `If-Match`, and if the
67
+ record changed in between (409 `CONFLICT`) it reads and tries again, up to three more times.
68
+ With `{ version }` the change is sent once with that `If-Match` and a `CONFLICT` is yours to
69
+ handle; that is the right choice when the user saw the version they are changing. `delete`
70
+ works the same way. Keep cursors exactly as returned.
71
+
72
+ ## Files
73
+
74
+ ```javascript
75
+ const file = await mdrn.files.upload({ name: "photo.jpg", type: "image/jpeg", bytes });
76
+ const info = await mdrn.files.get(file.id); // null when there is none
77
+ const page = await mdrn.files.list({ limit: 50, cursor });
78
+ const { url, expiresAt } = await mdrn.files.downloadUrl(file.id); // a plain URL, 15 minutes
79
+ await mdrn.files.delete(file.id);
80
+ ```
81
+
82
+ `upload` does the whole upload: the file's SHA-256, an upload session, one part of 16 MiB after
83
+ another sent to a grant URL with the grant's own headers (through the global `fetch`, never the
84
+ binding and never with the token), completion, and then waits for MDRN to verify the bytes
85
+ (polled every second, a minute at most) and answers the file in state `ready`. Pass
86
+ `{ wait: false }` to get it back after one read instead (usually still `verifying`) and check
87
+ `files.get` later. Bytes that do not match throw `FILE_REJECTED`.
88
+
89
+ The session itself is there too, for resuming an upload or for letting a browser PUT the
90
+ bytes: `mdrn.files.uploads.create`, `mdrn.files.uploads.get`, `mdrn.files.uploads.list`,
91
+ `mdrn.files.uploads.parts`, `mdrn.files.uploads.part`, `mdrn.files.uploads.complete`,
92
+ `mdrn.files.uploads.abort` and `mdrn.files.operation(id)`. Ask for a part grant in your
93
+ backend and hand the browser the grant's `url`, `method` and `headers`.
94
+
95
+ ## Mail
96
+
97
+ ```javascript
98
+ await mdrn.mail.send({ to: "ada@example.com", subject: "Welcome", text: "Hello Ada", html: "<p>Hello Ada</p>" });
99
+ await mdrn.mail.send({ to: "ada@example.com", template: "welcome" });
100
+ ```
101
+
102
+ A message is either your own content (`subject` and `text`, `html` if wanted) or a `template`
103
+ of the project and environment, which supplies all three; giving a template together with
104
+ any of them throws a `TypeError` before anything is sent. `attachments` (up to four,
105
+ `{ filename, type, content }` with base64 content) and `senderId` (a verified sender domain;
106
+ omit it when the workspace has one) go with either. The answer is `{ id, state: "queued" }`:
107
+ accepted, not yet delivered. A Sandbox environment delivers like Production: to anyone, from
108
+ a verified sender.
109
+
110
+ ## Auth
111
+
112
+ MDRN's Proxy deletes every `x-mdrn-*` header a client sent and, for a signed-in visitor, adds
113
+ `x-mdrn-user-id` and `x-mdrn-identity-token`. Your Worker is reachable only through the Proxy,
114
+ so these can be trusted there (not under `wrangler dev`, where anyone can send them).
115
+
116
+ ```javascript
117
+ const user = mdrn.auth.user(request); // { id, token } or null
118
+ const me = await mdrn.auth.me(request); // { id, email?, name?, metadataState, ... } or null
119
+ ```
120
+
121
+ `me` asks MDRN Identity (`GET /v1/auth/me` through the binding, with the visitor's short-lived
122
+ token) and answers null when nobody is signed in or the session has ended.
123
+
124
+ ### Sign-in from the browser
125
+
126
+ With `loginPage` set to `own` in the Auth configuration, your app serves the sign-in page
127
+ itself, and `@mdrnio/sdk/browser` makes that page's calls: no dependencies, nothing of the
128
+ server-side client, so it runs in any browser, bundled with your page or served from
129
+ `node_modules/@mdrnio/sdk/src/browser.js`.
130
+
131
+ ```javascript
132
+ import { signIn, session, signOut } from "@mdrnio/sdk/browser";
133
+
134
+ const auth = signIn({ returnPath: "/" }); // reads ?transactionId= and ?reason= from the address, and removes them
135
+ auth.message; // a sentence to show when MDRN sent the visitor back, else null
136
+ await auth.sendCode(email); // starts the sign-in unless one is resumed, emails a six-digit code
137
+ await auth.resendCode(); // the same address again; MDRN allows one code a minute
138
+ await auth.verifyCode(code); // signs in and goes to returnPath; { navigate: false } answers { redirectUrl }
139
+ await auth.continueWith("google"); // or "github": the browser goes to the provider and comes back signed in
140
+ await session(); // { state: "authenticated", user, expiresAt } or { state: "anonymous", user: null }
141
+ await signOut(); // ends the session on this address
142
+ ```
143
+
144
+ A refused call throws a `SignInError`: `code` is MDRN's (`RATE_LIMITED`, `ACCOUNT_DISABLED`,
145
+ `EMAIL_UNDELIVERABLE`, `EMAIL_PAUSED`, …) and `message` a sentence for the visitor. The calls go
146
+ to the app's own address with the browser's cookies and the CSRF tokens MDRN asks for; a test
147
+ gives `signIn`, `session` and `signOut` their own `fetch`, `location` and `history`.
148
+
149
+ ## Schedules
150
+
151
+ A schedule declared in `mdrn.jsonc` arrives as a `POST` to its path. `scheduledRun(request)`
152
+ returns the run, or `null` for any other request. The run id is the same on every attempt of
153
+ one run, so use it as the idempotency key of the work.
154
+
155
+ ```javascript
156
+ import { scheduledRun } from "@mdrnio/sdk";
157
+ const run = scheduledRun(request);
158
+ if (url.pathname === "/tasks/digest" && run) await sendDigest(run.runId);
159
+ ```
160
+
161
+ ## Errors
162
+
163
+ Every failed call throws `MdrnError` with `status` (the HTTP status, 0 when no response was
164
+ received), `code`, `message` (MDRN's, for your logs) and `details` (the response body).
165
+
166
+ | `code` | `status` | When |
167
+ | --- | --- | --- |
168
+ | `PRODUCT_NOT_ENABLED` | 0 | The product has no URL or token in this environment |
169
+ | `TIMEOUT` | 0 | No answer within 10 seconds (`timeoutMs`) |
170
+ | `NETWORK_ERROR` | 0 | The request could not be sent |
171
+ | `UNEXPECTED_REDIRECT` | 3xx | MDRN answered with a redirect, which is never followed |
172
+ | `HTTP_ERROR` | 4xx, 5xx | A failure without MDRN's error envelope |
173
+ | `INVALID_REQUEST`, `CONFLICT`, `NOT_FOUND`, `TEMPLATE_NOT_FOUND`, ... | 4xx, 5xx | The code from MDRN's envelope |
174
+ | `UPLOAD_FAILED`, `FILE_REJECTED` | 0 | An upload did not accept parts, or MDRN rejected the bytes |
175
+
176
+ `get` methods answer `null` for a 404 instead of throwing. `code` comes from MDRN's envelope
177
+ (`{ error: { code, message } }`), or is `HTTP_ERROR` when the body is not one. A redirect is
178
+ `UNEXPECTED_REDIRECT`: never followed, so the token cannot reach another origin. Nothing is
179
+ retried except the conflict loop of `update` and `delete`. The product URLs must be https;
180
+ plain http is accepted only to `localhost`, `127.0.0.1` or `[::1]`.
181
+
182
+ ```javascript
183
+ try { await mdrn.db.update("tasks", id, patch, { version }); }
184
+ catch (error) { if (error instanceof MdrnError && error.code === "CONFLICT") { /* reload */ } else throw error; }
185
+ ```
186
+
187
+ ## Testing
188
+
189
+ `@mdrnio/sdk/fake` is an in-memory MDRN: the 19 operations with the real response shapes, status
190
+ codes and error codes, idempotent replay, upload sessions with grants, a mail queue with the
191
+ template rule, `/v1/auth/me` for a signed-in user, and a log of every request.
192
+
193
+ ```javascript
194
+ import { createFakeMdrn } from "@mdrnio/sdk/fake";
195
+ const fake = createFakeMdrn();
196
+ const mdrn = Mdrn.fromEnv(fake.env, { transfer: fake.fetch }); // transfer: grant URLs, in Node
197
+ const headers = fake.signIn("user_1", "user@example.com"); // the two Proxy headers
198
+ await worker.fetch(new Request("https://app.example/", { headers }), fake.env);
199
+ fake.collections.get("notes"); fake.sent; fake.log;
200
+ ```
201
+
202
+ In workerd (Miniflare) give `fake.fetch` as the `MDRN_PRODUCTS` service binding and as the
203
+ `outboundService`. Three collection names reach error paths: `broken` (502 without an
204
+ envelope), `redirected` (307), `contended` (every read bumps the version).
205
+ [`examples/round-trip.js`](examples/round-trip.js) is a Worker that uses most of the SDK;
206
+ `npm test` runs it in Node and in workerd against the fake.
207
+
208
+ ## Versions
209
+
210
+ `0.3.1`, shared with the other languages (`sdk/VERSION`, `sdk/CHANGELOG.md`). All four SDKs
211
+ produce the same transcript against the same scenarios: `sdk/conformance/`.
212
+
213
+ ## Contributing
214
+
215
+ This repository is a mirror: the code is published here from MDRN's main repository on every
216
+ release, where it is tested against the API contract together with the other SDKs. Issues are
217
+ welcome here. Pull requests are reviewed here and merged in the main repository, from where the
218
+ next release brings them back.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@mdrnio/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "The MDRN SDK for JavaScript: Database, Files, Mail and Auth from a Worker or Node.js, sign-in from the browser, with an in-memory fake for tests",
5
+ "license": "MIT",
6
+ "homepage": "https://mdrn.io/docs/sdk/",
7
+ "bugs": "https://github.com/mdrn-io/sdk-javascript/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mdrn-io/sdk-javascript.git"
11
+ },
12
+ "type": "module",
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./src/index.d.ts",
19
+ "default": "./src/index.js"
20
+ },
21
+ "./fake": {
22
+ "types": "./src/fake.d.ts",
23
+ "default": "./src/fake.js"
24
+ },
25
+ "./browser": {
26
+ "types": "./src/browser.d.ts",
27
+ "default": "./src/browser.js"
28
+ }
29
+ },
30
+ "types": "./src/index.d.ts",
31
+ "scripts": {
32
+ "test": "node --test"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,72 @@
1
+ /** Sign-in from the browser, for an app that serves its own sign-in page (`loginPage` set to
2
+ * `own`): the calls MDRN's hosted page makes, on the app's own address, with the browser's
3
+ * cookies. No dependencies and nothing from `@mdrnio/sdk`; it runs in any browser. */
4
+
5
+ /** A sign-in call that did not succeed. `code` is MDRN's error code (ACCOUNT_DISABLED,
6
+ * RATE_LIMITED, EMAIL_UNDELIVERABLE, EMAIL_PAUSED, invalid_session, …), HTTP_ERROR for an answer
7
+ * without one, NETWORK_ERROR when nothing answered, INVALID_DESTINATION for a redirect off this
8
+ * origin; `status` the HTTP status, 0 for none. The message is a sentence to show the visitor. */
9
+ export class SignInError extends Error {
10
+ constructor(status: number, code: string, message: string);
11
+ status: number;
12
+ code: string;
13
+ }
14
+
15
+ export type Provider = "google" | "github";
16
+
17
+ /** Why MDRN sent the visitor back to the page. `provider_cancelled` and `unavailable` keep the
18
+ * sign-in, which the next call resumes; the others drop it, so the next call starts afresh. */
19
+ export type SignInReason = "provider_cancelled" | "unavailable" | "provider_attempts_exhausted" | "transaction_expired" | "transaction_already_proved" | "account_disabled";
20
+
21
+ /** The browser's own fetch, location and history unless given (a test gives its own). */
22
+ export interface BrowserOptions {
23
+ fetch?: typeof fetch;
24
+ location?: Pick<Location, "href" | "origin" | "assign">;
25
+ history?: Pick<History, "replaceState">;
26
+ }
27
+
28
+ export interface SignInOptions extends BrowserOptions {
29
+ /** Where the visitor goes once signed in; "/" unless given. */
30
+ returnPath?: string;
31
+ }
32
+
33
+ export interface SignIn {
34
+ /** The reason the address carried, or null. */
35
+ readonly reason: SignInReason | null;
36
+ /** A sentence to show for `reason`, or null. */
37
+ readonly message: string | null;
38
+ /** Starts a sign-in unless one is being resumed, and emails `email` a six-digit code. */
39
+ sendCode(email: string): Promise<void>;
40
+ /** Emails a new code to the address `sendCode` was given; MDRN allows one a minute. */
41
+ resendCode(): Promise<void>;
42
+ /** Verifies the code and finishes the sign-in: the browser goes to `redirectUrl`, unless
43
+ * `navigate` is false; the answer is returned either way. */
44
+ verifyCode(code: string, options?: { navigate?: boolean }): Promise<{ redirectUrl: string }>;
45
+ /** Starts a sign-in unless one is being resumed, then sends the browser to the provider; the
46
+ * visitor comes back signed in at `returnPath`, or to this page with a `reason`. */
47
+ continueWith(provider: Provider): Promise<void>;
48
+ }
49
+
50
+ /** The sign-in of the page this is called on: it reads `?transactionId=` (a sign-in to resume)
51
+ * and `?reason=` (why the visitor is back) from the address and removes them from it. One per
52
+ * page load. */
53
+ export function signIn(options?: SignInOptions): SignIn;
54
+
55
+ export interface SessionUser {
56
+ id: string;
57
+ email?: string;
58
+ name?: string;
59
+ }
60
+
61
+ export interface Session {
62
+ state: "authenticated" | "anonymous";
63
+ user: SessionUser | null;
64
+ expiresAt?: string | null;
65
+ }
66
+
67
+ /** Whether the browser is signed in on this address (`GET /_mdrn/v1/auth/session`). */
68
+ export function session(options?: BrowserOptions): Promise<Session>;
69
+
70
+ /** Ends the browser's session on this address (`DELETE /_mdrn/v1/auth/session`, with the CSRF
71
+ * token); resolves once it has. */
72
+ export function signOut(options?: BrowserOptions): Promise<void>;
package/src/browser.js ADDED
@@ -0,0 +1,190 @@
1
+ // Sign-in from the browser, for an app that serves its own sign-in page (loginPage set to own):
2
+ // the calls MDRN's hosted page makes (identity/web/login.js), on the app's own address, with the
3
+ // browser's cookies. Nothing from index.js, no dependencies: fetch, URL, location and history,
4
+ // each injectable for a test.
5
+
6
+ /** A sign-in call that did not succeed. `code` is MDRN's error code (ACCOUNT_DISABLED,
7
+ * RATE_LIMITED, EMAIL_UNDELIVERABLE, EMAIL_PAUSED, invalid_session, …), HTTP_ERROR for an answer
8
+ * without one, NETWORK_ERROR when nothing answered; `status` the HTTP status (0 for none). The
9
+ * message is a sentence for the visitor. */
10
+ export class SignInError extends Error {
11
+ constructor(status, code, message) {
12
+ super(message);
13
+ this.name = "SignInError";
14
+ this.status = status;
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ const PLATFORM_CSRF = "/_mdrn/v1/platform/csrf";
20
+ const START = "/_mdrn/v1/auth/start";
21
+ const SESSION = "/_mdrn/v1/auth/session";
22
+ const PROVIDERS = ["google", "github"];
23
+ const TRANSACTION = /^[a-f0-9]{64}$/;
24
+ // Why MDRN sent the browser back here. A declined provider page, or one MDRN could not serve just
25
+ // then, keeps its sign-in; a used-up, expired or finished one is dropped, so the next step starts
26
+ // a new one. A Map, so a reason named like an Object property (constructor) is no reason at all.
27
+ const REASONS = new Map([
28
+ ["provider_cancelled", "Sign-in was cancelled. Choose a way to sign in."],
29
+ ["unavailable", "Sign-in is unavailable right now. Try again in a moment."],
30
+ ["provider_attempts_exhausted", "That sign-in was tried too many times. Choose a way to sign in to start again."],
31
+ ["transaction_expired", "That sign-in expired. Choose a way to sign in to start again."],
32
+ ["transaction_already_proved", "That sign-in already finished in another tab or window. Choose a way to sign in to start again."],
33
+ ["account_disabled", "Account disabled"],
34
+ ]);
35
+ const KEPT = new Set(["provider_cancelled", "unavailable"]);
36
+ const WORDING = new Map([
37
+ ["ACCOUNT_DISABLED", "Account disabled"],
38
+ ["RATE_LIMITED", "Please wait before trying again."],
39
+ ["EMAIL_UNDELIVERABLE", "That address cannot receive email. Check it and try again."],
40
+ ["EMAIL_PAUSED", "This app cannot send sign-in emails right now. Try another way to sign in, or contact the app."],
41
+ ]);
42
+ const FALLBACK = "Unable to continue. Check your code or restart sign-in.";
43
+ const OFFLINE = "Could not reach the app. Check your connection and try again.";
44
+
45
+ // The browser's own fetch, location and history unless a test injects its own.
46
+ function browser(options = {}) {
47
+ const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
48
+ if (typeof fetcher !== "function") throw new TypeError("fetch is required");
49
+ return { fetch: fetcher, location: options.location ?? globalThis.location, history: options.history ?? globalThis.history };
50
+ }
51
+
52
+ // One call on the app's own address: a GET, or `method` with a JSON body and the CSRF token.
53
+ // The pre-sign-in cookie can be stale, so a 401 on the platform token is read once more, as
54
+ // the hosted page does. A failure is a SignInError with MDRN's code and the visitor's sentence.
55
+ async function request(env, path, { method = "GET", body, csrf } = {}) {
56
+ const init = { method, credentials: "same-origin", headers: {} };
57
+ if (method !== "GET") init.headers["X-CSRF-Token"] = csrf;
58
+ if (body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); }
59
+ let response;
60
+ try {
61
+ response = await env.fetch(path, init);
62
+ if (path === PLATFORM_CSRF && response.status === 401) response = await env.fetch(path, { credentials: "same-origin" });
63
+ } catch {
64
+ throw new SignInError(0, "NETWORK_ERROR", OFFLINE);
65
+ }
66
+ let value = null;
67
+ const text = await response.text();
68
+ if (text) { try { value = JSON.parse(text); } catch { value = null; } }
69
+ if (!response.ok) {
70
+ const code = typeof value?.error?.code === "string" ? value.error.code : "HTTP_ERROR";
71
+ throw new SignInError(response.status, code, WORDING.get(code) ?? FALLBACK);
72
+ }
73
+ return value;
74
+ }
75
+
76
+ const text = (value, what) => {
77
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${what} is required`);
78
+ return value.trim();
79
+ };
80
+
81
+ class SignIn {
82
+ #env;
83
+ #returnPath;
84
+ #transaction;
85
+ #csrf;
86
+ #email;
87
+ reason;
88
+ message;
89
+
90
+ constructor(options) {
91
+ this.#env = browser(options);
92
+ this.#returnPath = options.returnPath ?? "/";
93
+ if (typeof this.#returnPath !== "string" || !this.#returnPath.startsWith("/")) throw new TypeError("returnPath must be a path starting with /");
94
+ const { location, history } = this.#env;
95
+ if (!location?.href) throw new TypeError("signIn runs in a browser, on the page MDRN sends visitors to");
96
+ const opened = new URL(location.href);
97
+ const reason = opened.searchParams.get("reason");
98
+ const given = opened.searchParams.get("transactionId");
99
+ this.reason = reason !== null && REASONS.has(reason) ? reason : null;
100
+ this.message = this.reason ? REASONS.get(this.reason) : null;
101
+ // An unknown reason drops the sign-in too: only a kept one is worth resuming.
102
+ this.#transaction = (reason === null || KEPT.has(reason)) && given !== null && TRANSACTION.test(given) ? given : null;
103
+ this.#csrf = null;
104
+ this.#email = null;
105
+ if ((reason !== null || given !== null) && history?.replaceState) {
106
+ opened.searchParams.delete("reason");
107
+ opened.searchParams.delete("transactionId");
108
+ history.replaceState(null, "", opened.href);
109
+ }
110
+ }
111
+
112
+ // The sign-in to prove, started unless one is being resumed, and its own CSRF token.
113
+ async #ready(method) {
114
+ if (!this.#transaction) {
115
+ const { csrfToken } = await request(this.#env, PLATFORM_CSRF);
116
+ const started = await request(this.#env, START, { method: "POST", body: { returnPath: this.#returnPath, method }, csrf: csrfToken });
117
+ this.#transaction = started.transactionId;
118
+ this.#csrf = null;
119
+ }
120
+ if (!this.#csrf) this.#csrf = (await request(this.#env, `/v1/auth/transactions/${this.#transaction}/csrf`)).csrfToken;
121
+ }
122
+
123
+ #post(action, body) {
124
+ return request(this.#env, `/v1/auth/transactions/${this.#transaction}/${action}`, { method: "POST", body, csrf: this.#csrf });
125
+ }
126
+
127
+ #go(url) {
128
+ const target = new URL(url, this.#env.location.origin);
129
+ if (target.origin !== this.#env.location.origin) throw new SignInError(0, "INVALID_DESTINATION", "Invalid sign-in destination.");
130
+ this.#env.location.assign(target.href);
131
+ }
132
+
133
+ /** Starts a sign-in unless one is being resumed, and emails `email` a six-digit code. */
134
+ async sendCode(email) {
135
+ const address = text(email, "email");
136
+ await this.#ready("email_otp");
137
+ await this.#post("email-otp/send", { email: address });
138
+ this.#email = address;
139
+ }
140
+
141
+ /** Emails a new code to the address sendCode was given; MDRN allows one a minute. */
142
+ async resendCode() {
143
+ if (!this.#email) throw new TypeError("sendCode first");
144
+ await this.sendCode(this.#email);
145
+ }
146
+
147
+ /** Verifies the code and finishes the sign-in: the browser goes to the answer's redirectUrl,
148
+ * unless `navigate` is false; the answer is returned either way. A code already verified (a
149
+ * second submit, say) is finished through `complete`, as the hosted page does. */
150
+ async verifyCode(code, { navigate = true } = {}) {
151
+ const digits = text(code, "code");
152
+ await this.#ready("email_otp");
153
+ let result;
154
+ try {
155
+ result = await this.#post("email-otp/verify", { code: digits });
156
+ } catch (error) {
157
+ try { result = await this.#post("complete", {}); } catch { throw error; }
158
+ }
159
+ if (navigate) this.#go(result.redirectUrl);
160
+ return { redirectUrl: result.redirectUrl };
161
+ }
162
+
163
+ /** Starts a sign-in unless one is being resumed, then sends the browser to Google or GitHub;
164
+ * the visitor comes back signed in at returnPath, or here with a reason. */
165
+ async continueWith(provider) {
166
+ if (!PROVIDERS.includes(provider)) throw new TypeError(`provider must be one of ${PROVIDERS.join(", ")}`);
167
+ await this.#ready(provider);
168
+ this.#go(`/v1/auth/transactions/${this.#transaction}/${provider}`);
169
+ }
170
+ }
171
+
172
+ /** The sign-in of the page this is called on: it reads `?transactionId=` (a sign-in to resume)
173
+ * and `?reason=` (why the visitor is back; `reason` and `message` say) from the address and
174
+ * removes them from it. One per page load. */
175
+ export function signIn(options = {}) {
176
+ return new SignIn(options);
177
+ }
178
+
179
+ /** Whether the browser is signed in on this address: `state` authenticated with `user` and
180
+ * `expiresAt`, or anonymous. */
181
+ export function session(options) {
182
+ return request(browser(options), SESSION);
183
+ }
184
+
185
+ /** Ends the browser's session on this address; resolves once it has. */
186
+ export async function signOut(options) {
187
+ const env = browser(options);
188
+ const { csrfToken } = await request(env, PLATFORM_CSRF);
189
+ await request(env, SESSION, { method: "DELETE", csrf: csrfToken });
190
+ }
package/src/fake.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { MdrnEnv, Record } from "./index.js";
2
+
3
+ export interface FakeOptions {
4
+ /** The origin the product URLs are under; https://app.example by default. */
5
+ origin?: string;
6
+ /** Where grant URLs point; the origin by default. */
7
+ filesOrigin?: string;
8
+ project?: string;
9
+ environment?: string;
10
+ /** The size of an upload part, 16 MiB by default; small in tests to get several parts. */
11
+ partBytes?: number;
12
+ tokens?: { database?: string; storage?: string; mail?: string };
13
+ }
14
+
15
+ export interface LogEntry {
16
+ method: string;
17
+ /** Path and query. */
18
+ path: string;
19
+ /** authorization, mdrn-project, mdrn-environment, idempotency-key, if-match, content-type
20
+ * and x-mdrn-grant, when sent. */
21
+ headers: { [name: string]: string };
22
+ /** The JSON body of a mutation; `{bytes}` for a part PUT; null otherwise. */
23
+ body: unknown;
24
+ status: number;
25
+ }
26
+
27
+ export interface SentMessage {
28
+ id: string;
29
+ recipient: string;
30
+ subject: string;
31
+ text: string;
32
+ html: string | null;
33
+ attachments: { filename: string; type: string; content: string }[];
34
+ senderId: string | null;
35
+ templateId: string | null;
36
+ }
37
+
38
+ export interface FakeMdrn {
39
+ /** What MDRN injects into a release, with MDRN_PRODUCTS answered by this fake. */
40
+ env: MdrnEnv & { MDRN_PRODUCTS: { fetch: typeof fetch } };
41
+ /** Serves a product request, a grant URL or /v1/auth/me. */
42
+ fetch: typeof fetch;
43
+ collections: Map<string, Map<string, Record>>;
44
+ uploads: Map<string, unknown>;
45
+ objects: Map<string, unknown>;
46
+ operations: Map<string, unknown>;
47
+ /** Mail templates by id; `welcome` exists. */
48
+ templates: Map<string, { subject: string; text: string; html: string | null }>;
49
+ /** Every message queued, in order. */
50
+ sent: SentMessage[];
51
+ /** Every request served, in order. */
52
+ log: LogEntry[];
53
+ /** Makes `id` a signed-in user and gives the two headers MDRN's Proxy adds to their requests. */
54
+ signIn(id: string, email?: string, token?: string): { "x-mdrn-user-id": string; "x-mdrn-identity-token": string };
55
+ /** Sends bytes to a part grant's URL as a browser would. */
56
+ putBytes(grantUrl: string, bytes: Uint8Array | ArrayBuffer): Promise<{ partNumber: number; bytes: number; etag: string }>;
57
+ /** Fetches a download grant's URL. */
58
+ getBytes(grantUrl: string): Promise<Uint8Array>;
59
+ /** Forgets every record, upload, object, message, replay and log entry. */
60
+ reset(): void;
61
+ }
62
+
63
+ export function createFakeMdrn(options?: FakeOptions): FakeMdrn;