@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 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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"}
@@ -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"}
@@ -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;