@hecks/client 2.7.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 +310 -0
- package/dist/accountToken.d.ts +56 -0
- package/dist/accountToken.js +112 -0
- package/dist/accountToken.js.map +1 -0
- package/dist/answer.d.ts +27 -0
- package/dist/answer.js +37 -0
- package/dist/answer.js.map +1 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.js +107 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/paymentKeys.d.ts +25 -0
- package/dist/paymentKeys.js +36 -0
- package/dist/paymentKeys.js.map +1 -0
- package/dist/payments.d.ts +80 -0
- package/dist/payments.js +95 -0
- package/dist/payments.js.map +1 -0
- package/dist/resilientFetch.d.ts +61 -0
- package/dist/resilientFetch.js +91 -0
- package/dist/resilientFetch.js.map +1 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# @hecks/client
|
|
2
|
+
|
|
3
|
+
A small TypeScript client for the body-shaped dispatch protocol a Hecks host
|
|
4
|
+
answers. It reads the state of a domain, dispatches commands to it, and turns
|
|
5
|
+
the answers into typed values. It has no runtime dependencies and uses the
|
|
6
|
+
platform `fetch`, so it needs Node 18 or newer.
|
|
7
|
+
|
|
8
|
+
Beside the dispatch client it carries the other server-side pieces a site in
|
|
9
|
+
front of a host tends to need: a JSON reader that survives a briefly
|
|
10
|
+
unavailable upstream (`createResilientFetch`), a client for the host's
|
|
11
|
+
payment-connection API (`PaymentsConnection`), and a verifier for the account
|
|
12
|
+
token the host signs (`verifyAccountToken`). Each is described below.
|
|
13
|
+
|
|
14
|
+
The host answers this protocol on any path but `/` when it runs with
|
|
15
|
+
`HECKS_SERVE_MODE=1` (`rust/host`). The client posts to `<url>/dispatch`. The
|
|
16
|
+
protocol has no authentication: use it server to server on a private network,
|
|
17
|
+
never from a browser.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
The package is versioned with the `hecks` gem and published under the `@hecks`
|
|
22
|
+
npm scope:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @hecks/client
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The `hecks` organization exists on npm, but no version is published yet. Until
|
|
29
|
+
the first publish, install from a release tag of the repository instead (the
|
|
30
|
+
package lives in `packages/hecks-client`, so use a tool that can install from a
|
|
31
|
+
subdirectory, or a tarball built with `npm pack`):
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
git clone --branch v<version> https://github.com/heckslabs/hecks.git
|
|
35
|
+
cd hecks/packages/hecks-client && npm ci && npm pack
|
|
36
|
+
npm install ./hecks-client-<version>.tgz
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`<version>` is a release of the gem, such as `2.6.0`: the package carries the
|
|
40
|
+
same version as the `hecks` gem it was released with, so pick the one matching
|
|
41
|
+
the host you talk to.
|
|
42
|
+
|
|
43
|
+
The package is ESM and ships its type declarations.
|
|
44
|
+
|
|
45
|
+
## Use
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { HostClient, text, whole } from "@hecks/client";
|
|
49
|
+
|
|
50
|
+
const client = new HostClient({
|
|
51
|
+
domain: "Shop",
|
|
52
|
+
url: "http://127.0.0.1:4322",
|
|
53
|
+
role: "Organizer",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Everything the domain holds.
|
|
57
|
+
const answer = await client.read();
|
|
58
|
+
|
|
59
|
+
// One aggregate's states, as [id, state] pairs.
|
|
60
|
+
const events = client.instancesOf(answer, "Event").map(([id, state]) => ({
|
|
61
|
+
id,
|
|
62
|
+
name: text(state.name), // {value: "Yoga"} -> "Yoga"
|
|
63
|
+
priceCents: whole(state.price, "cents"), // {cents: 10800} -> 10800
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// A command, judged by the state that comes back.
|
|
67
|
+
const repriced = await client.apply({
|
|
68
|
+
verb: "Event.Reprice",
|
|
69
|
+
to: "yoga-1",
|
|
70
|
+
with: { price: { cents: 12000 } },
|
|
71
|
+
parse: (answer) => client.instancesOf(answer, "Event"),
|
|
72
|
+
confirm: (states) => states.find(([id]) => id === "yoga-1")?.[1].price?.cents === 12000,
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
`new HostClient(options)` (or `createClient(options)`) takes:
|
|
79
|
+
|
|
80
|
+
| Option | Meaning |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `domain` | The domain's name as its bluebook declares it. Falls back to the `HECKS_DOMAIN` environment variable. Required. |
|
|
83
|
+
| `url` | Where the host listens. Trailing slashes are trimmed. Falls back to the `HECKS_SERVICE_URL` environment variable. Required. |
|
|
84
|
+
| `role` | The role sent with every command unless a call names its own. The host compares it as a plain string against the roles a command declares. When neither the client nor the call sets one, no role is sent and the host performs no role check. |
|
|
85
|
+
| `timeoutMs` | How long one request may take before it counts as unreachable. Defaults to `8000`. |
|
|
86
|
+
| `fetch` | The `fetch` to send requests with, for tests or a custom agent. Defaults to the global `fetch`, looked up on each call. |
|
|
87
|
+
|
|
88
|
+
The environment variables are read once, when the client is constructed. A
|
|
89
|
+
missing domain or URL throws a `TypeError`.
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
`HostClient`
|
|
94
|
+
|
|
95
|
+
- `domain`, `url`: the resolved configuration.
|
|
96
|
+
- `qualify(name)`: places a bare name in the client's domain (`"Event"` becomes `"Shop::Event"`). A name that already contains `::` is returned unchanged.
|
|
97
|
+
- `read(): Promise<Answer>`: posts `{"read": true}` and returns everything the domain holds.
|
|
98
|
+
- `dispatch(verb, args = {}, to?, role?): Promise<Answer>`: posts one command and returns the raw answer. `verb` is `Aggregate.Verb` or fully qualified. `to` targets an existing instance and is omitted for a command that creates one.
|
|
99
|
+
- `apply<T>(command): Promise<T>`: dispatches `command.verb` with `command.with`, `command.to` and `command.role`, reads the answer with `command.parse`, and returns the result when `command.confirm` accepts it. Otherwise it throws `DomainRefusal` built from the answer's last refusal.
|
|
100
|
+
- `instancesOf(answer, aggregate): [string, state][]`: the states of one aggregate, bare or qualified name.
|
|
101
|
+
|
|
102
|
+
Standalone readers, importable without a client:
|
|
103
|
+
|
|
104
|
+
- `instancesOf(answer, "Domain::Aggregate")`: the same lookup with the aggregate named in full.
|
|
105
|
+
- `text(raw): string | null`: a string value object's text, `null` when absent or empty.
|
|
106
|
+
- `whole(raw, key): number`: a numeric value object's number under `key` (`"value"`, `"cents"`), `0` when absent.
|
|
107
|
+
- `optionalWhole(raw, key): number | null`: like `whole`, but `null` when the attribute was left unset.
|
|
108
|
+
- `refusalOf(answer, verb): DomainRefusal`: the domain's own words for why a command changed nothing.
|
|
109
|
+
|
|
110
|
+
Types and errors: `Answer` (`{ instances?, refusals?, error? }`), `Refusal`
|
|
111
|
+
(`{ kind, error }`), `ClientOptions`, `Command<T>`, `DomainRefusal` (has
|
|
112
|
+
`kind`), `DomainUnavailable`.
|
|
113
|
+
|
|
114
|
+
The other exports, by the section that describes them: `createResilientFetch`,
|
|
115
|
+
`ResilientFetchError` and the types `ResilientFetch`, `ResilientFetchConfig`,
|
|
116
|
+
`ResilientRequest`; `PaymentsConnection`, `pastedKeys`, `savedMessage` and the
|
|
117
|
+
types `PaymentConnectionState`, `PaymentMode`, `PaymentStatus`,
|
|
118
|
+
`PaymentsConnectionOptions`, `PaymentsResult`, `FormFields`, `PastedKeys`;
|
|
119
|
+
`verifyAccountToken`, `accountFromCookieHeader`, `cookieValue`,
|
|
120
|
+
`accountToken`, `resolveAccountCookieName`, `DEFAULT_ACCOUNT_COOKIE` and the
|
|
121
|
+
types `AccountClaims`, `Clock`, `CookieVerifyOptions`, `VerifyOptions`.
|
|
122
|
+
|
|
123
|
+
### Why `apply` judges by state
|
|
124
|
+
|
|
125
|
+
The host answers HTTP 200 whether or not the domain refused, and its
|
|
126
|
+
`refusals` list can carry entries replayed from history. A command therefore
|
|
127
|
+
counts as done when the state that comes back shows the change, never because
|
|
128
|
+
the list is empty or non-empty.
|
|
129
|
+
|
|
130
|
+
### Errors
|
|
131
|
+
|
|
132
|
+
- `DomainUnavailable`: the host could not be reached, answered a non-2xx status, did not answer with JSON, or answered `{ "error": ... }`.
|
|
133
|
+
- `DomainRefusal`: the domain understood a command and said no (`kind` is the failed rule, such as `GivenNotMet`, and `message` is the domain's wording).
|
|
134
|
+
|
|
135
|
+
## Reading upstream JSON that may be down: `createResilientFetch`
|
|
136
|
+
|
|
137
|
+
A site that renders from a service it does not own (a CMS, say) should not
|
|
138
|
+
turn that service restarting into an error for every visitor.
|
|
139
|
+
`createResilientFetch` reads JSON with two layers of protection: it retries
|
|
140
|
+
network errors, timeouts and 502/503/504 with a short backoff, and when the
|
|
141
|
+
upstream is still failing (a network error or any 5xx) it answers with the last
|
|
142
|
+
good response for that exact URL, from this process's memory. A cold process
|
|
143
|
+
has nothing remembered and still throws, so a broken deploy is never masked. A
|
|
144
|
+
4xx is a real answer and is never retried or served stale.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { createResilientFetch } from "@hecks/client";
|
|
148
|
+
|
|
149
|
+
const upstream = createResilientFetch();
|
|
150
|
+
const page = await upstream.json<Page>("http://cms.internal/api/pages/about", {
|
|
151
|
+
what: 'page "about"',
|
|
152
|
+
source: "the CMS",
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`createResilientFetch(config)` takes `fetch`, `sleep` and `warn` (all
|
|
157
|
+
injectable, for tests), `retryDelaysMs` (default `[150, 500]`; its length is the
|
|
158
|
+
number of retries), `attemptTimeoutMs` (default `10000`) and `maxRemembered`
|
|
159
|
+
(default `200`, the least recently answered URL is dropped first). Each reader
|
|
160
|
+
keeps its own memory; `clear()` forgets it.
|
|
161
|
+
|
|
162
|
+
`json<T>(url, request): Promise<T | null>` takes:
|
|
163
|
+
|
|
164
|
+
| Field | Meaning |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `what`, `source` | Words for the error message: `Failed to fetch <what> from <source>: <failure>`, thrown as `ResilientFetchError` (its `failure` is the last attempt's reason). |
|
|
167
|
+
| `allowStale` | Serve the remembered answer when the upstream is down. Defaults to `true`; turn it off where a stale answer would be acted on. |
|
|
168
|
+
| `nullOn404` | Resolve to `null` on a 404 instead of throwing. |
|
|
169
|
+
| `retryDelaysMs` | Replaces the configured delays for this call. |
|
|
170
|
+
| `headers` | Extra request headers. |
|
|
171
|
+
| `private` | The response is for one viewer (a draft read with their cookie): never remembered, and never answered from the remembered public copy of the same URL. |
|
|
172
|
+
|
|
173
|
+
## Connecting a payment processor: `PaymentsConnection`
|
|
174
|
+
|
|
175
|
+
A host that serves a domain with a `PaymentConnection` answers a JSON API under
|
|
176
|
+
`/payments/connection` (`rust/host/src/payments.rs`). Every route acts as the
|
|
177
|
+
person behind the host's account cookie, so each call forwards that person's own
|
|
178
|
+
session and the host decides who may do what: an Owner connects and
|
|
179
|
+
disconnects, only the platform operator turns real payments on or off.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { PaymentsConnection, pastedKeys, savedMessage } from "@hecks/client";
|
|
183
|
+
|
|
184
|
+
const payments = new PaymentsConnection({
|
|
185
|
+
url: "http://127.0.0.1:4322",
|
|
186
|
+
cookieName: "hecks_session", // the host's HECKS_SESSION_COOKIE; this is its default
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// `session` is the value of the person's account cookie.
|
|
190
|
+
const shown = await payments.show(session);
|
|
191
|
+
if (shown.ok) console.log(shown.connection.status, shown.connection.can_manage);
|
|
192
|
+
|
|
193
|
+
const pasted = pastedKeys(await request.formData()); // {keys} or {error}
|
|
194
|
+
if ("keys" in pasted) {
|
|
195
|
+
const saved = await payments.saveKeys(session, pasted.keys);
|
|
196
|
+
if (saved.ok) console.log(savedMessage(saved.connection.display_name, saved.connection.mode));
|
|
197
|
+
else if (saved.status === 401) redirectToSignIn();
|
|
198
|
+
else console.error(saved.error);
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`new PaymentsConnection({ url, cookieName?, timeoutMs?, fetch? })`. Every method
|
|
203
|
+
takes the session first and returns `Promise<PaymentsResult>`:
|
|
204
|
+
|
|
205
|
+
| Method | Route | Who may |
|
|
206
|
+
| --- | --- | --- |
|
|
207
|
+
| `show(session)` | `GET /payments/connection` | an Owner or the operator |
|
|
208
|
+
| `saveKeys(session, { secret_key, publishable_key })` | `POST .../direct` | an Owner |
|
|
209
|
+
| `useOwnAccount(session, mode)` | `POST .../direct` with `{ mode }` | an Owner |
|
|
210
|
+
| `disconnect(session)` | `POST .../disconnect` | an Owner |
|
|
211
|
+
| `enable(session)`, `disable(session)` | `POST .../enable`, `.../disable` | the operator |
|
|
212
|
+
|
|
213
|
+
`PaymentsResult` is `{ ok: true, status, connection }` or
|
|
214
|
+
`{ ok: false, status, error }`. `connection` is a `PaymentConnectionState`
|
|
215
|
+
(`status`, `processor`, `label`, `account_ref`, `mode`, `display_name`,
|
|
216
|
+
`direct_modes`, `direct`, `can_save_keys`, `can_manage`, `can_enable`); it never
|
|
217
|
+
carries a credential. `error` is the host's own sentence (`401` "not logged in",
|
|
218
|
+
`403`, `409`, `422` for keys the host rejects) and is `undefined` when the host
|
|
219
|
+
sent none, which is what an older host without these routes does by redirecting
|
|
220
|
+
to an HTML login page. A host that cannot be reached throws `DomainUnavailable`.
|
|
221
|
+
A `session` that is empty or not one cookie value throws `TypeError`.
|
|
222
|
+
|
|
223
|
+
The host, not this package, decides whether a key is well formed: it checks the
|
|
224
|
+
prefixes and that both keys are from the same mode, and answers `422` without
|
|
225
|
+
quoting a key. `pastedKeys(form)` only trims the two fields (`secret_key`,
|
|
226
|
+
`publishable_key`) of a `FormData` (or anything with `get(name)`) and says in
|
|
227
|
+
plain words which one is missing; its errors never repeat a pasted value.
|
|
228
|
+
`savedMessage(displayName, mode)` writes the confirmation sentence. Keys go to
|
|
229
|
+
the host once and are never stored or logged.
|
|
230
|
+
|
|
231
|
+
## Verifying the host's account token: `verifyAccountToken`
|
|
232
|
+
|
|
233
|
+
After a sign-in the host mints a signed claim of who the person is and sets it
|
|
234
|
+
as a cookie; `GET /accounts/sso-token` mints a 60-second copy as JSON so
|
|
235
|
+
another service can start its own session for the same person. These functions
|
|
236
|
+
are the receiving half. They are framework independent, do no I/O, and leave
|
|
237
|
+
what to do with the person (find or create a user, start a session) to the
|
|
238
|
+
caller.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { accountFromCookieHeader, verifyAccountToken } from "@hecks/client";
|
|
242
|
+
|
|
243
|
+
// A handoff token passed in a URL.
|
|
244
|
+
const claims = verifyAccountToken(token, process.env.HOST_SESSION_SECRET!);
|
|
245
|
+
if (!claims) return new Response("Invalid or expired token.", { status: 401 });
|
|
246
|
+
signInAs(claims.email);
|
|
247
|
+
|
|
248
|
+
// Or the cookie the host set, on a request to the same site.
|
|
249
|
+
const who = accountFromCookieHeader(request.headers.get("cookie"), secret, { cookieName: "site_session" });
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- `verifyAccountToken(token, secret, opts?): AccountClaims | null`, where `AccountClaims` is `{ email, exp }` (`exp` in Unix seconds). It returns `null`, and never throws, for a malformed token, a signature made with another secret, a missing email or expiry, or an expired token. `opts.now` is the clock (`() => milliseconds`, default `Date.now`). `opts.normalizeEmail` trims and lowercases the email and refuses one that is blank afterwards, for a receiver that stores addresses in that form; the default returns the email exactly as the host signed it. An empty `secret` throws `TypeError`.
|
|
253
|
+
- `accountFromCookieHeader(header, secret, opts?)`: reads the account cookie out of a `Cookie` header (`opts.cookieName`, default `hecks_session`) and verifies it.
|
|
254
|
+
- `cookieValue(header, name)`: one cookie's value in a `Cookie` header, or `null`.
|
|
255
|
+
- `accountToken(secret, email, ttlSeconds, opts?)`: mints a token in the host's format, for tests and stand-in hosts.
|
|
256
|
+
- `resolveAccountCookieName(configured?)` and `DEFAULT_ACCOUNT_COOKIE`: the cookie name the host uses for a value of `HECKS_SESSION_COOKIE` (unset or empty means `hecks_session`; an invalid name throws `TypeError`).
|
|
257
|
+
|
|
258
|
+
The secret is the host's session secret; the receiver must hold the same value.
|
|
259
|
+
The format is `rust/host/src/auth.rs`'s `account_token`:
|
|
260
|
+
`<payload>.<signature>`, where the payload is the base64url (no padding) of
|
|
261
|
+
the JSON `{"email": ..., "exp": <unix seconds>}` and the signature is the
|
|
262
|
+
lowercase hex HMAC-SHA256 of the payload as sent. A token is expired once the
|
|
263
|
+
current second is past `exp`. The tests pin a known-answer vector that the
|
|
264
|
+
host's own unit test (`account_token_matches_the_known_answer_vector`) checks
|
|
265
|
+
too.
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```sh
|
|
270
|
+
npm ci
|
|
271
|
+
npm test # builds, then runs the hermetic tests (no network)
|
|
272
|
+
npm run typecheck
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The hermetic tests run a protocol scenario against an in-process fake of the
|
|
276
|
+
host. `npm run test:contract` runs the same scenario against a live host; see
|
|
277
|
+
`test/contract.mjs`. The repository's `client-contract` workflow starts
|
|
278
|
+
`rust/host` on the `spec/fixtures/rust_host/checkout_fixture` domain and runs
|
|
279
|
+
it. To do the same locally, build that domain with `bin/project_wasm`, start
|
|
280
|
+
`rust/host`'s `bootstrap` binary with `HECKS_SERVE_MODE=1`, `HECKS_DOMAIN`,
|
|
281
|
+
`HECKS_WASM_PATH`, `HECKS_IR_PATH` and `DATABASE_URL` (a Postgres database), and
|
|
282
|
+
run `HECKS_SERVICE_URL=http://127.0.0.1:<port> npm run test:contract`. Add
|
|
283
|
+
`HECKS_CONTRACT_PAYMENTS=1` when that host also serves the payment-connection
|
|
284
|
+
routes (it started with `HECKS_CHECKOUT_DOMAIN` naming its domain) to check the
|
|
285
|
+
`PaymentsConnection` 401 as well; the workflow leaves it off.
|
|
286
|
+
|
|
287
|
+
## Releasing
|
|
288
|
+
|
|
289
|
+
The package version equals `Hecks::VERSION` and is released together with the
|
|
290
|
+
gem; `spec/hecks_client_version_spec.rb` fails when they differ, and
|
|
291
|
+
`bin/release_gem` refuses to push the gem while they do. When
|
|
292
|
+
`lib/hecks/version.rb` changes, bump the package in the same change:
|
|
293
|
+
|
|
294
|
+
```sh
|
|
295
|
+
cd packages/hecks-client
|
|
296
|
+
npm version <version> --no-git-tag-version # also updates package-lock.json
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`bin/release_gem` publishes the gem only. Publish the package from this
|
|
300
|
+
directory once the gem is out (until the first publish, the tag install above is
|
|
301
|
+
the way to consume it). npm is restricting tokens that bypass two-factor
|
|
302
|
+
authentication, so publish from an account that can answer the prompt or set up
|
|
303
|
+
trusted publishing, rather than relying on a long-lived token:
|
|
304
|
+
|
|
305
|
+
```sh
|
|
306
|
+
npm publish --access public # `prepack` builds dist/ first
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The steps as a whole are under "Releasing" in the repository's
|
|
310
|
+
`CONTRIBUTING.md`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The cookie name the account token travels in when a deploy names none. */
|
|
2
|
+
export declare const DEFAULT_ACCOUNT_COOKIE = "hecks_session";
|
|
3
|
+
/** The clock: the current time in milliseconds since the epoch, like `Date.now`. */
|
|
4
|
+
export type Clock = () => number;
|
|
5
|
+
/** What a verified token says. */
|
|
6
|
+
export interface AccountClaims {
|
|
7
|
+
/** The person's email, as the host signed it unless `normalizeEmail` was set. */
|
|
8
|
+
email: string;
|
|
9
|
+
/** When the token stops being valid, in seconds since the epoch. */
|
|
10
|
+
exp: number;
|
|
11
|
+
}
|
|
12
|
+
/** Choices for `verifyAccountToken`. */
|
|
13
|
+
export interface VerifyOptions {
|
|
14
|
+
/** The clock the expiry is judged by. Defaults to `Date.now`. */
|
|
15
|
+
now?: Clock;
|
|
16
|
+
/**
|
|
17
|
+
* Trim and lowercase the email, and refuse a token whose email is blank
|
|
18
|
+
* afterwards. For a receiver that stores addresses in that form: an address
|
|
19
|
+
* the host signed in mixed case must find the row stored for it.
|
|
20
|
+
*/
|
|
21
|
+
normalizeEmail?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Choices for `accountFromCookieHeader`. */
|
|
24
|
+
export interface CookieVerifyOptions extends VerifyOptions {
|
|
25
|
+
/** The cookie the token is read from. Defaults to `hecks_session`. */
|
|
26
|
+
cookieName?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Mints a token in the host's format, for tests and for a stand-in host.
|
|
30
|
+
* Expires `ttlSeconds` after `now` (by the injected clock).
|
|
31
|
+
*/
|
|
32
|
+
export declare function accountToken(secret: string, email: string, ttlSeconds: number, opts?: {
|
|
33
|
+
now?: Clock;
|
|
34
|
+
}): string;
|
|
35
|
+
/**
|
|
36
|
+
* Checks a token the host minted and returns who it names, or `null` when it
|
|
37
|
+
* is malformed, signed with another secret, carries no email or expiry, or
|
|
38
|
+
* has expired. Never throws for a bad token.
|
|
39
|
+
* @throws {TypeError} when `secret` is empty
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyAccountToken(token: string, secret: string, opts?: VerifyOptions): AccountClaims | null;
|
|
42
|
+
/** The value of one cookie in a `Cookie` request header, or `null` when the header does not carry it. */
|
|
43
|
+
export declare function cookieValue(header: string | null | undefined, name: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Reads the account cookie out of a `Cookie` header and verifies it: who the
|
|
46
|
+
* request is from, or `null` when the cookie is absent or does not verify.
|
|
47
|
+
*/
|
|
48
|
+
export declare function accountFromCookieHeader(header: string | null | undefined, secret: string, opts?: CookieVerifyOptions): AccountClaims | null;
|
|
49
|
+
/**
|
|
50
|
+
* The account cookie's name for a deploy that configures it (the host reads
|
|
51
|
+
* `HECKS_SESSION_COOKIE`): unset or empty means the default, anything else
|
|
52
|
+
* must be a valid cookie name because it is written into a `Set-Cookie`
|
|
53
|
+
* header.
|
|
54
|
+
* @throws {TypeError} when `configured` has characters other than letters, digits, `_`, `-` and `.`
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveAccountCookieName(configured?: string | null): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The host's account token, verified outside the host. After a sign-in the
|
|
2
|
+
// host mints a short claim of who the person is, signs it, and sets it as a
|
|
3
|
+
// cookie; `GET /accounts/sso-token` mints a shorter-lived copy as JSON for a
|
|
4
|
+
// service that wants to start its own session for the same person. This is
|
|
5
|
+
// the receiver's half: check the signature and the expiry, and read the
|
|
6
|
+
// email. It is framework independent and does no I/O, so what a service then
|
|
7
|
+
// does with the person (find or create a user, start a session) stays its own.
|
|
8
|
+
//
|
|
9
|
+
// The wire format is rust/host/src/auth.rs's `account_token`, byte for byte:
|
|
10
|
+
//
|
|
11
|
+
// token = <payload> "." <signature>
|
|
12
|
+
// payload = base64url (no padding) of the UTF-8 JSON {"email": "...", "exp": <unix seconds>}
|
|
13
|
+
// signature = lowercase hex of HMAC-SHA256(secret, <payload as sent>)
|
|
14
|
+
//
|
|
15
|
+
// The host splits at the last ".", compares the signature as a plain string,
|
|
16
|
+
// requires `exp` to be a non-negative integer, and treats the token as
|
|
17
|
+
// expired once the current second is past `exp`. This module makes the same
|
|
18
|
+
// decisions. The one deliberate difference is that the signature is compared
|
|
19
|
+
// in constant time.
|
|
20
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
21
|
+
/** The cookie name the account token travels in when a deploy names none. */
|
|
22
|
+
export const DEFAULT_ACCOUNT_COOKIE = "hecks_session";
|
|
23
|
+
const encode = (text) => Buffer.from(text, "utf8").toString("base64url");
|
|
24
|
+
const sign = (secret, payload) => createHmac("sha256", secret).update(payload).digest("hex");
|
|
25
|
+
const nowSeconds = (now) => Math.floor(now() / 1000);
|
|
26
|
+
function requireSecret(secret) {
|
|
27
|
+
// An empty key would verify tokens anyone can sign; a receiver with no
|
|
28
|
+
// secret configured is a misconfiguration, not an anonymous host.
|
|
29
|
+
if (typeof secret !== "string" || secret === "")
|
|
30
|
+
throw new TypeError("the account token secret must be a non-empty string");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Mints a token in the host's format, for tests and for a stand-in host.
|
|
34
|
+
* Expires `ttlSeconds` after `now` (by the injected clock).
|
|
35
|
+
*/
|
|
36
|
+
export function accountToken(secret, email, ttlSeconds, opts = {}) {
|
|
37
|
+
requireSecret(secret);
|
|
38
|
+
const payload = encode(JSON.stringify({ email, exp: nowSeconds(opts.now ?? Date.now) + ttlSeconds }));
|
|
39
|
+
return `${payload}.${sign(secret, payload)}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Checks a token the host minted and returns who it names, or `null` when it
|
|
43
|
+
* is malformed, signed with another secret, carries no email or expiry, or
|
|
44
|
+
* has expired. Never throws for a bad token.
|
|
45
|
+
* @throws {TypeError} when `secret` is empty
|
|
46
|
+
*/
|
|
47
|
+
export function verifyAccountToken(token, secret, opts = {}) {
|
|
48
|
+
requireSecret(secret);
|
|
49
|
+
if (typeof token !== "string")
|
|
50
|
+
return null;
|
|
51
|
+
const dot = token.lastIndexOf(".");
|
|
52
|
+
if (dot < 0)
|
|
53
|
+
return null;
|
|
54
|
+
const payload = token.slice(0, dot);
|
|
55
|
+
const signature = token.slice(dot + 1);
|
|
56
|
+
const expected = Buffer.from(sign(secret, payload));
|
|
57
|
+
const given = Buffer.from(signature);
|
|
58
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
59
|
+
return null;
|
|
60
|
+
let claims;
|
|
61
|
+
try {
|
|
62
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!claims || typeof claims !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const { exp, email } = claims;
|
|
70
|
+
if (typeof exp !== "number" || !Number.isInteger(exp) || exp < 0)
|
|
71
|
+
return null;
|
|
72
|
+
if (nowSeconds(opts.now ?? Date.now) > exp)
|
|
73
|
+
return null;
|
|
74
|
+
if (typeof email !== "string")
|
|
75
|
+
return null;
|
|
76
|
+
if (!opts.normalizeEmail)
|
|
77
|
+
return { email, exp };
|
|
78
|
+
const normalized = email.toLowerCase().trim();
|
|
79
|
+
return normalized ? { email: normalized, exp } : null;
|
|
80
|
+
}
|
|
81
|
+
/** The value of one cookie in a `Cookie` request header, or `null` when the header does not carry it. */
|
|
82
|
+
export function cookieValue(header, name) {
|
|
83
|
+
for (const part of (header ?? "").split(";")) {
|
|
84
|
+
const pair = part.trim();
|
|
85
|
+
if (pair.startsWith(`${name}=`))
|
|
86
|
+
return pair.slice(name.length + 1);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Reads the account cookie out of a `Cookie` header and verifies it: who the
|
|
92
|
+
* request is from, or `null` when the cookie is absent or does not verify.
|
|
93
|
+
*/
|
|
94
|
+
export function accountFromCookieHeader(header, secret, opts = {}) {
|
|
95
|
+
const token = cookieValue(header, opts.cookieName ?? DEFAULT_ACCOUNT_COOKIE);
|
|
96
|
+
return token ? verifyAccountToken(token, secret, opts) : null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The account cookie's name for a deploy that configures it (the host reads
|
|
100
|
+
* `HECKS_SESSION_COOKIE`): unset or empty means the default, anything else
|
|
101
|
+
* must be a valid cookie name because it is written into a `Set-Cookie`
|
|
102
|
+
* header.
|
|
103
|
+
* @throws {TypeError} when `configured` has characters other than letters, digits, `_`, `-` and `.`
|
|
104
|
+
*/
|
|
105
|
+
export function resolveAccountCookieName(configured) {
|
|
106
|
+
if (!configured)
|
|
107
|
+
return DEFAULT_ACCOUNT_COOKIE;
|
|
108
|
+
if (/^[A-Za-z0-9_.-]+$/.test(configured))
|
|
109
|
+
return configured;
|
|
110
|
+
throw new TypeError(`${JSON.stringify(configured)} is not a valid cookie name (letters, digits, '_', '-' and '.' only)`);
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=accountToken.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"accountToken.js","sourceRoot":"","sources":["../src/accountToken.ts"],"names":[],"mappings":"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"}
|
package/dist/answer.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A refusal the host reported: the failed rule's kind and the domain's own words for it. */
|
|
2
|
+
export interface Refusal {
|
|
3
|
+
kind: string;
|
|
4
|
+
error: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The body of every host answer: the state of the aggregates, keyed
|
|
8
|
+
* `Domain::Aggregate#id`, and the refusals the run reported. `error` is set
|
|
9
|
+
* when the host itself failed rather than the domain refusing.
|
|
10
|
+
*/
|
|
11
|
+
export interface Answer {
|
|
12
|
+
instances?: Record<string, Record<string, unknown>>;
|
|
13
|
+
refusals?: Refusal[];
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The states of one aggregate in an answer, as `[id, state]` pairs. The
|
|
18
|
+
* aggregate is named in full (`Domain::Aggregate`), the way it appears in the
|
|
19
|
+
* answer's keys.
|
|
20
|
+
*/
|
|
21
|
+
export declare function instancesOf(answer: Answer, aggregate: string): [string, Record<string, unknown>][];
|
|
22
|
+
/** A string value object's text, or null when it is absent or empty. */
|
|
23
|
+
export declare const text: (raw: unknown) => string | null;
|
|
24
|
+
/** A numeric value object's number under `key` ("value", "cents"), 0 when absent. */
|
|
25
|
+
export declare const whole: (raw: unknown, key: string) => number;
|
|
26
|
+
/** A numeric value object's number under `key`, or null when it is absent (an optional attribute left unset). */
|
|
27
|
+
export declare const optionalWhole: (raw: unknown, key: string) => number | null;
|
package/dist/answer.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The shapes a host answers with, and the readers that pull typed values out of
|
|
2
|
+
// them. Nothing here performs I/O, so it can be imported anywhere, including
|
|
3
|
+
// code that runs under a plain `node --test`.
|
|
4
|
+
/**
|
|
5
|
+
* The states of one aggregate in an answer, as `[id, state]` pairs. The
|
|
6
|
+
* aggregate is named in full (`Domain::Aggregate`), the way it appears in the
|
|
7
|
+
* answer's keys.
|
|
8
|
+
*/
|
|
9
|
+
export function instancesOf(answer, aggregate) {
|
|
10
|
+
const prefix = `${aggregate}#`;
|
|
11
|
+
return Object.entries(answer.instances ?? {})
|
|
12
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
13
|
+
.map(([key, state]) => [key.slice(prefix.length), state]);
|
|
14
|
+
}
|
|
15
|
+
// A value object arrives as {value: "x"} or {cents: 900}; a plain value passes
|
|
16
|
+
// through, so a wrapped and a flattened answer read the same.
|
|
17
|
+
function scalar(raw, key) {
|
|
18
|
+
if (raw && typeof raw === "object" && key in raw)
|
|
19
|
+
return raw[key];
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
/** A string value object's text, or null when it is absent or empty. */
|
|
23
|
+
export const text = (raw) => {
|
|
24
|
+
const v = scalar(raw, "value");
|
|
25
|
+
return typeof v === "string" && v !== "" ? v : null;
|
|
26
|
+
};
|
|
27
|
+
/** A numeric value object's number under `key` ("value", "cents"), 0 when absent. */
|
|
28
|
+
export const whole = (raw, key) => {
|
|
29
|
+
const v = scalar(raw, key);
|
|
30
|
+
return typeof v === "number" ? v : Number(v ?? 0);
|
|
31
|
+
};
|
|
32
|
+
/** A numeric value object's number under `key`, or null when it is absent (an optional attribute left unset). */
|
|
33
|
+
export const optionalWhole = (raw, key) => {
|
|
34
|
+
const v = scalar(raw, key);
|
|
35
|
+
return v === null || v === undefined || v === "" ? null : Number(v);
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=answer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"answer.js","sourceRoot":"","sources":["../src/answer.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,6EAA6E;AAC7E,8CAA8C;AAmB9C;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,MAAc,EAAE,SAAiB;IAC3D,MAAM,MAAM,GAAG,GAAG,SAAS,GAAG,CAAC;IAC/B,OAAO,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;SACzC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED,+EAA+E;AAC/E,8DAA8D;AAC9D,SAAS,MAAM,CAAC,GAAY,EAAE,GAAW;IACvC,IAAI,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,IAAI,GAAG;QAAE,OAAQ,GAA+B,CAAC,GAAG,CAAC,CAAC;IAC/F,OAAO,GAAG,CAAC;AACb,CAAC;AAED,wEAAwE;AACxE,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,GAAY,EAAiB,EAAE;IAClD,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACtD,CAAC,CAAC;AAEF,qFAAqF;AACrF,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,GAAY,EAAE,GAAW,EAAU,EAAE;IACzD,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC3B,OAAO,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AACpD,CAAC,CAAC;AAEF,iHAAiH;AACjH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,GAAY,EAAE,GAAW,EAAiB,EAAE;IACxE,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC3B,OAAO,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACtE,CAAC,CAAC"}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Answer } from "./answer.js";
|
|
2
|
+
/** What a `HostClient` needs to reach one domain. */
|
|
3
|
+
export interface ClientOptions {
|
|
4
|
+
/** The domain's name as its bluebook declares it ("Shop"). Falls back to the `HECKS_DOMAIN` environment variable. */
|
|
5
|
+
domain?: string;
|
|
6
|
+
/** Where the host listens ("http://127.0.0.1:4322"). Falls back to the `HECKS_SERVICE_URL` environment variable. */
|
|
7
|
+
url?: string;
|
|
8
|
+
/**
|
|
9
|
+
* The role sent with every command unless a call names its own. The host
|
|
10
|
+
* compares it as a plain string against the roles a command declares; when
|
|
11
|
+
* neither this nor the call sets one, no role is sent and the host performs
|
|
12
|
+
* no role check.
|
|
13
|
+
*/
|
|
14
|
+
role?: string;
|
|
15
|
+
/** How long one request may take before it counts as unreachable. Defaults to 8000. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** The fetch to send requests with. Defaults to the global `fetch`, looked up on each call. */
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One command whose outcome is judged from the state that comes back.
|
|
22
|
+
* `parse` turns the answer into the caller's own shape and `confirm` says
|
|
23
|
+
* whether that shape shows the command took effect.
|
|
24
|
+
*/
|
|
25
|
+
export interface Command<T> {
|
|
26
|
+
/** The verb, `Aggregate.Verb` or fully qualified (`Domain::Aggregate.Verb`). */
|
|
27
|
+
verb: string;
|
|
28
|
+
with: Record<string, unknown>;
|
|
29
|
+
/** The target instance id; absent for a command that creates one. */
|
|
30
|
+
to?: string;
|
|
31
|
+
role?: string;
|
|
32
|
+
parse: (answer: Answer) => T;
|
|
33
|
+
confirm: (parsed: T) => boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Talks to one domain on one host. */
|
|
36
|
+
export declare class HostClient {
|
|
37
|
+
/** The domain's name, as configured. */
|
|
38
|
+
readonly domain: string;
|
|
39
|
+
/** The host's base URL, without a trailing slash. */
|
|
40
|
+
readonly url: string;
|
|
41
|
+
private readonly role;
|
|
42
|
+
private readonly timeoutMs;
|
|
43
|
+
private readonly fetchImpl;
|
|
44
|
+
constructor(options?: ClientOptions);
|
|
45
|
+
/** A name in full: one that already carries `::` is left alone, any other is placed in this client's domain. */
|
|
46
|
+
qualify(name: string): string;
|
|
47
|
+
/** The states of one aggregate in an answer, as `[id, state]` pairs. `aggregate` may be bare ("Event") or qualified. */
|
|
48
|
+
instancesOf(answer: Answer, aggregate: string): [string, Record<string, unknown>][];
|
|
49
|
+
/** Everything the domain holds, as the protocol answers it. */
|
|
50
|
+
read(): Promise<Answer>;
|
|
51
|
+
/**
|
|
52
|
+
* Sends one command and returns the raw answer. Callers judge the outcome
|
|
53
|
+
* from the state in it and turn a miss into a refusal with `refusalOf`.
|
|
54
|
+
*/
|
|
55
|
+
dispatch(verb: string, args?: Record<string, unknown>, to?: string, role?: string): Promise<Answer>;
|
|
56
|
+
/**
|
|
57
|
+
* Runs one command and returns what `command.parse` reads from the answer.
|
|
58
|
+
* Throws `DomainRefusal` when that state does not show the change,
|
|
59
|
+
* carrying the domain's own words for why.
|
|
60
|
+
*/
|
|
61
|
+
apply<T>(command: Command<T>): Promise<T>;
|
|
62
|
+
private post;
|
|
63
|
+
}
|
|
64
|
+
/** Shorthand for `new HostClient(options)`. */
|
|
65
|
+
export declare const createClient: (options?: ClientOptions) => HostClient;
|