@mdrnio/sdk 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +217 -2
- package/package.json +38 -4
- package/src/browser.d.ts +72 -0
- package/src/browser.js +190 -0
- package/src/fake.d.ts +63 -0
- package/src/fake.js +412 -0
- package/src/index.d.ts +257 -0
- package/src/index.js +360 -0
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
|
-
#
|
|
1
|
+
# MDRN SDK for JavaScript
|
|
2
2
|
|
|
3
|
-
|
|
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.0`, 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mdrn-io/mdrn.git",
|
|
10
|
+
"directory": "sdk/javascript"
|
|
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
|
+
}
|
package/src/browser.d.ts
ADDED
|
@@ -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;
|