@stwrd/workers 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented in this file.
4
+
5
+ The format is based on Keep a Changelog 1.1.0, and this package adheres to Semantic Versioning (SemVer).
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ Initial release.
10
+
11
+ ### Added
12
+
13
+ - `stwrdFor(env, options)` and `StwrdWorkers`: the `/auth/*` routes as a `Request` to `Response` handler for Cloudflare Workers.
14
+ - Guards: `requireAuth`, `requireRole`, `requirePermission`, `requireOrg`, `protect` and `resolve`.
15
+ - `StwrdSessionObject`, a SQLite-backed Durable Object that keeps one session and its refresh lease, and `DurableObjectSessionStore`, which seals sessions as JWE with `STWRD_SESSION_KEYS`.
16
+ - Key rotation for `STWRD_SESSION_KEYS`.
17
+ - Optional `ctx` support: session renewals run under `ctx.waitUntil` so they finish after the request ends.
18
+ - Back-channel logout and webhook routes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stwrd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,185 @@
1
- # Temporary Holding Version
1
+ # @stwrd/workers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ stwrd SDK for Cloudflare Workers (backend-for-frontend profile): the `/auth/*` routes as a `Request` to `Response` handler, guards for your own routes, and a Durable Object that keeps each session on the server.
4
+
5
+ ## What it is
6
+
7
+ [stwrd](https://stwrd.dev) is a multi-tenant identity provider. In the backend-for-frontend (BFF) profile the server performs the OpenID Connect flow and keeps the tokens; the browser only holds a signed cookie. This package runs that profile inside a Worker. It exposes the same routes, the same `GET /auth/session` JSON and the same `STWRD_*` variables as [`@stwrd/node`](https://www.npmjs.com/package/@stwrd/node) and the Python SDK (`stwrd-auth` on PyPI), so a front end such as [`@stwrd/react`](https://www.npmjs.com/package/@stwrd/react) works unchanged. A person's `user.id` is the `sub` claim of the identity provider: global and stable.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ npm install @stwrd/workers
13
+ ```
14
+
15
+ [`@stwrd/core`](https://www.npmjs.com/package/@stwrd/core) is a dependency, pinned to the exact version that ships with this release. Requirements:
16
+
17
+ - Node.js 20 or newer for the tooling (`wrangler`).
18
+ - The `nodejs_compat` compatibility flag with a `compatibility_date` of 2024-09-23 or later (any date from 2026-08-04 on enables the flag by itself). Without the flag the Worker does not start, because `node:crypto` cannot be imported. With the flag but an earlier date, `stwrdFor` throws a `ConfigError` because the `Buffer` global is missing.
19
+ - A Durable Object binding named `STWRD_SESSIONS` (below).
20
+
21
+ The package is ESM only and ships its own TypeScript declarations.
22
+
23
+ ## Minimal example
24
+
25
+ `wrangler.jsonc`: the flag, the binding of the session object and the migration that creates it as a SQLite-backed class.
26
+
27
+ ```jsonc
28
+ // What an app's wrangler config needs for `@stwrd/workers`: the
29
+ // `nodejs_compat` flag, the Durable Object binding named STWRD_SESSIONS, and
30
+ // the migration that creates the class as SQLite-backed. (Secrets are not
31
+ // here: `wrangler secret put STWRD_COOKIE_SECRET`, ...)
32
+ {
33
+ "name": "my-worker",
34
+ "main": "./index.ts",
35
+ "compatibility_date": "2026-08-22",
36
+ "compatibility_flags": ["nodejs_compat"],
37
+ "durable_objects": { "bindings": [{ "name": "STWRD_SESSIONS", "class_name": "StwrdSessionObject" }] },
38
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["StwrdSessionObject"] }]
39
+ }
40
+ ```
41
+
42
+ `index.ts`:
43
+
44
+ ```ts
45
+ import { StwrdSessionObject, stwrdFor } from "@stwrd/workers";
46
+
47
+ // The Durable Object class that wrangler.jsonc binds as STWRD_SESSIONS.
48
+ export { StwrdSessionObject };
49
+
50
+ export default {
51
+ async fetch(request: Request, env: object, ctx: ExecutionContext): Promise<Response> {
52
+ const stwrd = stwrdFor(env);
53
+
54
+ // /auth/sign-in, /auth/callback, /auth/sign-out, /auth/session, ...
55
+ const auth = await stwrd.handle(request, ctx);
56
+ if (auth) return auth;
57
+
58
+ // Your own routes: the person, or the response that stops the request.
59
+ const guard = await stwrd.requireAuth(request, { ctx });
60
+ if (!guard.ok) return guard.response;
61
+ return Response.json({ user: guard.context.user?.id });
62
+ },
63
+ };
64
+ ```
65
+
66
+ Three details of the configuration matter:
67
+
68
+ - The binding must be named `STWRD_SESSIONS` and point to the class `StwrdSessionObject`, which your Worker has to export.
69
+ - The migration creates that class with SQLite storage; without it Cloudflare rejects the deploy. Keep the tag `v1`; a future migration gets a new tag.
70
+ - Register `{STWRD_BASE_URL}/auth/callback` as a redirect URI of the application in stwrd.
71
+
72
+ Run `wrangler dev` to try it locally, and `wrangler deploy --dry-run` to check that the Worker bundles and the binding is in place (it needs no Cloudflare account).
73
+
74
+ ## Configuration
75
+
76
+ Public values go in `wrangler.jsonc` under `vars` or in the dashboard. Everything secret is set with `wrangler secret put` and never in `vars`: a value in `vars` is in your repository and in the dashboard in the clear.
77
+
78
+ ```sh
79
+ wrangler secret put STWRD_CLIENT_SECRET
80
+ wrangler secret put STWRD_COOKIE_SECRET
81
+ wrangler secret put STWRD_SESSION_KEYS
82
+ ```
83
+
84
+ | Variable | Required | Secret | Description |
85
+ |---|---|---|---|
86
+ | `STWRD_ISSUER` | yes | no | Issuer URL of the tenant. |
87
+ | `STWRD_CLIENT_ID` | yes | no | OIDC client ID of the application. |
88
+ | `STWRD_BASE_URL` | yes | no | Public URL of the Worker, without a trailing slash. |
89
+ | `STWRD_CLIENT_SECRET` | yes | yes | Client secret of the application. |
90
+ | `STWRD_COOKIE_SECRET` | yes | yes | HMAC key of the session cookie, at least 32 random characters. |
91
+ | `STWRD_SESSION_KEYS` | yes | yes | Keys that seal the stored sessions: `id:key` entries separated by commas, each key 32 bytes in base64url. The first entry encrypts; all of them decrypt. |
92
+ | `STWRD_WEBHOOK_SECRET` | no | yes | Without it `/auth/webhook` answers 503. |
93
+ | `STWRD_SCOPE`, `STWRD_PREFIX`, `STWRD_SESSION_TTL_S`, `STWRD_COOKIE_SECURE`, `STWRD_POST_LOGIN_REDIRECT`, `STWRD_POST_LOGOUT_REDIRECT` | no | no | Optional; defaults and meaning as in [`@stwrd/core`](https://www.npmjs.com/package/@stwrd/core). Add `org` to the scope to enable organizations. |
94
+
95
+ Create the first session key with:
96
+
97
+ ```sh
98
+ node -e "console.log('k1:' + require('crypto').randomBytes(32).toString('base64url'))"
99
+ ```
100
+
101
+ Whoever can read `STWRD_SESSION_KEYS` can read every session; treat it like a password. If any variable is missing or malformed, `stwrdFor` throws a `ConfigError`, so the Worker does not start half configured.
102
+
103
+ ### Rotating keys
104
+
105
+ - Session keys rotate without signing anyone out. Generate a new key (`k2:...`) and put it first: `STWRD_SESSION_KEYS="k2:<new>,k1:<old>"`. Deploy. After `STWRD_SESSION_TTL_S` seconds (8 hours by default) every session sealed with the old key has expired or has been renewed under the new one; then remove `k1` and deploy again. Removing it earlier makes the people still under `k1` sign in again.
106
+ - The cookie secret does not rotate: there is no window for the previous secret, and changing `STWRD_COOKIE_SECRET` signs everybody out.
107
+ - Changing `STWRD_ISSUER` or `STWRD_CLIENT_ID` makes the stored sessions unreadable (everybody signs in again), by design: two apps that share a binding never read each other's sessions.
108
+
109
+ ### Options and hooks
110
+
111
+ `stwrdFor(env, options?)` builds the instance on the first call for an `env` and returns the same instance afterwards. `options` overrides the environment and can add `onUserRegistered(user)` (runs on every successful sign-in), `onEvent(event)` (runs for each accepted webhook delivery), `fetch`, `sessions` and `backChannel`. A later call that changes a value option (`issuer`, `clientId`, `scope`, ...) throws a `ConfigError`, because it would be ignored. Functions are not compared: those of the first call stay and later ones are ignored without error, so passing a hook inline in `fetch(request, env, ctx)` works on every request:
112
+
113
+ ```ts
114
+ const stwrd = stwrdFor(env, {
115
+ onUserRegistered: async (user) => {
116
+ console.log("new person", user.id);
117
+ },
118
+ });
119
+ ```
120
+
121
+ ## API reference
122
+
123
+ ### `StwrdWorkers`
124
+
125
+ | Member | Description |
126
+ |---|---|
127
+ | `handle(request, ctx?)` | Serves the `/auth/*` routes. Returns `null` for any other path or method, so the rest of your Worker runs as usual. |
128
+ | `requireAuth(request, options?)` | Guard: signed in. |
129
+ | `requireRole(request, role, options?)` | Guard: the role (organization roles when the session has an organization). |
130
+ | `requirePermission(request, permission, options?)` | Guard: the permission. |
131
+ | `requireOrg(request, options?)` | Guard: the session has an organization. Needs the `org` scope (`ConfigError` otherwise). |
132
+ | `protect(request, { public?, api?, ctx? })` | Fail-closed: `null` lets the request through, a `Response` stops it. Everything needs a session except the listed paths (exact, or a prefix ending in `*`) and everything under the auth prefix. |
133
+ | `resolve(request, ctx?)` | The session context for a route that works with or without a person. Check `idpUnavailable` before rendering a "signed out" state. |
134
+
135
+ Each guard answers `{ ok: true, context }`, with `context.user`, `context.organization`, `context.consents` and `context.session`, or `{ ok: false, response }` with the response that stops the request. In order: identity provider or session store silent gives 503; no person gives a 303 to sign-in for a browser navigation or a 401 JSON (always 401 with `{ api: true }`); a failed rule gives 403. The session is read once per request.
136
+
137
+ ### Other exports
138
+
139
+ `StwrdSessionObject` (the Durable Object), `DurableObjectSessionStore`, `SESSIONS_BINDING`, `parseSessionKeys`, `Keyring`, `MemoryStore` (tests only), `safeTarget`, `sessionContext`, `sessionResponse`, `hasRole`, `hasPermission`, the errors `ConfigError`, `OidcError`, `IdpUnavailable` and `RefreshUncertain`, and the session types.
140
+
141
+ ### Passing `ctx`
142
+
143
+ Pass the Worker's `ctx` (`handle(request, ctx)`, `resolve(request, ctx)`, or `{ ctx }` in the options of a guard and of `protect`). It is optional, but without it a person who closes the tab while their session is being renewed can leave that session `uncertain` (see below); with it the renewal runs under `ctx.waitUntil` and finishes anyway. The first call that reads the session in a request is the one whose `ctx` counts. A `ctx` without a `waitUntil` function (typically `env` passed where `ctx` goes) is a `ConfigError`, thrown before the session is read.
144
+
145
+ ## How it differs from the Node SDK
146
+
147
+ - Sessions live in Durable Objects: one object per session, named after the namespace (`issuer|clientId`) and the local session id. The Worker seals the session (JWE) with `STWRD_SESSION_KEYS`; the object keeps only the envelope and the refresh lease, and never sees a key or a token. The object deletes itself when the session expires.
148
+ - A network failure while renewing makes the session `uncertain`. The runtime does not tell "could not connect" from "cut after sending", so a refresh that may have been processed is never repeated. That session answers 503 until the person signs in again. Only an explicit "not now" from the identity provider (503, 408, 425, 429) releases it. Before a renewal the SDK asks the identity provider for its discovery document (5-second timeout), so a provider that is down costs a retry and not a session.
149
+ - Changing organization takes two steps (retire the old session, install the new one). If the Worker stops between them the person signs in again; there are never two live sessions.
150
+ - Back-channel logout deletes the session in its object and then marks the notice, in that order; if the object does not answer the route answers 503 and the identity provider retries.
151
+
152
+ ## Security
153
+
154
+ - Tokens never reach the browser: the cookie is HMAC-signed (tamper-evident) and carries only a session identifier. Sessions at rest in the Durable Object are encrypted (JWE `dir` + `A256GCM`) and authenticated against their namespace and id, so a copied envelope is rejected.
155
+ - Secrets (`STWRD_CLIENT_SECRET`, `STWRD_COOKIE_SECRET`, `STWRD_SESSION_KEYS`, `STWRD_WEBHOOK_SECRET`) belong in `wrangler secret put`. Error messages name the variable or the position of an entry, never a key.
156
+ - Sign-out and the organization switch require a CSRF token; redirect targets are confined to relative paths of the app; requests to the identity provider never follow redirects; the redirect URI is built from `STWRD_BASE_URL`, never from the `Host` header. The protocol details are those of `@stwrd/core`.
157
+ - Supported versions and how to report a vulnerability are described in the [security policy](https://github.com/stwrd-dev/sdk-js/security/policy).
158
+
159
+ ## Error handling and troubleshooting
160
+
161
+ A 503 from `/auth/session` or from a guard means "could not find out", never "signed out". The person keeps their session; show "try again" and not the sign-in page.
162
+
163
+ | Symptom | Cause | What to do |
164
+ |---|---|---|
165
+ | 503 that goes away by itself | The identity provider or a Durable Object did not answer. | Nothing; the next request tries again. |
166
+ | 503 that stays for one person | The session is `uncertain`: a renewal was cut off after it may have been sent. | The person signs in again; `/auth/sign-in` treats that session as gone. A "Sign in again" button on your 503 page is enough. Pass `ctx` everywhere to make this rare. |
167
+ | Many people `uncertain` at once | The identity provider was down during renewals. | Check the Worker logs for 503 answers. |
168
+ | `ConfigError` at start | A variable is missing or malformed, the binding is missing, or the compatibility flag or date is wrong. | The message names the cause. |
169
+ | The Worker does not start | `nodejs_compat` is missing. | Add the flag and a recent `compatibility_date`. |
170
+ | Everybody signed out after a deploy | `STWRD_COOKIE_SECRET`, `STWRD_ISSUER` or `STWRD_CLIENT_ID` changed. | Expected; see "Rotating keys". |
171
+
172
+ ## Compatibility
173
+
174
+ - Cloudflare Workers with `nodejs_compat`; Durable Objects with SQLite storage.
175
+ - Node.js 20 or newer for `wrangler` and the build.
176
+ - ESM only; TypeScript declarations included (use `@cloudflare/workers-types` for `ExecutionContext`).
177
+ - The test suite runs on workerd, the Workers runtime, and the example above bundles with `wrangler deploy --dry-run`. Behavior on Cloudflare's network itself is not covered by the tests.
178
+
179
+ ## Versioning
180
+
181
+ This package follows Semantic Versioning (SemVer). While the version is below 1.0, minor releases may contain breaking changes. `@stwrd/core` is pinned to an exact version, so the two always move together. Changes are listed in `CHANGELOG.md`.
182
+
183
+ ## License
184
+
185
+ MIT © 2026 stwrd. See `LICENSE`.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@stwrd/workers` — the Cloudflare Workers SDK (BFF profile): the `/auth/*`
3
+ * routes as a `Request → Response` handler, access guards for the app's own
4
+ * routes, and a Durable Object that keeps each session. Same contract as
5
+ * `@stwrd/node`: this module's exports are the public surface.
6
+ */
7
+ export declare const VERSION = "0.1.1";
8
+ export { SESSIONS_BINDING, StwrdWorkers, stwrdFor } from "./stwrd.js";
9
+ export type { GuardOptions, ProtectOptions, WorkersOptions } from "./stwrd.js";
10
+ export { StwrdSessionObject } from "./sessionObject.js";
11
+ export type { ObjectClaim } from "./sessionObject.js";
12
+ export { DurableObjectSessionStore, SESSION_OBJECT_PREFIX, UNNAMED_NOTICE_OBJECT } from "./store.js";
13
+ export type { DurableObjectSessionStoreOptions, SessionObjects } from "./store.js";
14
+ export { SESSION_KEYS_ENV, parseSessionKeys } from "./sessionKeys.js";
15
+ export { ConfigError } from "@stwrd/core/config";
16
+ export { Keyring } from "@stwrd/core/keyring";
17
+ export { IdpUnavailable, OidcError, RefreshUncertain } from "@stwrd/core/oidc";
18
+ export { MemoryStore, contextFromClaims, hasPermission, hasRole, sessionContext, sessionResponse, userFromClaims, } from "@stwrd/core/sessions";
19
+ export { safeTarget } from "@stwrd/core/web/access";
20
+ export type { BackChannelSink } from "@stwrd/core/backchannel";
21
+ export type { FetchLike } from "@stwrd/core/oidc";
22
+ export type { Claims, ConsentState, RefreshClaim, SessionContext, SessionResponse, SessionStore, StwrdOrganization, StwrdSession, StwrdUser, Tokens, } from "@stwrd/core/sessions";
23
+ export type { AccessContext, AccessResult, AccessRule, Resolution } from "@stwrd/core/web/access";
24
+ export type { WebhookEvent } from "@stwrd/core/webhooks";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@stwrd/workers` — the Cloudflare Workers SDK (BFF profile): the `/auth/*`
3
+ * routes as a `Request → Response` handler, access guards for the app's own
4
+ * routes, and a Durable Object that keeps each session. Same contract as
5
+ * `@stwrd/node`: this module's exports are the public surface.
6
+ */
7
+ export const VERSION = "0.1.1";
8
+ // --- the adapter -------------------------------------------------------------
9
+ export { SESSIONS_BINDING, StwrdWorkers, stwrdFor } from "./stwrd.js";
10
+ // --- sessions: the Durable Object an app exports and binds as `STWRD_SESSIONS` ---
11
+ export { StwrdSessionObject } from "./sessionObject.js";
12
+ export { DurableObjectSessionStore, SESSION_OBJECT_PREFIX, UNNAMED_NOTICE_OBJECT } from "./store.js";
13
+ export { SESSION_KEYS_ENV, parseSessionKeys } from "./sessionKeys.js";
14
+ // --- what an app reads and catches, from the shared core ---------------------
15
+ export { ConfigError } from "@stwrd/core/config";
16
+ export { Keyring } from "@stwrd/core/keyring";
17
+ export { IdpUnavailable, OidcError, RefreshUncertain } from "@stwrd/core/oidc";
18
+ export { MemoryStore, contextFromClaims, hasPermission, hasRole, sessionContext, sessionResponse, userFromClaims, } from "@stwrd/core/sessions";
19
+ export { safeTarget } from "@stwrd/core/web/access";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `STWRD_SESSION_KEYS` — the keys that seal sessions before they go to a
3
+ * Durable Object. Only the Workers adapter has it (`@stwrd/node` keeps its
4
+ * keyring in code, next to its PostgreSQL pool).
5
+ *
6
+ * Format: `id:key` entries separated by commas, each key 32 bytes in base64url
7
+ * (no padding). The FIRST entry encrypts; every entry decrypts, so a key is
8
+ * rotated by putting the new one first and dropping the old one once no
9
+ * session is sealed with it. Empty, a repeated id, a malformed entry or a key
10
+ * of another length: the configuration is not built (a Worker that cannot seal
11
+ * must not start). Errors name the entry's position, never its key.
12
+ */
13
+ import { Keyring } from "@stwrd/core/keyring";
14
+ export declare const SESSION_KEYS_ENV = "STWRD_SESSION_KEYS";
15
+ export declare function parseSessionKeys(raw: unknown): Keyring;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `STWRD_SESSION_KEYS` — the keys that seal sessions before they go to a
3
+ * Durable Object. Only the Workers adapter has it (`@stwrd/node` keeps its
4
+ * keyring in code, next to its PostgreSQL pool).
5
+ *
6
+ * Format: `id:key` entries separated by commas, each key 32 bytes in base64url
7
+ * (no padding). The FIRST entry encrypts; every entry decrypts, so a key is
8
+ * rotated by putting the new one first and dropping the old one once no
9
+ * session is sealed with it. Empty, a repeated id, a malformed entry or a key
10
+ * of another length: the configuration is not built (a Worker that cannot seal
11
+ * must not start). Errors name the entry's position, never its key.
12
+ */
13
+ import { ConfigError } from "@stwrd/core/config";
14
+ import { Keyring } from "@stwrd/core/keyring";
15
+ export const SESSION_KEYS_ENV = "STWRD_SESSION_KEYS";
16
+ const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
17
+ export function parseSessionKeys(raw) {
18
+ if (typeof raw !== "string" || raw.trim() === "") {
19
+ throw new ConfigError(`${SESSION_KEYS_ENV} is required: id:key entries (32-byte base64url keys) separated by commas.`);
20
+ }
21
+ const entries = [];
22
+ raw.split(",").forEach((part, index) => {
23
+ const position = `${SESSION_KEYS_ENV} entry ${index + 1}`;
24
+ const colon = part.indexOf(":");
25
+ const id = part.slice(0, colon).trim();
26
+ const encoded = part.slice(colon + 1).trim();
27
+ if (colon === -1 || !KEY_ID.test(id)) {
28
+ throw new ConfigError(`${position} must be id:key, with an id of letters, digits, dot, underscore or hyphen (at most 64).`);
29
+ }
30
+ if (entries.some(([seen]) => seen === id)) {
31
+ throw new ConfigError(`${position} repeats the key id "${id}".`);
32
+ }
33
+ const key = Buffer.from(encoded, "base64url");
34
+ // `Buffer` skips what is not base64url and accepts padding: the round trip is the strict check.
35
+ if (key.toString("base64url") !== encoded || key.length !== 32) {
36
+ throw new ConfigError(`${position} must be a 32-byte key in base64url (43 characters, no padding).`);
37
+ }
38
+ entries.push([id, new Uint8Array(key)]);
39
+ });
40
+ // `fromEntries` defines own properties: an id such as `__proto__` stays a key.
41
+ return new Keyring(Object.fromEntries(entries), entries[0][0]);
42
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `StwrdSessionObject` — the Durable Object that keeps ONE session (the object
3
+ * is named after the namespace and the local session id) and runs its refresh lease.
4
+ *
5
+ * It never sees a session in the clear: the Worker seals the session (JWE, see
6
+ * `store.ts`) and the object stores the envelope next to the lease columns,
7
+ * which hold no secret. The lease is the same state machine as the PostgreSQL
8
+ * and in-memory stores of `@stwrd/node` (`SessionStore` in `@stwrd/core/sessions`
9
+ * is the contract): `claim` grants the one refresh of a session, and every later
10
+ * step is a compare-and-set on the fence it handed out.
11
+ *
12
+ * **Every method is synchronous.** Awaiting a timer or a `fetch` inside a
13
+ * method can let another call in between, so the invariants cannot depend on a method being atomic
14
+ * across an `await`; with no `await` a method runs to the end before any other
15
+ * call is delivered, and the multi-statement ones are one `transactionSync`.
16
+ * Only `alarm` is async, at its very end. The lease clock is this object's.
17
+ *
18
+ * The object deletes itself: an alarm at the session's expiry removes the
19
+ * envelope (and with it the stored refresh token) and, when nothing is left,
20
+ * the whole storage. Storage is only ever created by a write (`set`, `logout`):
21
+ * reads and lease calls on an object with nothing in it make nothing.
22
+ */
23
+ import { DurableObject } from "cloudflare:workers";
24
+ /** What `claim` answers: `RefreshClaim` of the core, with the sealed envelope
25
+ * in place of the session. */
26
+ export type ObjectClaim = {
27
+ status: "granted";
28
+ payload: string;
29
+ fence: number;
30
+ phase: "acquired" | "hydrating";
31
+ freshIdToken: boolean;
32
+ } | {
33
+ status: "busy" | "uncertain" | "gone";
34
+ };
35
+ export declare class StwrdSessionObject extends DurableObject {
36
+ private ready;
37
+ /** Whether the storage exists. Only a write creates it (`ensureSchema`), so
38
+ * a read of an object nobody ever signed in under, or of one whose alarm
39
+ * emptied it, finds nothing and makes nothing. */
40
+ private hasSchema;
41
+ private ensureSchema;
42
+ private rows;
43
+ private nowS;
44
+ /** Strictly increasing, survives `delete`, and anchored to the clock so it
45
+ * still exceeds every earlier fence after the storage was emptied. */
46
+ private nextFence;
47
+ /** The alarm goes off at the earliest thing this object has to forget. */
48
+ private scheduleAlarm;
49
+ /** The sealed envelope, or `null`. Expiry is the caller's judgement (the
50
+ * alarm removes the row some time after it). */
51
+ get(): string | null;
52
+ /** Installs a session, clearing any lease and taking a new fence: a stale
53
+ * owner of an earlier session under this id can no longer write. */
54
+ set(payload: string, expiresAt: number): void;
55
+ delete(): void;
56
+ /** Deletes the session only if it is there; says whether it was. The first
57
+ * step of an organization switch between two objects. */
58
+ retire(): boolean;
59
+ /** Same decision table as the other stores: `gone` with no session; `uncertain`
60
+ * for good once a sent request has no result; `busy` while another owner's
61
+ * lease is live; an expired lease in `sent` becomes `uncertain`; otherwise
62
+ * the caller is granted the lease (`hydrating` is resumed, anything else
63
+ * starts at `acquired`). */
64
+ claim(owner: string, leaseS: number): ObjectClaim;
65
+ /** Written BEFORE the exchange leaves: from this point a lost answer is `uncertain`. */
66
+ markSent(fence: number): boolean;
67
+ /** Stores the rotated tokens (the caller re-sealed the envelope) and moves to `hydrating`. */
68
+ checkpoint(fence: number, payload: string, freshIdToken: boolean): boolean;
69
+ /** Stores the final session and ends the lease. */
70
+ complete(fence: number, payload: string, expiresAt: number): boolean;
71
+ /** `sent: false` clears the lease; `sent: true` in phase `sent` marks the
72
+ * session `uncertain`, in `hydrating` keeps the checkpoint for a retry. A
73
+ * session that is already `uncertain` stays so: the owner that sent the
74
+ * request is gone and nothing it says now can clear that. */
75
+ release(fence: number, sent: boolean): void;
76
+ /** Ends the session and remembers the notice's `jti` until `untilS`, in one
77
+ * step and in this order: the session is deleted FIRST and the `jti` marked
78
+ * SECOND (`@stwrd/core/backchannel`: the IdP retries a 5xx, and a `jti` marked
79
+ * before a failed deletion would turn the retry into a "replay"). A `jti`
80
+ * already seen deletes nothing. */
81
+ logout(jti: string, untilS: number): "terminated" | "replay";
82
+ alarm(): Promise<void>;
83
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `StwrdSessionObject` — the Durable Object that keeps ONE session (the object
3
+ * is named after the namespace and the local session id) and runs its refresh lease.
4
+ *
5
+ * It never sees a session in the clear: the Worker seals the session (JWE, see
6
+ * `store.ts`) and the object stores the envelope next to the lease columns,
7
+ * which hold no secret. The lease is the same state machine as the PostgreSQL
8
+ * and in-memory stores of `@stwrd/node` (`SessionStore` in `@stwrd/core/sessions`
9
+ * is the contract): `claim` grants the one refresh of a session, and every later
10
+ * step is a compare-and-set on the fence it handed out.
11
+ *
12
+ * **Every method is synchronous.** Awaiting a timer or a `fetch` inside a
13
+ * method can let another call in between, so the invariants cannot depend on a method being atomic
14
+ * across an `await`; with no `await` a method runs to the end before any other
15
+ * call is delivered, and the multi-statement ones are one `transactionSync`.
16
+ * Only `alarm` is async, at its very end. The lease clock is this object's.
17
+ *
18
+ * The object deletes itself: an alarm at the session's expiry removes the
19
+ * envelope (and with it the stored refresh token) and, when nothing is left,
20
+ * the whole storage. Storage is only ever created by a write (`set`, `logout`):
21
+ * reads and lease calls on an object with nothing in it make nothing.
22
+ */
23
+ import { DurableObject } from "cloudflare:workers";
24
+ export class StwrdSessionObject extends DurableObject {
25
+ ready = false;
26
+ /** Whether the storage exists. Only a write creates it (`ensureSchema`), so
27
+ * a read of an object nobody ever signed in under, or of one whose alarm
28
+ * emptied it, finds nothing and makes nothing. */
29
+ hasSchema() {
30
+ if (this.ready)
31
+ return true;
32
+ const [row] = this.rows("SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'counter', 'seen')");
33
+ this.ready = row?.n === 3;
34
+ return this.ready;
35
+ }
36
+ // The storage is created by the first write (and again after the alarm emptied it).
37
+ ensureSchema() {
38
+ if (this.ready)
39
+ return;
40
+ const sql = this.ctx.storage.sql;
41
+ sql.exec(`CREATE TABLE IF NOT EXISTS session (
42
+ slot INTEGER PRIMARY KEY CHECK (slot = 0),
43
+ payload TEXT NOT NULL,
44
+ expires_at REAL NOT NULL,
45
+ fence INTEGER NOT NULL,
46
+ lease_owner TEXT,
47
+ lease_phase TEXT CHECK (lease_phase IN ('acquired', 'sent', 'hydrating', 'uncertain')),
48
+ lease_expires_at REAL,
49
+ fresh_id_token INTEGER NOT NULL DEFAULT 0,
50
+ CHECK ((lease_phase IS NULL) = (lease_owner IS NULL))
51
+ )`);
52
+ sql.exec("CREATE TABLE IF NOT EXISTS counter (slot INTEGER PRIMARY KEY CHECK (slot = 0), fence INTEGER NOT NULL)");
53
+ sql.exec("CREATE TABLE IF NOT EXISTS seen (jti TEXT PRIMARY KEY, until_s REAL NOT NULL)");
54
+ this.ready = true;
55
+ }
56
+ rows(query, ...bindings) {
57
+ return this.ctx.storage.sql.exec(query, ...bindings).toArray();
58
+ }
59
+ nowS() {
60
+ return Date.now() / 1000;
61
+ }
62
+ /** Strictly increasing, survives `delete`, and anchored to the clock so it
63
+ * still exceeds every earlier fence after the storage was emptied. */
64
+ nextFence() {
65
+ const [row] = this.rows("SELECT fence FROM counter WHERE slot = 0");
66
+ const next = Math.max((row?.fence ?? 0) + 1, Date.now() * 1000);
67
+ this.rows("INSERT INTO counter (slot, fence) VALUES (0, ?) ON CONFLICT (slot) DO UPDATE SET fence = excluded.fence", next);
68
+ return next;
69
+ }
70
+ /** The alarm goes off at the earliest thing this object has to forget. */
71
+ scheduleAlarm() {
72
+ const [row] = this.rows(`SELECT min(t) AS next_at FROM (SELECT expires_at AS t FROM session UNION ALL SELECT until_s AS t FROM seen)`);
73
+ if (row?.next_at !== null && row?.next_at !== undefined) {
74
+ void this.ctx.storage.setAlarm(Math.ceil(row.next_at * 1000));
75
+ }
76
+ }
77
+ // --- the session -------------------------------------------------------
78
+ /** The sealed envelope, or `null`. Expiry is the caller's judgement (the
79
+ * alarm removes the row some time after it). */
80
+ get() {
81
+ if (!this.hasSchema())
82
+ return null;
83
+ const [row] = this.rows("SELECT payload FROM session WHERE slot = 0");
84
+ return row ? row.payload : null;
85
+ }
86
+ /** Installs a session, clearing any lease and taking a new fence: a stale
87
+ * owner of an earlier session under this id can no longer write. */
88
+ set(payload, expiresAt) {
89
+ this.ensureSchema();
90
+ this.ctx.storage.transactionSync(() => {
91
+ const fence = this.nextFence();
92
+ this.rows(`INSERT INTO session (slot, payload, expires_at, fence) VALUES (0, ?, ?, ?)
93
+ ON CONFLICT (slot) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at,
94
+ fence = excluded.fence, lease_owner = NULL, lease_phase = NULL, lease_expires_at = NULL, fresh_id_token = 0`, payload, expiresAt, fence);
95
+ });
96
+ this.scheduleAlarm();
97
+ }
98
+ delete() {
99
+ if (!this.hasSchema())
100
+ return;
101
+ this.rows("DELETE FROM session");
102
+ this.scheduleAlarm();
103
+ }
104
+ /** Deletes the session only if it is there; says whether it was. The first
105
+ * step of an organization switch between two objects. */
106
+ retire() {
107
+ if (!this.hasSchema())
108
+ return false;
109
+ const removed = this.rows("DELETE FROM session RETURNING slot").length > 0;
110
+ this.scheduleAlarm();
111
+ return removed;
112
+ }
113
+ // --- the refresh lease -------------------------------------------------
114
+ /** Same decision table as the other stores: `gone` with no session; `uncertain`
115
+ * for good once a sent request has no result; `busy` while another owner's
116
+ * lease is live; an expired lease in `sent` becomes `uncertain`; otherwise
117
+ * the caller is granted the lease (`hydrating` is resumed, anything else
118
+ * starts at `acquired`). */
119
+ claim(owner, leaseS) {
120
+ if (!this.hasSchema())
121
+ return { status: "gone" };
122
+ return this.ctx.storage.transactionSync(() => {
123
+ const [row] = this.rows("SELECT payload, lease_phase, lease_expires_at, fresh_id_token FROM session WHERE slot = 0");
124
+ if (!row)
125
+ return { status: "gone" };
126
+ const now = this.nowS();
127
+ const phase = row.lease_phase;
128
+ if (phase === "uncertain")
129
+ return { status: "uncertain" };
130
+ if (phase !== null && row.lease_expires_at > now)
131
+ return { status: "busy" };
132
+ if (phase === "sent") {
133
+ this.rows("UPDATE session SET lease_phase = 'uncertain' WHERE slot = 0");
134
+ return { status: "uncertain" };
135
+ }
136
+ const granted = phase === "hydrating" ? "hydrating" : "acquired";
137
+ const fence = this.nextFence();
138
+ this.rows("UPDATE session SET fence = ?, lease_owner = ?, lease_phase = ?, lease_expires_at = ? WHERE slot = 0", fence, owner, granted, now + leaseS);
139
+ return { status: "granted", payload: row.payload, fence, phase: granted, freshIdToken: Boolean(row.fresh_id_token) };
140
+ });
141
+ }
142
+ /** Written BEFORE the exchange leaves: from this point a lost answer is `uncertain`. */
143
+ markSent(fence) {
144
+ if (!this.hasSchema())
145
+ return false;
146
+ return this.rows("UPDATE session SET lease_phase = 'sent' WHERE fence = ? AND lease_phase = 'acquired' RETURNING slot", fence).length > 0;
147
+ }
148
+ /** Stores the rotated tokens (the caller re-sealed the envelope) and moves to `hydrating`. */
149
+ checkpoint(fence, payload, freshIdToken) {
150
+ if (!this.hasSchema())
151
+ return false;
152
+ return (this.rows("UPDATE session SET payload = ?, lease_phase = 'hydrating', fresh_id_token = ? WHERE fence = ? AND lease_phase = 'sent' RETURNING slot", payload, freshIdToken ? 1 : 0, fence).length > 0);
153
+ }
154
+ /** Stores the final session and ends the lease. */
155
+ complete(fence, payload, expiresAt) {
156
+ if (!this.hasSchema())
157
+ return false;
158
+ const done = this.rows(`UPDATE session SET payload = ?, expires_at = ?, lease_owner = NULL, lease_phase = NULL, lease_expires_at = NULL,
159
+ fresh_id_token = 0
160
+ WHERE fence = ? AND lease_phase IS NOT NULL AND lease_phase <> 'uncertain' RETURNING slot`, payload, expiresAt, fence).length > 0;
161
+ if (done)
162
+ this.scheduleAlarm();
163
+ return done;
164
+ }
165
+ /** `sent: false` clears the lease; `sent: true` in phase `sent` marks the
166
+ * session `uncertain`, in `hydrating` keeps the checkpoint for a retry. A
167
+ * session that is already `uncertain` stays so: the owner that sent the
168
+ * request is gone and nothing it says now can clear that. */
169
+ release(fence, sent) {
170
+ if (!this.hasSchema())
171
+ return;
172
+ this.rows(`UPDATE session SET
173
+ lease_phase = CASE
174
+ WHEN lease_phase = 'sent' AND ? THEN 'uncertain'
175
+ WHEN lease_phase = 'hydrating' THEN 'hydrating'
176
+ ELSE NULL END,
177
+ lease_owner = CASE
178
+ WHEN lease_phase = 'hydrating' OR (lease_phase = 'sent' AND ?) THEN lease_owner
179
+ ELSE NULL END,
180
+ lease_expires_at = CASE
181
+ WHEN lease_phase = 'hydrating' THEN ?
182
+ WHEN lease_phase = 'sent' AND ? THEN lease_expires_at
183
+ ELSE NULL END
184
+ WHERE fence = ? AND lease_phase IS NOT NULL AND lease_phase <> 'uncertain'`, sent ? 1 : 0, sent ? 1 : 0, this.nowS(), sent ? 1 : 0, fence);
185
+ }
186
+ // --- back-channel logout ---------------------------------------------------
187
+ /** Ends the session and remembers the notice's `jti` until `untilS`, in one
188
+ * step and in this order: the session is deleted FIRST and the `jti` marked
189
+ * SECOND (`@stwrd/core/backchannel`: the IdP retries a 5xx, and a `jti` marked
190
+ * before a failed deletion would turn the retry into a "replay"). A `jti`
191
+ * already seen deletes nothing. */
192
+ logout(jti, untilS) {
193
+ this.ensureSchema();
194
+ const outcome = this.ctx.storage.transactionSync(() => {
195
+ this.rows("DELETE FROM seen WHERE until_s <= ?", this.nowS());
196
+ if (this.rows("SELECT 1 AS seen FROM seen WHERE jti = ?", jti).length > 0)
197
+ return "replay";
198
+ this.rows("DELETE FROM session");
199
+ this.rows("INSERT INTO seen (jti, until_s) VALUES (?, ?)", jti, untilS);
200
+ return "terminated";
201
+ });
202
+ this.scheduleAlarm();
203
+ return outcome;
204
+ }
205
+ // --- forgetting ---------------------------------------------------------
206
+ async alarm() {
207
+ if (!this.hasSchema())
208
+ return;
209
+ const now = this.nowS();
210
+ const left = this.ctx.storage.transactionSync(() => {
211
+ this.rows("DELETE FROM session WHERE expires_at <= ?", now);
212
+ this.rows("DELETE FROM seen WHERE until_s <= ?", now);
213
+ return this.rows("SELECT (SELECT count(*) FROM session) + (SELECT count(*) FROM seen) AS n")[0].n ?? 0;
214
+ });
215
+ if (left === 0) {
216
+ // Nothing left to remember: the storage goes too. The fence restarts from
217
+ // the clock, which is above every fence handed out before.
218
+ await this.ctx.storage.deleteAll();
219
+ this.ready = false;
220
+ }
221
+ else {
222
+ this.scheduleAlarm();
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `DurableObjectSessionStore` — the `SessionStore` of `@stwrd/workers`: one
3
+ * Durable Object (`StwrdSessionObject`) per local session, named after the
4
+ * namespace (tenant/client) and the session id, like the PostgreSQL key
5
+ * `(namespace, session_id)`, so two clients that share a binding never share
6
+ * an object, and a login, a refresh and a back-channel notice for different
7
+ * sessions never queue behind each other and the lease of one session is
8
+ * decided in one place.
9
+ *
10
+ * Sessions are sealed here, in the Worker, with a `Keyring` (JWE `dir` +
11
+ * `A256GCM`, as `@stwrd/node/postgres` does): the object only ever holds the
12
+ * envelope, never the key and never a token in the clear. The authenticated
13
+ * payload carries the namespace (tenant/client), the session id and the
14
+ * purpose, so an envelope copied to another namespace or another object is
15
+ * rejected on read. Lease phase, fence and expiry live in plain columns inside
16
+ * the object because the object decides on them and they hold no secret.
17
+ *
18
+ * A store that does not answer is an IdP that does not answer: every failure of
19
+ * a call to the object is raised as `IdpUnavailable`, so every route and guard answers
20
+ * 503 and none of them reads it as "no session" (a session lost to a
21
+ * hiccup of the platform is not a session that ended).
22
+ *
23
+ * Back-channel logout goes through the same objects: `backChannelSink()` gives
24
+ * the sink, built from this store so it cannot name other objects than the
25
+ * store does (the notice's `sid` names the session's own object, which deletes
26
+ * the session and THEN marks the `jti`, in one call: `StwrdSessionObject.logout`.
27
+ * If the object does not answer, nothing was marked: the caller answers 503 and
28
+ * the IdP retries — it retries a 5xx and never a 4xx, so a `jti` marked before
29
+ * a failed deletion would turn the retry into a "replay" and leave the session
30
+ * alive for good).
31
+ */
32
+ import type { BackChannelSink } from "@stwrd/core/backchannel";
33
+ import type { Keyring } from "@stwrd/core/keyring";
34
+ import { type RefreshClaim, type SessionStore, type StwrdSession, type Tokens } from "@stwrd/core/sessions";
35
+ import type { StwrdSessionObject } from "./sessionObject.js";
36
+ /** The prefix of every object name; the namespace and the session id follow it. */
37
+ export declare const SESSION_OBJECT_PREFIX = "stwrd-session:";
38
+ /** The name of the object that keeps one session: the namespace and the id as
39
+ * a JSON pair, so no namespace/id split can be read as another (a `|` or a
40
+ * `:` inside either cannot move the boundary). */
41
+ export declare function sessionObjectName(namespace: string, sessionId: string): string;
42
+ /** Where the `jti` of a notice that names no `sid` (only a `sub`) is kept:
43
+ * there is no session of ours to end, and the notice must not replay. The
44
+ * namespace follows it, and the name cannot collide with a session's (those
45
+ * start with `stwrd-session:`). */
46
+ export declare const UNNAMED_NOTICE_OBJECT = "stwrd-backchannel:unnamed";
47
+ export interface DurableObjectSessionStoreOptions {
48
+ /** Tenant/client this store serves; bound into every envelope. */
49
+ namespace: string;
50
+ keyring: Keyring;
51
+ /** Called with the reason when an envelope cannot be read (tampered,
52
+ * relocated, key gone). Default: `console.warn`. */
53
+ onUnreadable?: (error: unknown) => void;
54
+ }
55
+ export type SessionObjects = DurableObjectNamespace<StwrdSessionObject>;
56
+ export declare class DurableObjectSessionStore implements SessionStore {
57
+ private readonly objects;
58
+ private readonly options;
59
+ constructor(objects: SessionObjects, options: DurableObjectSessionStoreOptions);
60
+ private get namespace();
61
+ private objectFor;
62
+ /** Every call to an object goes through here: whatever goes wrong, the store did not answer. */
63
+ private call;
64
+ /** The back-channel logout sink for the objects of THIS store: same
65
+ * namespace, same objects. There is no way to build one with another namespace. */
66
+ backChannelSink(): BackChannelSink;
67
+ private seal;
68
+ private open;
69
+ get(sessionId: string): Promise<StwrdSession | null>;
70
+ set(session: StwrdSession): Promise<void>;
71
+ delete(sessionId: string): Promise<void>;
72
+ /** Two steps across two objects, not one: the old session is retired first
73
+ * (and only if it still exists, so a logout in between is never undone) and
74
+ * the new one installed second. A failure between them leaves no session —
75
+ * the person signs in again — and never two. */
76
+ replaceSession(oldId: string, session: StwrdSession): Promise<boolean>;
77
+ claimRefresh(sessionId: string, owner: string, leaseS: number): Promise<RefreshClaim>;
78
+ markRefreshSent(sessionId: string, fence: number): Promise<boolean>;
79
+ checkpointRefresh(sessionId: string, fence: number, tokens: Tokens, freshIdToken: boolean): Promise<boolean>;
80
+ completeRefresh(sessionId: string, fence: number, session: StwrdSession): Promise<boolean>;
81
+ releaseRefresh(sessionId: string, fence: number, sent: boolean): Promise<void>;
82
+ }
package/dist/store.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `DurableObjectSessionStore` — the `SessionStore` of `@stwrd/workers`: one
3
+ * Durable Object (`StwrdSessionObject`) per local session, named after the
4
+ * namespace (tenant/client) and the session id, like the PostgreSQL key
5
+ * `(namespace, session_id)`, so two clients that share a binding never share
6
+ * an object, and a login, a refresh and a back-channel notice for different
7
+ * sessions never queue behind each other and the lease of one session is
8
+ * decided in one place.
9
+ *
10
+ * Sessions are sealed here, in the Worker, with a `Keyring` (JWE `dir` +
11
+ * `A256GCM`, as `@stwrd/node/postgres` does): the object only ever holds the
12
+ * envelope, never the key and never a token in the clear. The authenticated
13
+ * payload carries the namespace (tenant/client), the session id and the
14
+ * purpose, so an envelope copied to another namespace or another object is
15
+ * rejected on read. Lease phase, fence and expiry live in plain columns inside
16
+ * the object because the object decides on them and they hold no secret.
17
+ *
18
+ * A store that does not answer is an IdP that does not answer: every failure of
19
+ * a call to the object is raised as `IdpUnavailable`, so every route and guard answers
20
+ * 503 and none of them reads it as "no session" (a session lost to a
21
+ * hiccup of the platform is not a session that ended).
22
+ *
23
+ * Back-channel logout goes through the same objects: `backChannelSink()` gives
24
+ * the sink, built from this store so it cannot name other objects than the
25
+ * store does (the notice's `sid` names the session's own object, which deletes
26
+ * the session and THEN marks the `jti`, in one call: `StwrdSessionObject.logout`.
27
+ * If the object does not answer, nothing was marked: the caller answers 503 and
28
+ * the IdP retries — it retries a 5xx and never a 4xx, so a `jti` marked before
29
+ * a failed deletion would turn the retry into a "replay" and leave the session
30
+ * alive for good).
31
+ */
32
+ import { IdpUnavailable } from "@stwrd/core/oidc";
33
+ import { withoutCapabilities, } from "@stwrd/core/sessions";
34
+ /** The prefix of every object name; the namespace and the session id follow it. */
35
+ export const SESSION_OBJECT_PREFIX = "stwrd-session:";
36
+ /** The name of the object that keeps one session: the namespace and the id as
37
+ * a JSON pair, so no namespace/id split can be read as another (a `|` or a
38
+ * `:` inside either cannot move the boundary). */
39
+ export function sessionObjectName(namespace, sessionId) {
40
+ return SESSION_OBJECT_PREFIX + JSON.stringify([namespace, sessionId]);
41
+ }
42
+ /** Where the `jti` of a notice that names no `sid` (only a `sub`) is kept:
43
+ * there is no session of ours to end, and the notice must not replay. The
44
+ * namespace follows it, and the name cannot collide with a session's (those
45
+ * start with `stwrd-session:`). */
46
+ export const UNNAMED_NOTICE_OBJECT = "stwrd-backchannel:unnamed";
47
+ function defaultUnreadable(error) {
48
+ console.warn("stwrd: unreadable session envelope", error);
49
+ }
50
+ export class DurableObjectSessionStore {
51
+ objects;
52
+ options;
53
+ constructor(objects, options) {
54
+ this.objects = objects;
55
+ this.options = options;
56
+ if (!options.namespace)
57
+ throw new Error("A namespace (tenant/client) is required.");
58
+ }
59
+ get namespace() {
60
+ return this.options.namespace;
61
+ }
62
+ objectFor(sessionId) {
63
+ return this.objects.get(this.objects.idFromName(sessionObjectName(this.namespace, sessionId)));
64
+ }
65
+ /** Every call to an object goes through here: whatever goes wrong, the store did not answer. */
66
+ async call(sessionId, run) {
67
+ try {
68
+ return await run(this.objectFor(sessionId));
69
+ }
70
+ catch (error) {
71
+ throw Object.assign(new IdpUnavailable("The session store did not respond.", false), { cause: error });
72
+ }
73
+ }
74
+ /** The back-channel logout sink for the objects of THIS store: same
75
+ * namespace, same objects. There is no way to build one with another namespace. */
76
+ backChannelSink() {
77
+ return {
78
+ terminate: async (sessionId, jti, untilS) => {
79
+ const name = sessionId === null ? `${UNNAMED_NOTICE_OBJECT}:${this.namespace}` : sessionObjectName(this.namespace, sessionId);
80
+ try {
81
+ return await this.objects.get(this.objects.idFromName(name)).logout(jti, untilS);
82
+ }
83
+ catch (error) {
84
+ throw Object.assign(new IdpUnavailable("The session store did not respond.", false), { cause: error });
85
+ }
86
+ },
87
+ };
88
+ }
89
+ async seal(session) {
90
+ return this.options.keyring.encrypt(JSON.stringify({ ns: this.namespace, sid: session.id, purpose: "session", session }));
91
+ }
92
+ async open(sessionId, payload) {
93
+ try {
94
+ const body = JSON.parse(await this.options.keyring.decrypt(payload));
95
+ if (body.ns !== this.namespace || body.sid !== sessionId || body.purpose !== "session") {
96
+ throw new Error("Envelope context does not match its location.");
97
+ }
98
+ return body.session;
99
+ }
100
+ catch (error) {
101
+ // Fail closed: a tampered, relocated or unreadable envelope is no session.
102
+ (this.options.onUnreadable ?? defaultUnreadable)(error);
103
+ return null;
104
+ }
105
+ }
106
+ async get(sessionId) {
107
+ const payload = await this.call(sessionId, (stub) => stub.get());
108
+ return payload === null ? null : this.open(sessionId, payload);
109
+ }
110
+ async set(session) {
111
+ const payload = await this.seal(session);
112
+ await this.call(session.id, (stub) => stub.set(payload, session.expiresAt));
113
+ }
114
+ async delete(sessionId) {
115
+ await this.call(sessionId, (stub) => stub.delete());
116
+ }
117
+ /** Two steps across two objects, not one: the old session is retired first
118
+ * (and only if it still exists, so a logout in between is never undone) and
119
+ * the new one installed second. A failure between them leaves no session —
120
+ * the person signs in again — and never two. */
121
+ async replaceSession(oldId, session) {
122
+ const retired = await this.call(oldId, (stub) => stub.retire());
123
+ if (!retired)
124
+ return false;
125
+ await this.set(session);
126
+ return true;
127
+ }
128
+ async claimRefresh(sessionId, owner, leaseS) {
129
+ const claim = await this.call(sessionId, (stub) => stub.claim(owner, leaseS));
130
+ if (claim.status !== "granted")
131
+ return { status: claim.status };
132
+ const session = await this.open(sessionId, claim.payload);
133
+ if (!session) {
134
+ // A lease on an envelope nobody can read would only block the session.
135
+ await this.call(sessionId, (stub) => stub.release(claim.fence, false));
136
+ return { status: "gone" };
137
+ }
138
+ return { status: "granted", session, fence: claim.fence, phase: claim.phase, freshIdToken: claim.freshIdToken };
139
+ }
140
+ async markRefreshSent(sessionId, fence) {
141
+ return this.call(sessionId, (stub) => stub.markSent(fence));
142
+ }
143
+ async checkpointRefresh(sessionId, fence, tokens, freshIdToken) {
144
+ // The object cannot rewrite what it cannot read: the envelope is opened
145
+ // here, the rotated tokens put in, and the lease (fence and phase) decides
146
+ // in the object whether this write still counts.
147
+ const payload = await this.call(sessionId, (stub) => stub.get());
148
+ const current = payload === null ? null : await this.open(sessionId, payload);
149
+ if (!current)
150
+ return false;
151
+ const sealed = await this.seal({ ...current, claims: withoutCapabilities(current.claims), tokens });
152
+ return this.call(sessionId, (stub) => stub.checkpoint(fence, sealed, freshIdToken));
153
+ }
154
+ async completeRefresh(sessionId, fence, session) {
155
+ if (session.id !== sessionId)
156
+ return false;
157
+ const payload = await this.seal(session);
158
+ return this.call(sessionId, (stub) => stub.complete(fence, payload, session.expiresAt));
159
+ }
160
+ async releaseRefresh(sessionId, fence, sent) {
161
+ await this.call(sessionId, (stub) => stub.release(fence, sent));
162
+ }
163
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `stwrdFor(env, options)` and `StwrdWorkers` — the object a Worker builds
3
+ * once from its `env` and uses for the `/auth/*` routes (and, further down,
4
+ * the guards). Counterpart of `Stwrd` in `@stwrd/node`: everything that is
5
+ * not about HTTP lives in `StwrdCore`, and the routes are the core's
6
+ * `Request → Response` handler.
7
+ *
8
+ * export default {
9
+ * async fetch(request: Request, env: Env) {
10
+ * const stwrd = stwrdFor(env);
11
+ * return (await stwrd.handle(request)) ?? app(request, env);
12
+ * },
13
+ * };
14
+ *
15
+ * Variables are the same as the other SDKs' (`STWRD_ISSUER`, ...), plus
16
+ * `STWRD_SESSION_KEYS` (see `sessionKeys.ts`); sessions live in the Durable
17
+ * Object bound as `STWRD_SESSIONS`. `MemoryStore` is only ever used when an app
18
+ * passes it as `options.sessions` (tests): a Worker has no memory that outlives
19
+ * a request, so it is not a default.
20
+ */
21
+ import { type RefreshTuning, StwrdCore, type StwrdOptions } from "@stwrd/core/client";
22
+ import { type AccessResult, type Resolution } from "@stwrd/core/web/access";
23
+ import { type AuthHandlerOptions } from "@stwrd/core/web/authHandler";
24
+ /** The name of the Durable Object binding an app declares in its wrangler config. */
25
+ export declare const SESSIONS_BINDING = "STWRD_SESSIONS";
26
+ /** What differs from Node when a session is renewed.
27
+ *
28
+ * workerd gives no proof that an exchange never left (its `fetch` errors carry
29
+ * no `cause.code`), so a failed exchange sends the session to `uncertain` for
30
+ * good: the IdP is asked for its discovery document, with a timeout, right
31
+ * before the exchange is marked as sent, so an IdP that is down costs a retry
32
+ * and not a session. And every poll for another owner's refresh is a call to
33
+ * a Durable Object: the wait grows (50, 100, 200, 400, then 800 ms) instead of
34
+ * polling every 50 ms for ten seconds. */
35
+ export declare const WORKERS_REFRESH_TUNING: RefreshTuning;
36
+ export interface WorkersOptions extends Partial<StwrdOptions>, AuthHandlerOptions {
37
+ }
38
+ /** What a Worker hands the adapter so a renewal outlives the request: its
39
+ * `ExecutionContext` (only `waitUntil` is used). Optional everywhere; without
40
+ * it a renewal ends with the request, as in Node. */
41
+ export type WaitUntilContext = Pick<ExecutionContext, "waitUntil">;
42
+ export interface GuardOptions {
43
+ /** Force a 401 JSON denial even for a browser navigation (the default sends
44
+ * a navigation, `Accept: text/html`, to the sign-in route instead). */
45
+ api?: boolean;
46
+ /** The Worker's `ExecutionContext`: a session renewal it starts is kept
47
+ * alive with `ctx.waitUntil`, so a person who closes the tab in the middle
48
+ * of it does not leave the session `uncertain`. */
49
+ ctx?: WaitUntilContext;
50
+ }
51
+ export interface ProtectOptions extends GuardOptions {
52
+ /** Paths that need no session: an exact path, or a prefix ending in `*`.
53
+ * Everything under the auth prefix is public without being listed. */
54
+ public?: Iterable<string>;
55
+ }
56
+ export declare class StwrdWorkers extends StwrdCore {
57
+ private readonly handler;
58
+ private readonly resolutions;
59
+ constructor(options: StwrdOptions, hooks?: AuthHandlerOptions);
60
+ /** Serves the `/auth/*` routes. `null` for anything else (another path, or a
61
+ * method the route does not take): the app routes it. A session store that
62
+ * does not answer is a 503, like an IdP that does not answer; any other
63
+ * error is the app's to see. Pass the Worker's `ctx` so a renewal survives
64
+ * the person closing the tab. */
65
+ handle(request: Request, ctx?: WaitUntilContext): Promise<Response | null>;
66
+ /** The request's session context, for a route that works with or without a
67
+ * person (no denial). `idpUnavailable` is the fact `context.user === null`
68
+ * cannot carry: the IdP or the session store did not answer, so there is no
69
+ * user to show — which is not the same as there being none. Render nothing
70
+ * that says "signed out" when it is set. The first call for a request is the
71
+ * one that reads the session, so it is the one whose `ctx` keeps a renewal alive. */
72
+ resolve(request: Request, ctx?: WaitUntilContext): Promise<Resolution>;
73
+ private guard;
74
+ /** Every guard answers `{ ok: true, context }` or `{ ok: false, response }`
75
+ * with the response that stops the request, in this order: IdP or store
76
+ * silent → 503; no user → 401 JSON (or the 303 to sign-in for a navigation);
77
+ * user but the rule fails → 403. */
78
+ requireAuth(request: Request, options?: GuardOptions): Promise<AccessResult>;
79
+ /** `org_roles` is an open registry: membership, never set equality. */
80
+ requireRole(request: Request, role: string, options?: GuardOptions): Promise<AccessResult>;
81
+ requirePermission(request: Request, permission: string, options?: GuardOptions): Promise<AccessResult>;
82
+ /** Needs the `org` scope in the configuration (`ConfigError` otherwise). */
83
+ requireOrg(request: Request, options?: GuardOptions): Promise<AccessResult>;
84
+ /** The opt-in fail-closed guard: `null` lets the request through, a
85
+ * `Response` stops it. It needs a session on every path that is not declared
86
+ * public, answering what `requireAuth` answers. */
87
+ protect(request: Request, options?: ProtectOptions): Promise<Response | null>;
88
+ }
89
+ /** The `StwrdWorkers` of this `env`: built on the first call (the options of
90
+ * that first call are the ones that count) and reused after. A later call with
91
+ * no options just gets it; one that changes a value option (issuer, client id,
92
+ * scope, ...) is a `ConfigError`, because it would be silently ignored. The
93
+ * callbacks (`onUserRegistered`, `onEvent`, `fetch`) are declared once: those
94
+ * of the first call stay and those of later calls are ignored, without error,
95
+ * so `stwrdFor(env, { onUserRegistered })` inside `fetch(req, env, ctx)` works
96
+ * on every request. Fails closed with a `ConfigError` too when a required
97
+ * variable, the `STWRD_SESSIONS` binding or `STWRD_SESSION_KEYS` is missing or
98
+ * invalid. */
99
+ export declare function stwrdFor(env: object, options?: WorkersOptions): StwrdWorkers;
package/dist/stwrd.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `stwrdFor(env, options)` and `StwrdWorkers` — the object a Worker builds
3
+ * once from its `env` and uses for the `/auth/*` routes (and, further down,
4
+ * the guards). Counterpart of `Stwrd` in `@stwrd/node`: everything that is
5
+ * not about HTTP lives in `StwrdCore`, and the routes are the core's
6
+ * `Request → Response` handler.
7
+ *
8
+ * export default {
9
+ * async fetch(request: Request, env: Env) {
10
+ * const stwrd = stwrdFor(env);
11
+ * return (await stwrd.handle(request)) ?? app(request, env);
12
+ * },
13
+ * };
14
+ *
15
+ * Variables are the same as the other SDKs' (`STWRD_ISSUER`, ...), plus
16
+ * `STWRD_SESSION_KEYS` (see `sessionKeys.ts`); sessions live in the Durable
17
+ * Object bound as `STWRD_SESSIONS`. `MemoryStore` is only ever used when an app
18
+ * passes it as `options.sessions` (tests): a Worker has no memory that outlives
19
+ * a request, so it is not a default.
20
+ */
21
+ import { StwrdCore, stwrdOptionsFromEnv } from "@stwrd/core/client";
22
+ import { ConfigError } from "@stwrd/core/config";
23
+ import { IdpUnavailable } from "@stwrd/core/oidc";
24
+ import { checkAccess, idpSilentResponse, isPublicPath, resolveAccess, } from "@stwrd/core/web/access";
25
+ import { readRequestCookie } from "@stwrd/core/web/cookies";
26
+ import { authHandler } from "@stwrd/core/web/authHandler";
27
+ import { parseSessionKeys } from "./sessionKeys.js";
28
+ import { DurableObjectSessionStore } from "./store.js";
29
+ /** The name of the Durable Object binding an app declares in its wrangler config. */
30
+ export const SESSIONS_BINDING = "STWRD_SESSIONS";
31
+ /** What differs from Node when a session is renewed.
32
+ *
33
+ * workerd gives no proof that an exchange never left (its `fetch` errors carry
34
+ * no `cause.code`), so a failed exchange sends the session to `uncertain` for
35
+ * good: the IdP is asked for its discovery document, with a timeout, right
36
+ * before the exchange is marked as sent, so an IdP that is down costs a retry
37
+ * and not a session. And every poll for another owner's refresh is a call to
38
+ * a Durable Object: the wait grows (50, 100, 200, 400, then 800 ms) instead of
39
+ * polling every 50 ms for ten seconds. */
40
+ export const WORKERS_REFRESH_TUNING = Object.freeze({
41
+ waitS: 10,
42
+ pollDelayMs: (attempt) => Math.min(50 * 2 ** attempt, 800),
43
+ probeBeforeExchange: true,
44
+ });
45
+ /** The keep-alive of a `ctx`, or `undefined` for none. A `ctx` that cannot
46
+ * `waitUntil` is a `ConfigError` before anything is read: the usual cause is
47
+ * `env` passed where the Worker's `ctx` goes, and the failure would otherwise
48
+ * show up in the middle of a renewal, after the exchange was marked as sent. */
49
+ function keepAliveOf(ctx) {
50
+ if (ctx === undefined)
51
+ return undefined;
52
+ if (typeof ctx?.waitUntil !== "function") {
53
+ throw new ConfigError("The ctx you passed has no waitUntil function: pass the ExecutionContext of the Worker (the third argument of fetch), not env.");
54
+ }
55
+ return (work) => ctx.waitUntil(work);
56
+ }
57
+ export class StwrdWorkers extends StwrdCore {
58
+ handler;
59
+ // One resolution per request object: the session is read (and maybe
60
+ // renewed) once however many guards and handlers ask.
61
+ resolutions = new WeakMap();
62
+ constructor(options, hooks = {}) {
63
+ super(options, WORKERS_REFRESH_TUNING);
64
+ this.handler = authHandler(this, hooks);
65
+ }
66
+ /** Serves the `/auth/*` routes. `null` for anything else (another path, or a
67
+ * method the route does not take): the app routes it. A session store that
68
+ * does not answer is a 503, like an IdP that does not answer; any other
69
+ * error is the app's to see. Pass the Worker's `ctx` so a renewal survives
70
+ * the person closing the tab. */
71
+ async handle(request, ctx) {
72
+ const keepAlive = keepAliveOf(ctx);
73
+ try {
74
+ return await this.handler(request, keepAlive);
75
+ }
76
+ catch (exc) {
77
+ if (exc instanceof IdpUnavailable) {
78
+ return idpSilentResponse();
79
+ }
80
+ throw exc;
81
+ }
82
+ }
83
+ // --- guards ----------------------------------------------------------------
84
+ /** The request's session context, for a route that works with or without a
85
+ * person (no denial). `idpUnavailable` is the fact `context.user === null`
86
+ * cannot carry: the IdP or the session store did not answer, so there is no
87
+ * user to show — which is not the same as there being none. Render nothing
88
+ * that says "signed out" when it is set. The first call for a request is the
89
+ * one that reads the session, so it is the one whose `ctx` keeps a renewal alive. */
90
+ async resolve(request, ctx) {
91
+ const keepAlive = keepAliveOf(ctx);
92
+ let resolution = this.resolutions.get(request);
93
+ if (!resolution) {
94
+ resolution = resolveAccess(this, readRequestCookie(request.headers, this.config.sessionCookie), keepAlive);
95
+ this.resolutions.set(request, resolution);
96
+ }
97
+ return resolution;
98
+ }
99
+ async guard(request, rule, options) {
100
+ const url = new URL(request.url);
101
+ return checkAccess(this, { accept: request.headers.get("accept"), target: url.pathname + url.search, api: options.api, resolved: await this.resolve(request, options.ctx) }, rule);
102
+ }
103
+ /** Every guard answers `{ ok: true, context }` or `{ ok: false, response }`
104
+ * with the response that stops the request, in this order: IdP or store
105
+ * silent → 503; no user → 401 JSON (or the 303 to sign-in for a navigation);
106
+ * user but the rule fails → 403. */
107
+ requireAuth(request, options = {}) {
108
+ return this.guard(request, { auth: true }, options);
109
+ }
110
+ /** `org_roles` is an open registry: membership, never set equality. */
111
+ requireRole(request, role, options = {}) {
112
+ return this.guard(request, { role }, options);
113
+ }
114
+ requirePermission(request, permission, options = {}) {
115
+ return this.guard(request, { permission }, options);
116
+ }
117
+ /** Needs the `org` scope in the configuration (`ConfigError` otherwise). */
118
+ requireOrg(request, options = {}) {
119
+ return this.guard(request, { org: true }, options);
120
+ }
121
+ /** The opt-in fail-closed guard: `null` lets the request through, a
122
+ * `Response` stops it. It needs a session on every path that is not declared
123
+ * public, answering what `requireAuth` answers. */
124
+ async protect(request, options = {}) {
125
+ keepAliveOf(options.ctx);
126
+ const url = new URL(request.url);
127
+ if (isPublicPath(url.pathname, [...(options.public ?? [])], this.config.prefix)) {
128
+ return null;
129
+ }
130
+ const result = await this.requireAuth(request, options);
131
+ return result.ok ? null : result.response;
132
+ }
133
+ }
134
+ const instances = new WeakMap();
135
+ // What defines the configuration: every option that is set and is not a
136
+ // function. Callbacks (`onUserRegistered`, `onEvent`, `fetch`) are not
137
+ // compared: declared inline in `fetch(req, env, ctx)` they are a new function
138
+ // on every request, and the first call's are the ones that count.
139
+ const present = (options) => Object.entries(options).filter(([, value]) => value !== undefined && typeof value !== "function");
140
+ function sameValue(a, b) {
141
+ if (Object.is(a, b))
142
+ return true;
143
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => Object.is(item, b[index]));
144
+ }
145
+ /** The same options: the same keys with the same values (an array by its
146
+ * items, an object by identity; functions are not compared, see `present`). */
147
+ function sameOptions(a, b) {
148
+ const left = present(a);
149
+ const right = present(b);
150
+ return left.length === right.length && left.every(([key, value]) => key in b && sameValue(value, b[key]));
151
+ }
152
+ function isObjects(value) {
153
+ const candidate = value;
154
+ return typeof candidate?.idFromName === "function" && typeof candidate?.get === "function";
155
+ }
156
+ /** The `StwrdWorkers` of this `env`: built on the first call (the options of
157
+ * that first call are the ones that count) and reused after. A later call with
158
+ * no options just gets it; one that changes a value option (issuer, client id,
159
+ * scope, ...) is a `ConfigError`, because it would be silently ignored. The
160
+ * callbacks (`onUserRegistered`, `onEvent`, `fetch`) are declared once: those
161
+ * of the first call stay and those of later calls are ignored, without error,
162
+ * so `stwrdFor(env, { onUserRegistered })` inside `fetch(req, env, ctx)` works
163
+ * on every request. Fails closed with a `ConfigError` too when a required
164
+ * variable, the `STWRD_SESSIONS` binding or `STWRD_SESSION_KEYS` is missing or
165
+ * invalid. */
166
+ export function stwrdFor(env, options = {}) {
167
+ // The core seals cookies with the `Buffer` global; without `nodejs_compat` it
168
+ // is missing and the first request would fail somewhere far from the cause.
169
+ if (typeof Buffer === "undefined") {
170
+ throw new ConfigError("The Buffer global is missing: turn on the nodejs_compat flag in wrangler.jsonc with a compatibility_date of 2024-09-23 or later (any date from 2026-08-04 on turns it on by itself).");
171
+ }
172
+ const cached = instances.get(env);
173
+ if (cached) {
174
+ if (present(options).length > 0 && !sameOptions(cached.options, options)) {
175
+ throw new ConfigError("stwrdFor was already called for this env with other options; they would be ignored. Pass the same options every time, or build the instance once (callbacks are the exception: the first call's stay).");
176
+ }
177
+ return cached.instance;
178
+ }
179
+ const { onUserRegistered, onEvent, backChannel, ...coreOverrides } = options;
180
+ const coreOptions = stwrdOptionsFromEnv(env, coreOverrides);
181
+ let sessions = coreOptions.sessions;
182
+ let sink = backChannel;
183
+ if (!sessions) {
184
+ const objects = env[SESSIONS_BINDING];
185
+ if (!isObjects(objects)) {
186
+ throw new ConfigError(`The ${SESSIONS_BINDING} Durable Object binding is missing: declare it in wrangler.jsonc (see the README).`);
187
+ }
188
+ const keyring = parseSessionKeys(env.STWRD_SESSION_KEYS);
189
+ // Sessions sealed for one issuer and client are not readable under another.
190
+ const namespace = `${coreOptions.issuer}|${coreOptions.clientId}`;
191
+ const store = new DurableObjectSessionStore(objects, { namespace, keyring });
192
+ sessions = store;
193
+ sink ??= store.backChannelSink();
194
+ }
195
+ const instance = new StwrdWorkers({ ...coreOptions, sessions }, { onUserRegistered, onEvent, backChannel: sink });
196
+ instances.set(env, { instance, options });
197
+ return instance;
198
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@stwrd/workers",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "stwrd SDK for Cloudflare Workers (backend-for-frontend profile): the /auth/* handler, route guards and a Durable Object session store.",
5
+ "keywords": [
6
+ "stwrd",
7
+ "authentication",
8
+ "oidc",
9
+ "oauth2",
10
+ "cloudflare-workers",
11
+ "durable-objects",
12
+ "bff",
13
+ "sessions"
14
+ ],
15
+ "license": "MIT",
16
+ "author": {
17
+ "name": "stwrd",
18
+ "url": "https://stwrd.dev"
19
+ },
20
+ "homepage": "https://stwrd.dev",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/stwrd-dev/sdk-js.git",
24
+ "directory": "packages/workers"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/stwrd-dev/sdk-js/issues"
28
+ },
29
+ "type": "module",
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ }
37
+ },
38
+ "sideEffects": false,
39
+ "files": [
40
+ "dist",
41
+ "!dist/.tsbuildinfo",
42
+ "README.md",
43
+ "CHANGELOG.md",
44
+ "LICENSE"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "prepack": "npm run build",
51
+ "build": "tsc -b tsconfig.json",
52
+ "test": "vitest run",
53
+ "lint": "tsc -b tsconfig.json && tsc -p tests/tsconfig.json"
54
+ },
55
+ "engines": {
56
+ "node": ">=20"
57
+ },
58
+ "dependencies": {
59
+ "@stwrd/core": "0.1.1"
60
+ },
61
+ "devDependencies": {
62
+ "@cloudflare/vitest-pool-workers": "0.22.0",
63
+ "@cloudflare/workers-types": "^5.20261007.1",
64
+ "@types/node": "^22.10.0",
65
+ "typescript": "^5.6.0",
66
+ "vitest": "^4.1.10",
67
+ "wrangler": "4.124.0"
68
+ }
69
+ }