@stewardhq/sdk 0.5.0 → 0.6.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 +201 -0
- package/README.md +229 -200
- package/dist/_chunks/events.d.ts +133 -122
- package/dist/_chunks/events.js +45 -7
- package/dist/_chunks/index.d.ts +970 -589
- package/dist/_chunks/locale.d.ts +3 -3
- package/dist/_chunks/src.js +130 -18
- package/dist/_chunks/validators.d.ts +2 -2
- package/dist/_chunks/webhook-core.d.ts +2 -2
- package/dist/contract.d.ts +2 -2
- package/dist/contract.js +3 -3
- package/dist/index.d.ts +24 -24
- package/dist/index.js +4 -4
- package/dist/server.d.ts +32 -20
- package/dist/server.js +7 -3
- package/package.json +14 -8
package/README.md
CHANGED
|
@@ -1,188 +1,195 @@
|
|
|
1
1
|
# @stewardhq/sdk
|
|
2
2
|
|
|
3
|
-
Steward ↔
|
|
4
|
-
|
|
3
|
+
Steward ↔ product integration: contract schemas (zod), a typed API client and webhook signing.
|
|
4
|
+
Source: [z9cloud/steward](https://github.com/z9cloud/steward/tree/main/sdk).
|
|
5
5
|
|
|
6
|
-
##
|
|
6
|
+
## Installation
|
|
7
7
|
|
|
8
|
-
npmjs
|
|
8
|
+
Public on npmjs; no token needed.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
pnpm add @stewardhq/sdk
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
ESM, Node ≥ 22.
|
|
14
|
+
ESM, Node ≥ 22. Its only runtime dependency is zod.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Subpaths
|
|
17
17
|
|
|
18
|
-
| Import |
|
|
18
|
+
| Import | Contents |
|
|
19
19
|
| --- | --- |
|
|
20
|
-
| `@stewardhq/sdk` |
|
|
21
|
-
| `@stewardhq/sdk/contract` |
|
|
22
|
-
| `@stewardhq/sdk/webhook` | `signWebhook` / `verifyWebhook` / `verifyWebhookRaw` (Node HMAC,
|
|
23
|
-
| `@stewardhq/sdk/webhook/web` |
|
|
24
|
-
| `@stewardhq/sdk/server` | `Webhooks()`:
|
|
20
|
+
| `@stewardhq/sdk` | All schemas and types + the throwing client `createSteward`, the account state cache `stateCache`, `defineCatalog`, the error classes + `httpStatusFor`, `isValidTckn`/`isValidVkn`/`isE164`; metering: `Events()`, `Meters()` |
|
|
21
|
+
| `@stewardhq/sdk/contract` | Schemas and types only |
|
|
22
|
+
| `@stewardhq/sdk/webhook` | `signWebhook` / `verifyWebhook` / `verifyWebhookRaw` (Node HMAC, synchronous) |
|
|
23
|
+
| `@stewardhq/sdk/webhook/web` | The same API on WebCrypto (async; edge/workerd/browser) |
|
|
24
|
+
| `@stewardhq/sdk/server` | `Webhooks()`: a signed event receiver, live state + cache, typed hooks (Node); `Checkout()`: a hosted checkout session (`create` → `url`) and its return result (`result`); `CustomerPortal()`: a hosted customer portal session (`create` → `url`) |
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
`./webhook`
|
|
26
|
+
The root entry, `./contract` and `./webhook/web` do not use `node:*` (edge/browser);
|
|
27
|
+
`./webhook` is the synchronous Node HMAC.
|
|
28
28
|
|
|
29
|
-
##
|
|
29
|
+
## Usage
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
Account state (the read model) — no entitlement projection is kept in the product database;
|
|
32
|
+
gates read steward live through a per-pod cache:
|
|
33
33
|
|
|
34
34
|
```ts
|
|
35
35
|
import { createSteward, stateCache } from "@stewardhq/sdk";
|
|
36
36
|
|
|
37
37
|
export const steward = createSteward();
|
|
38
38
|
export const state = stateCache(steward, {
|
|
39
|
-
catalog, // entitlements()
|
|
40
|
-
// steward
|
|
39
|
+
catalog, // makes entitlements() typed
|
|
40
|
+
// used when steward is down and there is no stale value (AccountState | null); catalog.stateOf: full state from the catalog (version 0)
|
|
41
41
|
fallback: async (ref) => {
|
|
42
42
|
const org = await findOrg(ref);
|
|
43
43
|
return org ? catalog.stateOf(ref, org.plan, { displayName: org.name }) : null;
|
|
44
44
|
},
|
|
45
45
|
onFallback: ({ ref, reason }) => metrics.billingFallback.inc({ reason }),
|
|
46
|
-
// missing: "fallback" → steward
|
|
46
|
+
// missing: "fallback" → a record with no account in steward also goes to the fallback (if the account is only created at the first checkout)
|
|
47
47
|
// ttlMs: 30_000, staleIfErrorMs: 600_000, maxEntries: 10_000, logger
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
const e = await state.entitlements(orgId); // { max_projects: number | null; sso: boolean }
|
|
51
|
-
await state.state(orgId, { fresh: true }); // TTL
|
|
52
|
-
state.prime((await steward.grants.create(orgId, input)).account); //
|
|
53
|
-
const byRef = await state.many(orgIds); // Map<ref, AccountState>,
|
|
51
|
+
await state.state(orgId, { fresh: true }); // without waiting for the TTL
|
|
52
|
+
state.prime((await steward.grants.create(orgId, input)).account); // a mutation response into the cache
|
|
53
|
+
const byRef = await state.many(orgIds); // Map<ref, AccountState>, at most 8 in parallel
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
|
56
|
+
| Situation | Behaviour |
|
|
57
57
|
| --- | --- |
|
|
58
|
-
| TTL
|
|
59
|
-
| TTL
|
|
60
|
-
|
|
|
61
|
-
| 404 `account_not_found` (
|
|
62
|
-
| 404 `account_not_found`, `missing: "fallback"` | `fallback(ref)` (
|
|
63
|
-
| `prime(account)` |
|
|
58
|
+
| Within the TTL | From memory, no request; concurrent reads of the same account share one request |
|
|
59
|
+
| TTL expired | `If-None-Match`: a 304 renews the window, a 200 stores the new state |
|
|
60
|
+
| Network error, timeout, 5xx, 429 | After expiry, the stale value for `staleIfErrorMs` (WARN), then `fallback` (WARN + `onFallback`), and if there is none (or it returns `null`) the original `StewardNetworkError`/`StewardApiError`. After a failed request, steward is not called again for that account for `min(ttlMs, 5 s)` |
|
|
61
|
+
| 404 `account_not_found` (default `missing: "throw"`) | Not an outage: no fallback, nothing cached, `StewardApiError` is thrown (a missing `upsert` stays visible) |
|
|
62
|
+
| 404 `account_not_found`, `missing: "fallback"` | `fallback(ref)` (a required option); no WARN, and `onFallback`'s reason is `account_not_found` (kept apart from the outage metric); the stale value is not used. The result is not cached, and the "absent" fact is held for `min(ttlMs, 5 s)`; `prime`/`invalidate`/`fresh` end it (a checkout result and a webhook both `prime`). A `null` fallback → the 404 is thrown |
|
|
63
|
+
| `prime(account)` | Writes only when `version >= the cached one` (never goes backwards); `null`/`undefined` is ignored |
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
`invalidate(ref)`
|
|
65
|
+
The cache is in-memory per pod (LRU); across many pods, consistency is bounded by the TTL.
|
|
66
|
+
`invalidate(ref)` makes the next read ask immediately (with the ETag).
|
|
67
67
|
|
|
68
|
-
Webhook
|
|
69
|
-
|
|
68
|
+
Webhook receiver (`/server`, Node) — the signature is checked on the raw body, then the
|
|
69
|
+
contract; state is read live from steward and written into the cache; the hooks are typed:
|
|
70
70
|
|
|
71
71
|
```ts
|
|
72
72
|
import { Webhooks } from "@stewardhq/sdk/server";
|
|
73
73
|
|
|
74
74
|
const webhooks = Webhooks({
|
|
75
|
-
steward, cache: state, // refetch (steward
|
|
76
|
-
// secrets
|
|
75
|
+
steward, cache: state, // refetch (the default once steward is given): the payload is only a trigger
|
|
76
|
+
// without secrets, STEWARD_WEBHOOK_SECRET is read on every delivery ("new,old" rotation); toleranceSeconds: 300
|
|
77
77
|
onStateChanged: async ({ ref, state, cause }) => {
|
|
78
78
|
const org = await findOrg(ref);
|
|
79
|
-
if (!org) return "ignore"; //
|
|
79
|
+
if (!org) return "ignore"; // unknown account: 200, and neither the other hooks nor after run
|
|
80
80
|
await applyOrgPlan(org, state.planCode, state.accessState);
|
|
81
81
|
},
|
|
82
|
-
onSubscriptionTerminated: ({ ref }) => flagOps(ref), // on<
|
|
82
|
+
onSubscriptionTerminated: ({ ref }) => flagOps(ref), // on<Type>: from the old type or from state_changed's cause
|
|
83
83
|
onPaymentFailed: ({ ref, state, profile }) => mailOwner(ref, profile), // + onSuspended, onTerminated
|
|
84
|
-
after: ({ ref, state }) => propagate(ref, state.accessState), //
|
|
85
|
-
// includeProfile: true →
|
|
84
|
+
after: ({ ref, state }) => propagate(ref, state.accessState), // after the other hooks, on every delivery
|
|
85
|
+
// includeProfile: true → the notification hooks get the billing profile (email); refetch: false → the state from the body
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
app.post("/v1/billing/events", (c) => webhooks.handle(c.req.raw)); //
|
|
88
|
+
app.post("/v1/billing/events", (c) => webhooks.handle(c.req.raw)); // or: const { status, body } = await webhooks.receive(rawBody, headers)
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
|
|
|
91
|
+
| Situation | Response |
|
|
92
92
|
| --- | --- |
|
|
93
|
-
|
|
|
94
|
-
| `onStateChanged` `"ignore"`
|
|
95
|
-
|
|
|
96
|
-
| `endpoint.ping` | 200 `ping`,
|
|
97
|
-
| `refetch: false`
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`onPaymentFailed`…, `onUnknownEvent`, `after`)
|
|
105
|
-
|
|
106
|
-
`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
93
|
+
| The hooks ran | 200 `{ok: true, handled: "received"}` |
|
|
94
|
+
| `onStateChanged` returned `"ignore"` | 200 `ignored` + WARN; the cause hooks and `after` do not run |
|
|
95
|
+
| Account deleted: `account.deleted` or `cause: "account.deleted"` | 200 `deleted`; no live read, and `onAccountDeleted` + `after` run with the anonymised final state from the body (`onStateChanged` does not run; `cache.invalidate` is called if present). If a delivery queued before the deletion gets `account_deleted` (410) on its live read, it returns 200 `deleted` + WARN with no hooks |
|
|
96
|
+
| `endpoint.ping` | 200 `ping`, no hooks |
|
|
97
|
+
| `refetch: false` and the body carries only a snapshot (a queue from before the upgrade) | 200 `no_state` + WARN: `onStateChanged` does not run, and the cause hooks and `after` run with `state: null` |
|
|
98
|
+
| Unrecognised type | 200; `onUnknownEvent` (preceded by `onStateChanged` if there is state) |
|
|
99
|
+
| Signature / header / time window | 401 |
|
|
100
|
+
| Signed but not contract-conformant body, or an id mismatch | 400 + ALARM (steward retries) |
|
|
101
|
+
| No secret, current state unreadable (`state_unavailable`), profile unreadable, or a hook threw | 503 (steward retries, and every hook runs again) |
|
|
102
|
+
|
|
103
|
+
Types: when `steward` is given and `refetch` is not `false`, the `state` of every hook
|
|
104
|
+
(`onStateChanged`, `on<Type>`, `onPaymentFailed`…, `onUnknownEvent`, `after`) is an
|
|
105
|
+
`AccountState` — if the state cannot be read, 503 is returned and no hook runs; `after`'s
|
|
106
|
+
`handled` is `"received"`. With `refetch: false`, or in a receiver without `steward`, the cause
|
|
107
|
+
hooks, `onUnknownEvent` and `after` get `AccountState | null` (a queue from before the upgrade;
|
|
108
|
+
`onStateChanged` still runs with full state). The distinction is inferred from the types of the
|
|
109
|
+
`refetch` and `steward` fields (when the type is ambiguous, the nullable one).
|
|
110
|
+
|
|
111
|
+
**The SDK does not deduplicate, and holds no version gate or lock.** With `refetch`, every
|
|
112
|
+
delivery reads the state live from steward: an out-of-order or repeated delivery sees the most
|
|
113
|
+
current state, and even an old body carrying only a snapshot is fully usable
|
|
114
|
+
(`event.data.account` may be stale; use `state`). The same event can still arrive several
|
|
115
|
+
times, and deliveries for the same account can arrive concurrently: hooks must write
|
|
116
|
+
idempotently (write the plan field from the state, "if already suspended, don't touch",
|
|
117
|
+
`coalesce` the termination flag). To have a delivery retried, throw from a hook → 503.
|
|
118
|
+
`refetch: false` uses the state in the body (no ordering guarantee; wait for the old queue to
|
|
119
|
+
drain). An endpoint that asks for both the old type and `account.state_changed` for the same
|
|
120
|
+
occurrence runs the cause hooks twice: pick one in the endpoint filter.
|
|
121
|
+
|
|
122
|
+
Hosted checkout (`/server`) — the profile, tax identity, consents and the payment form all live
|
|
123
|
+
on steward's page; the product only opens the session, redirects the user to `url`, and asks
|
|
124
|
+
for the result on return:
|
|
121
125
|
|
|
122
126
|
```ts
|
|
123
127
|
import { Checkout } from "@stewardhq/sdk/server";
|
|
124
128
|
|
|
125
129
|
const checkout = Checkout({
|
|
126
130
|
steward, cache: state,
|
|
127
|
-
//
|
|
128
|
-
// steward
|
|
131
|
+
// absolute; the origin must be allowed in steward's settings (appUrl + extraReturnOrigins). If
|
|
132
|
+
// placeholders are present steward fills only those; otherwise the address is used as-is (no parameters added).
|
|
129
133
|
successUrl: `${process.env.APP_URL}/{LOCALE}/app/orgs/{ACCOUNT_REF}/billing?checkout={CHECKOUT_ID}`,
|
|
130
|
-
consents: (locale) => legalDocs(locale), // [{document, version, url}]
|
|
131
|
-
// cancelUrl (
|
|
134
|
+
consents: (locale) => legalDocs(locale), // [{document, version, url}] or an array; consented to on the page
|
|
135
|
+
// cancelUrl (defaults to the appUrl setting), locale and currency (default to the product setting)
|
|
132
136
|
});
|
|
133
137
|
|
|
134
138
|
app.post("/v1/orgs/:orgId/billing/checkout", async (c) => {
|
|
135
|
-
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); //
|
|
139
|
+
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); // authorization BEFORE the call is the product's job
|
|
136
140
|
const { url } = await checkout.create({ ref: c.req.param("orgId"), planCode: "team", interval: "month",
|
|
137
141
|
locale: c.get("locale"), actorRef: `user:${c.get("user").id}` }); // displayName?, prefill?: {email, name, kind}
|
|
138
|
-
return c.json({ url }); //
|
|
142
|
+
return c.json({ url }); // the browser 303s to url
|
|
139
143
|
});
|
|
140
144
|
|
|
141
|
-
app.get("/v1/orgs/:orgId/billing/checkout/:id", async (c) => //
|
|
145
|
+
app.get("/v1/orgs/:orgId/billing/checkout/:id", async (c) => // the return page ?checkout=<id>
|
|
142
146
|
c.json(await checkout.result(c.req.param("id"), { ref: c.req.param("orgId") })));
|
|
143
147
|
// { status: "open" | "completed" | "failed" | "expired" | "canceled", planCode?, account? }
|
|
144
148
|
```
|
|
145
149
|
|
|
146
|
-
|
|
|
150
|
+
| Situation | Behaviour |
|
|
147
151
|
| --- | --- |
|
|
148
|
-
| `create` | `POST …/checkout-sessions` hosted
|
|
149
|
-
| `result`,
|
|
150
|
-
| `result`, `completed` |
|
|
151
|
-
| `locale` | `CustomerPortal
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
152
|
+
| `create` | `POST …/checkout-sessions` with the hosted body (no profile or identity); no call to the provider; `{id, url, expiresAt}`. If the account does not exist it is opened with `displayName`, and without that, `account_not_found`. Steward's errors pass through (`already_subscribed` 409, `plan_not_sellable` 422, `return_url_not_allowed` → `httpStatusFor` 500 + ALARM) |
|
|
153
|
+
| `result`, session belongs to another account / invalid id / no session | The same error: `StewardApiError` `checkout_session_not_found` (404) — `?checkout=` is user input, so ownership is not leaked |
|
|
154
|
+
| `result`, `completed` | The `account` in the response is `cache.prime`d (the return page shows the new plan without waiting for the webhook; the webhook is still the source of truth) |
|
|
155
|
+
| `locale` | Same rule as `CustomerPortal`: the primary subtag of a BCP-47-like tag (`tr-TR` → `tr`, `en-US` → `en`). An unsupported language in the call is ignored: the one from the options is used, and failing that nothing is sent (steward's `defaultLocale`); the `consents` function is called with that language |
|
|
156
|
+
| A relative or non-http(s) `successUrl`, `cancelUrl` or document address; an unsupported `locale` in the options | `StewardConfigError` at construction |
|
|
157
|
+
| No `url` in the response (a steward from before F2b) | `StewardContractError` |
|
|
158
|
+
|
|
159
|
+
If the user closes the page, steward closes the session once it expires
|
|
160
|
+
(`checkoutTtlMinutes`), and the product learns the result from `account.state_changed`.
|
|
161
|
+
`steward.checkoutSessions.create` also only accepts the hosted body; the billing profile is
|
|
162
|
+
written only on the hosted checkout and portal pages.
|
|
163
|
+
|
|
164
|
+
Hosted customer portal (`/server`) — subscription status, cancel at period end, retry payment,
|
|
165
|
+
the invoice list/PDF and the corporate billing profile all live on steward's page; the product
|
|
166
|
+
only checks authorization, opens the session and redirects. There is no cancel, retry or
|
|
167
|
+
invoice endpoint on the product side: the results arrive at `Webhooks()` as
|
|
168
|
+
`account.state_changed`:
|
|
163
169
|
|
|
164
170
|
```ts
|
|
165
171
|
import { CustomerPortal } from "@stewardhq/sdk/server";
|
|
166
172
|
|
|
167
|
-
const portal = CustomerPortal({ steward, returnUrl: `${process.env.APP_URL}/billing` }); // returnUrl
|
|
173
|
+
const portal = CustomerPortal({ steward, returnUrl: `${process.env.APP_URL}/billing` }); // without returnUrl, the appUrl setting
|
|
168
174
|
|
|
169
175
|
app.post("/v1/orgs/:orgId/billing/portal", async (c) => {
|
|
170
|
-
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); //
|
|
176
|
+
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); // authorization BEFORE the call is the product's job
|
|
171
177
|
const { url } = await portal.create({ ref: c.req.param("orgId"), locale: c.get("locale"), actorRef: `user:${c.get("user").id}` });
|
|
172
|
-
return c.json({ url }); //
|
|
178
|
+
return c.json({ url }); // the browser 303s to url
|
|
173
179
|
});
|
|
174
180
|
```
|
|
175
181
|
|
|
176
|
-
|
|
|
182
|
+
| Situation | Behaviour |
|
|
177
183
|
| --- | --- |
|
|
178
|
-
| `create` | `POST …/portal-sessions {locale?, returnUrl?}` → `{id, url, expiresAt}`;
|
|
179
|
-
|
|
|
180
|
-
| `returnUrl` origin
|
|
181
|
-
| `locale` | `tr`/`en` (
|
|
182
|
-
| `actorRef
|
|
183
|
-
| Portal
|
|
184
|
+
| `create` | `POST …/portal-sessions {locale?, returnUrl?}` → `{id, url, expiresAt}`; state does not change and no event is emitted. The lifetime is the product setting `portalTtlMinutes` (30 minutes by default); an expired link, or one with the wrong key, gives 404 |
|
|
185
|
+
| No account | `StewardApiError` `account_not_found` (404) — unlike checkout, no account is opened |
|
|
186
|
+
| The `returnUrl` origin is not allowed | `return_url_not_allowed` (422; `httpStatusFor` 500 + ALARM) |
|
|
187
|
+
| `locale` | `tr`/`en` (e.g. `tr-TR` → `tr`; the same rule as `Checkout`); any other language is not sent and steward uses its `defaultLocale` |
|
|
188
|
+
| Missing or malformed `actorRef`, or a relative / non-http(s) `returnUrl` | `StewardConfigError` (no request is sent) |
|
|
189
|
+
| Portal actions | The actor in steward's audit trail is `customer:<ref>`; a cancellation is `subscription.cancel_scheduled` (reason `customer_portal`), a profile change is `billing_profile.updated`, and a retry does not change state (the collection result is `subscription.reactivated`) |
|
|
184
190
|
|
|
185
|
-
|
|
191
|
+
At the low level the signature is verified over the raw body and only then parsed with the
|
|
192
|
+
contract:
|
|
186
193
|
|
|
187
194
|
```ts
|
|
188
195
|
import { verifyWebhook } from "@stewardhq/sdk/webhook";
|
|
@@ -190,93 +197,100 @@ import { verifyWebhook } from "@stewardhq/sdk/webhook";
|
|
|
190
197
|
const rawBody = await request.text();
|
|
191
198
|
const result = verifyWebhook({ secrets: [process.env.STEWARD_WEBHOOK_SECRET!], headers: request.headers, rawBody });
|
|
192
199
|
if (!result.ok) return new Response(null, { status: 400 });
|
|
193
|
-
// result.event: BillingEvent —
|
|
200
|
+
// result.event: BillingEvent — deduplicate on `id`, and do not apply an older `version`.
|
|
194
201
|
```
|
|
195
202
|
|
|
196
|
-
API
|
|
197
|
-
(`STEWARD_URL`, `STEWARD_API_KEY`; edge
|
|
198
|
-
`StewardConfigError`
|
|
203
|
+
API client — `createSteward()` reads its settings from the options and otherwise from the
|
|
204
|
+
environment (`STEWARD_URL`, `STEWARD_API_KEY`; on the edge, pass `env`), and throws
|
|
205
|
+
`StewardConfigError` **at construction** if anything is missing (except for the admin-only
|
|
206
|
+
client, below). A failed call throws:
|
|
199
207
|
|
|
200
208
|
```ts
|
|
201
209
|
import { createSteward } from "@stewardhq/sdk";
|
|
202
210
|
|
|
203
|
-
export const steward = createSteward({ actorRef: "system" }); //
|
|
211
|
+
export const steward = createSteward({ actorRef: "system" }); // or createSteward({ env, fetch, timeoutMs: 10_000 })
|
|
204
212
|
|
|
205
213
|
const state = await steward.accounts.state(accountRef); // AccountState
|
|
206
|
-
await steward.grants.create(accountRef, { planCode: "team", reason: "
|
|
214
|
+
await steward.grants.create(accountRef, { planCode: "team", reason: "enterprise agreement" }, { actorRef: `staff:${staffId}` });
|
|
207
215
|
```
|
|
208
216
|
|
|
209
|
-
|
|
|
217
|
+
| Resource | Methods |
|
|
210
218
|
| --- | --- |
|
|
211
|
-
| `accounts` | `upsert(ref, input)`, `get(ref, {include?})`, `state(ref)`, `readState(ref, {etag?})` (304 → `{notModified: true}`), `setPlan(ref, {planCode \| null, reason, mode?, endsAt?, overrides?})` → `{grant, account, warnings}` (
|
|
219
|
+
| `accounts` | `upsert(ref, input)`, `get(ref, {include?})`, `state(ref)`, `readState(ref, {etag?})` (304 → `{notModified: true}`), `setPlan(ref, {planCode \| null, reason, mode?, endsAt?, overrides?})` → `{grant, account, warnings}` (entitlements are a union of layers — there is no `rank`; M35: `mode` is accepted and **ignored**, and `warnings` is always empty), `resync(ref)` → `{queued, eventId}` (the stored state as `account.state_changed`, `cause: "resync"`), `delete(ref)` → `{ref, deletedAt}` (KVKK anonymisation; invoices remain; any live subscription must be cancelled `immediate` first — otherwise `subscription_active`, or `checkout_in_progress` while a checkout form is open; afterwards every endpoint for the account returns `account_deleted` 410) |
|
|
212
220
|
| `checkoutSessions` | `create(ref, input)`, `get(id)` |
|
|
213
|
-
| `portalSessions` | `create(ref, {locale?, returnUrl?})` → `{id, url, expiresAt}` (hosted
|
|
221
|
+
| `portalSessions` | `create(ref, {locale?, returnUrl?})` → `{id, url, expiresAt}` (the hosted customer portal; `CustomerPortal()` wraps this) |
|
|
214
222
|
| `subscriptions` | `cancel(id, input)` |
|
|
215
223
|
| `grants` | `create(ref, input)`, `revoke(id)` |
|
|
216
|
-
| `invoices` | `issue(id, input)`, `document(id)` → `{contentType, bytes}`, `createManual(ref, {lines, currency, taxRateBps, taxInclusive?, dueAt?, note?})` (
|
|
224
|
+
| `invoices` | `issue(id, input)`, `document(id)` → `{contentType, bytes}`, `createManual(ref, {lines, currency, taxRateBps, taxInclusive?, dueAt?, note?})` (a manual draft; needs a profile, and is issued with `issue`), `void(id, {reason})` (manual invoices only; `invoice.voided` goes only to an endpoint that asks for it in its filter) |
|
|
217
225
|
| `catalog` | `get()`, `read({etag?})` |
|
|
218
|
-
| — | `stats()`, `me()`, `doctor()` →
|
|
219
|
-
| `admin` | `catalog.sync(input)`, `accounts.refresh()`, `settings.{get(), update(patch)}`, `endpoints.{list(), create(input), rotate(id, {secret}), deactivate(id)}` — `STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (
|
|
220
|
-
|
|
221
|
-
- **
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
(`code`
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
226
|
+
| — | `stats()`, `me()`, `doctor()` → setup diagnostics (`GET /v1/doctor`; below) |
|
|
227
|
+
| `admin` | `catalog.sync(input)`, `accounts.refresh()`, `settings.{get(), update(patch)}`, `endpoints.{list(), create(input), rotate(id, {secret}), deactivate(id)}` — requires `STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (or `adminBaseUrl`/`adminApiKey`); without them, `StewardConfigError` at the call |
|
|
228
|
+
|
|
229
|
+
- **Mutations** take `{ idempotencyKey?, actorRef? }` as their last argument. Without a key,
|
|
230
|
+
one is generated per call; without `actorRef`, the default from `createSteward` is used. If
|
|
231
|
+
neither exists, or the format is invalid (`type[:id]`), the request is **not sent**:
|
|
232
|
+
`StewardConfigError` (with the same `code` as the service's: `actor_ref_required` /
|
|
233
|
+
`invalid_actor_ref`).
|
|
234
|
+
- **Admin only:** when neither `STEWARD_URL` nor `STEWARD_API_KEY` is set but
|
|
235
|
+
`STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (or `adminBaseUrl`/`adminApiKey`) are, the
|
|
236
|
+
client is constructed (for the CLI and catalog sync): `admin.*` works, `doctor()` and `me()`
|
|
237
|
+
— open to both scopes — go out with the admin key, and the product endpoints and `raw.*`
|
|
238
|
+
reject at the call with `StewardConfigError` (no request is sent). If only half of the
|
|
239
|
+
product setting is given, it still fails at construction.
|
|
240
|
+
- **Errors:** an error with a response is `StewardApiError` (`code`, `httpStatus`, `category`,
|
|
241
|
+
`retryable`, `requestId`, `retryAfterMs`), no response is `StewardNetworkError`
|
|
242
|
+
(`network_error`/`timeout`), and a response that does not match the schema is
|
|
243
|
+
`StewardContractError`.
|
|
244
|
+
- **Retries** (`retries: { attempts: 2, on: ["network", "5xx", "429"], maxDelayMs: 5000 }`): on
|
|
245
|
+
a network error/timeout, a 5xx or a 429, GETs and mutations carrying an `Idempotency-Key` are
|
|
246
|
+
retried at most `attempts` more times (by default at most 3 requests), with the same key on
|
|
247
|
+
every attempt. The wait is `Retry-After` when given, otherwise exponential (250 ms,
|
|
248
|
+
500 ms…) + jitter; both are capped by `maxDelayMs`. 4xx other than 429, and responses with
|
|
249
|
+
`retryable: false` (`provider_error`, `provider_not_configured`), are not retried.
|
|
250
|
+
`timeoutMs` is per attempt.
|
|
251
|
+
- Requests carry `Steward-Api-Version` and `X-Steward-Sdk: @stewardhq/sdk/<version>`
|
|
252
|
+
(observability only).
|
|
253
|
+
- Web standards only (`fetch`, `crypto.randomUUID`, `AbortSignal.timeout`): the same code on
|
|
254
|
+
Node 22, on the edge and on workerd. The root entry and `./webhook/web` are run on workerd
|
|
255
|
+
with `nodejs_compat` disabled on every pack check (`scripts/check-workerd.mjs`).
|
|
256
|
+
|
|
257
|
+
Administration (admin key; in-cluster, port-forward) — product settings and webhook endpoints.
|
|
258
|
+
**The caller generates** the webhook secret and writes it into their own Secret; steward never
|
|
259
|
+
returns the secret in any response:
|
|
247
260
|
|
|
248
261
|
```ts
|
|
249
262
|
import { generateWebhookSecret } from "@stewardhq/sdk";
|
|
250
263
|
|
|
251
264
|
await steward.admin.settings.update({
|
|
252
|
-
appUrl: "https://app.example.com", // origin
|
|
265
|
+
appUrl: "https://app.example.com", // its origin is allowed for the checkout return; + extraReturnOrigins
|
|
253
266
|
privacyUrl: "https://app.example.com/kvkk",
|
|
254
|
-
branding: { name: "Acme", accentColor: "#0f766e" }, //
|
|
267
|
+
branding: { name: "Acme", accentColor: "#0f766e" }, // shallow merge; null clears a field
|
|
255
268
|
checkoutTtlMinutes: 30,
|
|
256
269
|
});
|
|
257
270
|
|
|
258
|
-
const secret = generateWebhookSecret(); // whsec_ + 32
|
|
271
|
+
const secret = generateWebhookSecret(); // whsec_ + 32 random bytes
|
|
259
272
|
const endpoint = await steward.admin.endpoints.create({ url: "http://api.acme.svc.cluster.local/billing/events", secret });
|
|
260
|
-
// eventTypes
|
|
261
|
-
await steward.admin.endpoints.rotate(endpoint.id, { secret: generateWebhookSecret() }); //
|
|
273
|
+
// without eventTypes, ["account.state_changed"]; the address must be https:// or http://*.svc.cluster.local
|
|
274
|
+
await steward.admin.endpoints.rotate(endpoint.id, { secret: generateWebhookSecret() }); // the old secret stays as a second signature during the transition
|
|
262
275
|
```
|
|
263
276
|
|
|
264
|
-
|
|
277
|
+
Setup diagnostics — the product key is enough; expect `ok: true` before the first checkout:
|
|
265
278
|
|
|
266
279
|
```ts
|
|
267
280
|
const report = await steward.doctor();
|
|
268
|
-
// report.ok: `error`
|
|
281
|
+
// report.ok: no warning at `error` severity
|
|
269
282
|
// report.warnings: [{ code: "endpoint_wrong_secret", severity: "error", message, endpointId }, ...]
|
|
270
|
-
// report.endpoints[].diagnosis: ok | wrong_secret (
|
|
283
|
+
// report.endpoints[].diagnosis: ok | wrong_secret (last delivery 401) | old_contract (400) | unreachable | failing | no_deliveries
|
|
271
284
|
// report.catalog, report.provider (environment: sandbox | live | fake), report.hosted.ready, report.dunning.dryRun
|
|
272
285
|
```
|
|
273
286
|
|
|
274
|
-
|
|
275
|
-
`lastError`
|
|
287
|
+
The codes and diagnoses are an open set (display an unknown one, nothing more).
|
|
288
|
+
`provider.healthy` and `lastError` are the observation of the steward pod that answered.
|
|
276
289
|
|
|
277
|
-
|
|
278
|
-
`null` =
|
|
279
|
-
|
|
290
|
+
Catalog — a typed definition in code; a missing or extra entitlement, a wrong type (`limit` →
|
|
291
|
+
an integer or `null` = unlimited, `flag` → boolean, `text` → string) and a price for an
|
|
292
|
+
undefined plan are compile errors, and if the types are bypassed the definition throws
|
|
293
|
+
`StewardConfigError` immediately (TypeScript ≥ 5.4):
|
|
280
294
|
|
|
281
295
|
```ts
|
|
282
296
|
import { defineCatalog, flag, limit } from "@stewardhq/sdk";
|
|
@@ -291,16 +305,17 @@ export const catalog = defineCatalog({
|
|
|
291
305
|
prices: [{ plan: "team", interval: "month", currency: "TRY", amountMinor: 125_000, taxInclusive: true, taxRateBps: 2000 }],
|
|
292
306
|
});
|
|
293
307
|
|
|
294
|
-
await steward.admin.catalog.sync(catalog.toSyncInput()); // admin
|
|
308
|
+
await steward.admin.catalog.sync(catalog.toSyncInput()); // with the admin key (a CLI/deploy step)
|
|
295
309
|
const e = catalog.resolve(snapshot); // { max_projects: number | null; sso: boolean }
|
|
296
310
|
const placeholder = catalog.stateOf("org_1", "team", { accessState: "active", displayName: "Acme" });
|
|
297
|
-
//
|
|
298
|
-
//
|
|
311
|
+
// a full AccountState: version 0, updatedAt 1970, catalogVersion null, the plan's entitlements, no subscription or open checkout,
|
|
312
|
+
// profile { present: false }; without a plan, the default plan (for a stateCache fallback)
|
|
299
313
|
```
|
|
300
314
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
315
|
+
For a missing key or one whose type does not match, `resolve` takes the value from the
|
|
316
|
+
snapshot's plan (or, if that plan is not in the catalog, from the default plan) and never
|
|
317
|
+
produces "unlimited"; `onFallback` can raise an alarm. Without a snapshot, the default plan's
|
|
318
|
+
entitlements.
|
|
304
319
|
|
|
305
320
|
Benefits (billing core) — reusable definitions plans list by code; `rank` is optional (the
|
|
306
321
|
benefits resolution does not read it). A `featureFlag()` sets catalog features with their
|
|
@@ -397,7 +412,7 @@ process.once("SIGTERM", () => void events.flush()); // the SDK never hooks pro
|
|
|
397
412
|
`steward.credits.grant`, `steward.meterCharges.list` are the raw calls. Push a catalog with
|
|
398
413
|
meters only to a steward whose `me().capabilities` lists `"meters"` (an older one drops them).
|
|
399
414
|
|
|
400
|
-
|
|
415
|
+
Errors — the product API's status code:
|
|
401
416
|
|
|
402
417
|
```ts
|
|
403
418
|
import { httpStatusFor, StewardError } from "@stewardhq/sdk";
|
|
@@ -408,34 +423,42 @@ if (error instanceof StewardError) {
|
|
|
408
423
|
}
|
|
409
424
|
```
|
|
410
425
|
|
|
411
|
-
|
|
412
|
-
`provider` 502, `unavailable` 503; `validation`/`config` 500 + ERROR log).
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
426
|
+
A code that is not in the table is mapped by `category` (`not_found` 404, `conflict` 409,
|
|
427
|
+
`provider` 502, `unavailable` 503; `validation`/`config` 500 + an ERROR log). The text shown to
|
|
428
|
+
the user is the product's (`error.code` → your own i18n); `error.message` is for logs
|
|
429
|
+
(steward's message, or the code if there is none). Price display lives on the hosted checkout
|
|
430
|
+
and portal pages; the SDK carries no currency formatting helper.
|
|
416
431
|
|
|
417
|
-
|
|
418
|
-
|
|
432
|
+
Validators: `isValidTckn`, `isValidVkn` (checksum digit) and `isE164(value, {country: "TR"}?)`
|
|
433
|
+
check the value as-is (no whitespace trimming).
|
|
419
434
|
|
|
420
|
-
##
|
|
435
|
+
## Removed APIs
|
|
421
436
|
|
|
422
|
-
|
|
|
437
|
+
| Old | New |
|
|
423
438
|
| --- | --- |
|
|
424
|
-
| `@
|
|
425
|
-
|
|
|
426
|
-
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
(
|
|
439
|
+
| `@stewardhq/sdk/testing` (`signedEvent`, `createFakeSteward`) | Gone (0.4.0): write integration tests against the service, or stub `fetch` |
|
|
440
|
+
| `getEntitlements(ref)` / `accounts.entitlements(ref)` | `accounts.state(ref)` or `stateCache` (the single read model `AccountState`; the endpoint was removed on 2026-09-16) |
|
|
441
|
+
| `listEvents` / `redeliverEvent` / `events.*` | Gone (2026-09-16): for missed state use `accounts.resync(ref)`; an operator redelivers a dead delivery with the pod CLI's `redeliver-event` |
|
|
442
|
+
|
|
443
|
+
## Versioning
|
|
444
|
+
|
|
445
|
+
Semver. Removing or renaming a field is breaking (major; before 1.0, minor). Release: bump the
|
|
446
|
+
version in `sdk/package.json` and push the `sdk-v<version>` tag
|
|
447
|
+
(`.github/workflows/sdk-publish.yml`). **The first version on npmjs is 0.5.0** (2026-09-21);
|
|
448
|
+
0.4.0 stayed inside the repository and was never published.
|
|
449
|
+
|
|
450
|
+
**0.6.0** (contract 0.5.0, bank transfer F1; additive; `API_VERSION` `2026-10-01` unchanged):
|
|
451
|
+
|
|
452
|
+
- `Checkout().create({ replacesSubscriptionId })`: an upgrade that replaces the live subscription only
|
|
453
|
+
when the payment is recorded (do not cancel it before the checkout); `result()` adds `paymentMethod`.
|
|
454
|
+
- State: `openCheckout.paymentMethod` (`bank_transfer` while the order waits for a transfer) and
|
|
455
|
+
`bankTransferCode`; `subscription.activated` detail `replacedSubscriptionId`; `CheckoutSession`
|
|
456
|
+
`paymentMethod` / `replacesSubscriptionId`.
|
|
457
|
+
- Settings: `billing.bankTransfer` (`enabled`, `paymentDays`); email template `bank_transfer_instructions`.
|
|
458
|
+
- Admin: `BankTransferRecordInputSchema` / `BankTransferRecordedSchema` (`POST /v1/admin/bank-transfers`;
|
|
459
|
+
a transfer completes a checkout or pays an unpaid invoice, `checkoutSessionId` / `invoiceId`);
|
|
460
|
+
error codes `replaces_subscription_invalid`, `bank_transfer_*`.
|
|
461
|
+
- State change cause `bank_transfer.code_assigned` (the account chose bank transfer on the invoice pay page).
|
|
439
462
|
|
|
440
463
|
**0.5.0** (contract 0.4.0, metering phase 2; additive; `API_VERSION` `2026-10-01` unchanged):
|
|
441
464
|
|
|
@@ -446,16 +469,22 @@ Yayın: `sdk/package.json` sürümü artırılır, `sdk-v<sürüm>` etiketi push
|
|
|
446
469
|
- Client: `events.ingest`, `meters.get/read/periods`, `credits.grant`, `meterCharges.list`.
|
|
447
470
|
- Hooks: `onMeterThreshold`, `onMeterPeriodClosed` (+ `period`), `onBenefitCycled`.
|
|
448
471
|
|
|
449
|
-
**0.4.0** (
|
|
450
|
-
|
|
451
|
-
-
|
|
452
|
-
|
|
453
|
-
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
`
|
|
472
|
+
**0.4.0** (contract 0.3.0, billing core phase 1; `API_VERSION` `2026-10-01` unchanged):
|
|
473
|
+
|
|
474
|
+
- Breaking (0.x minor): the `@stewardhq/sdk/testing` subpath (`signedEvent`, the fixtures, the
|
|
475
|
+
fake steward) was removed; tests are written against the real service or a `fetch` stub.
|
|
476
|
+
- Catalog: `benefits` + `featureFlag()`, `plans[].benefits`, `typeof catalog.$benefit`;
|
|
477
|
+
`PlanSpec.rank` is optional and is not read during entitlement resolution (a union of layers,
|
|
478
|
+
the contract's `resolveBenefits`).
|
|
479
|
+
- State: `AccountState.benefits`, `subscriptions[]` (the singular `subscription` is the primary
|
|
480
|
+
subscription), `openInvoices[]`; `amountMinor` on a subscription is the amount that
|
|
481
|
+
subscription locked.
|
|
482
|
+
- Events (when requested in the endpoint's `eventTypes`): `invoice.paid`,
|
|
483
|
+
`benefit_grant.created/updated/revoked`; the hooks `onInvoicePaid`, `onBenefitGranted`,
|
|
484
|
+
`onBenefitRevoked` (and `onBenefitGrantCreated/Updated/Revoked` derived from the type).
|
|
485
|
+
- Client: `invoices.createPaymentSession`, `invoices.waive`, `subscriptions.change`,
|
|
486
|
+
`subscriptions.uncancel`; `capabilities` in the `me()` response.
|
|
487
|
+
|
|
488
|
+
## License
|
|
489
|
+
|
|
490
|
+
Apache License 2.0 — see [LICENSE](LICENSE).
|