okengine 0.5.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,16 +93,16 @@ ninth element — the set of eight is closed.
93
93
 
94
94
  ## Traditional vs OKE
95
95
 
96
- | Seam | Maintained by hand | Derived by OKE |
97
- | ------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
98
- | Behavior model | Endpoints, jobs, consumers, workflows as separate species | One species — Flow: `on(Trigger) → Effects` |
99
- | Cache invalidation | Hand-written keys; drift from writers | Derived from effects recorded through `fx` |
100
- | HTTP glue | Middleware copied per repo | Official plugins — `securityHeaders`, `cors`, `csrf`, compression, IP allowlist |
101
- | Secrets / config | Env sprawl; fails on first request | Vault contracts; `VaultBootError` at boot |
102
- | Observability | Bolted on; separate source of truth | Console from the Manifest, dev and prod (`:6533`) |
103
- | Permissions | Ad-hoc checks scattered in handlers | Least-privilege matrix from declared effects |
104
- | Local vs prod | Vendor clients; one-off compose | Protocol drivers; vendor in `images`; `oke dev --docker` |
105
- | Client / agents | Separate codegen or hand-kept schemas | Typed client and MCP (`:6535`) from the same Manifest |
96
+ | Seam | Maintained by hand | Derived by OKE |
97
+ | ------------------ | --------------------------------------------------------- | ----------------------------------------------------------------------- |
98
+ | Behavior model | Endpoints, jobs, consumers, workflows as separate species | One species — Flow: `on(Trigger) → Effects` |
99
+ | Cache invalidation | Hand-written keys; drift from writers | Derived from effects recorded through `fx` |
100
+ | HTTP glue | Middleware copied per repo | Official plugins — `headers`, `cors`, `csrf`, compression, IP allowlist |
101
+ | Secrets / config | Env sprawl; fails on first request | Vault contracts; `VaultBootError` at boot |
102
+ | Observability | Bolted on; separate source of truth | Console from the Manifest, dev and prod (`:6533`) |
103
+ | Permissions | Ad-hoc checks scattered in handlers | Least-privilege matrix from declared effects |
104
+ | Local vs prod | Vendor clients; one-off compose | Protocol drivers; vendor in `images`; `oke dev --docker` |
105
+ | Client / agents | Separate codegen or hand-kept schemas | Typed client and MCP (`:6535`) from the same Manifest |
106
106
 
107
107
  ## Ambition, stated plainly
108
108
 
@@ -2,18 +2,18 @@
2
2
  title: "Headers"
3
3
  description: "Official plugin — the complete secure-headers set on every HTTP response, failures included. Full helmet.js parity with API-first defaults, a CSP builder with report-only mode, and live DB-driven config."
4
4
  icon: "ShieldCheck"
5
- source: "src/plugins/security-headers.ts"
5
+ source: "src/plugins/headers.ts"
6
6
  ---
7
7
 
8
- `securityHeaders()` stamps security headers on **every** HTTP flow response — successes, failures, and short-circuits alike, because it runs at `onResponse`, the last pipeline stage. An explicit value your app already set is never overridden unless you ask for it.
8
+ `headers()` stamps security headers on **every** HTTP flow response — successes, failures, and short-circuits alike, because it runs at `onResponse`, the last pipeline stage. An explicit value your app already set is never overridden unless you ask for it.
9
9
 
10
10
  ## Quick start
11
11
 
12
12
  ```typescript title="src/app.ts"
13
13
  import { oke } from "okengine";
14
- import { securityHeaders } from "okengine/plugins";
14
+ import { headers } from "okengine/plugins";
15
15
 
16
- export const app = oke({ name: "shop", env: "dev" }).plug(securityHeaders());
16
+ export const app = oke({ name: "shop", env: "dev" }).plug(headers());
17
17
  ```
18
18
 
19
19
  Every HTTP response now carries:
@@ -75,7 +75,7 @@ Beyond parity: headers land on **failures too** (middleware that only wraps happ
75
75
 
76
76
  ```typescript
77
77
  .plug(
78
- securityHeaders({
78
+ headers({
79
79
  contentSecurityPolicy: {
80
80
  directives: { scriptSrc: ["'self'", "https://cdn.example.com"] }, // merged over the defaults
81
81
  reportOnly: true, // Content-Security-Policy-Report-Only while you tune
@@ -97,18 +97,18 @@ Beyond parity: headers land on **failures too** (middleware that only wraps happ
97
97
  Header policy is exactly the config you want to flip without a redeploy — enable HSTS the day HTTPS lands, tighten the CSP after an audit. Pass a `configSource()` and options follow the database within one sync interval:
98
98
 
99
99
  ```typescript
100
- const headers = configSource({
101
- plugin: "security-headers",
100
+ const headerConfig = configSource({
101
+ plugin: "headers",
102
102
  code: { hsts: false }, // safe floor for local dev
103
103
  db: { store: db },
104
104
  kv: cache,
105
105
  });
106
- on(every("30s"), headers.sync());
107
- export const app = oke({ name: "shop", env: "dev" }).plug(securityHeaders(headers));
106
+ on(every("30s"), headerConfig.sync());
107
+ export const app = oke({ name: "shop", env: "dev" }).plug(headers(headerConfig));
108
108
  ```
109
109
 
110
110
  ```sql
111
- INSERT INTO security_headers_config ("key", "value")
111
+ INSERT INTO headers_config ("key", "value")
112
112
  VALUES ('config', '{"hsts": true}');
113
113
  ```
114
114
 
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  title: "Passkey"
3
- description: "Official plugin — simplified WebAuthn register and authenticate under /auth."
3
+ description: "Official plugin — WebAuthn register and authenticate under /auth with signature and origin checks."
4
4
  icon: "FingerprintPattern"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
- `passkey()` adds register and authenticate Flows for passkey-shaped credentials (`oke_passkeys`).
9
- v1 is a simplified ceremony options return a challenge; register/authenticate accept the
10
- payload your client posts (not a full browser WebAuthn SDK).
8
+ `passkey()` adds register and authenticate Flows for WebAuthn credentials (`oke_passkeys`).
9
+ Options return a challenge; register and authenticate verify `clientDataJSON` origin + challenge,
10
+ `authenticatorData` rpId hash, and an ECDSA P-256 signature against the stored SPKI public key.
11
11
 
12
12
  <Callout title="The one rule">
13
13
  Enable `gate.auth`, then `.plug(passkey())`. Registration needs a Bearer session; authenticate is
14
- public. Wire `navigator.credentials` (or tests) yourselfthe plugin stores and verifies the
15
- posted fields.
14
+ public. Post the full ceremony fieldspresence of a stored credential alone never issues a
15
+ session.
16
16
  </Callout>
17
17
 
18
18
  ## Quick start
@@ -30,7 +30,7 @@ export const app = oke({
30
30
  name: "shop",
31
31
  env: "local",
32
32
  gate: { auth: {} },
33
- }).plug(passkey());
33
+ }).plug(passkey({ origins: ["http://localhost", "https://localhost"] }));
34
34
  ```
35
35
 
36
36
  </Step>
@@ -45,10 +45,13 @@ const opts = await api.auth.passkeyRegisterOptions({});
45
45
  // opts.data: { challenge, rpId, userId }
46
46
 
47
47
  await api.auth.passkeyRegister({
48
- credentialId: "...",
49
- publicKey: "...",
48
+ credentialId: "...", // base64url
49
+ publicKey: "...", // base64url SPKI (ECDSA P-256)
50
50
  userId: opts.data!.userId,
51
51
  challenge: opts.data!.challenge,
52
+ clientDataJSON: "...", // base64url JSON { type, challenge, origin }
53
+ authenticatorData: "...", // base64url
54
+ signature: "...", // base64url ECDSA over authData || SHA-256(clientDataJSON)
52
55
  });
53
56
  ```
54
57
 
@@ -60,15 +63,18 @@ Paths: `POST /auth/passkey/register/options`, `POST /auth/passkey/register`.
60
63
  ### Authenticate
61
64
 
62
65
  ```typescript
63
- await api.auth.passkeyAuthenticateOptions({});
66
+ const opts = await api.auth.passkeyAuthenticateOptions({});
64
67
  const { data } = await api.auth.passkeyAuthenticate({
65
68
  credentialId: "...",
66
- userId: "...",
69
+ challenge: opts.data!.challenge,
70
+ clientDataJSON: "...",
71
+ authenticatorData: "...",
72
+ signature: "...",
67
73
  });
68
74
  ```
69
75
 
70
76
  Paths: `POST /auth/passkey/authenticate/options`, `POST /auth/passkey/authenticate`.
71
- v1 issues a session when the stored credential matches `userId`.
77
+ Challenges are single-use; wrong origin `invalid_origin`; bad signature `invalid_credentials`.
72
78
 
73
79
  </Step>
74
80
 
@@ -76,14 +82,15 @@ v1 issues a session when the stored credential matches `userId`.
76
82
 
77
83
  ## Options
78
84
 
79
- | Option | Type | Default | Meaning |
80
- | ------------ | ------------------- | ------------- | -------------------------------- |
81
- | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
82
- | `sessions` | `SessionStore` | active\* | Session store |
83
- | `now` | `() => number` | `Date.now` | Injectable clock |
84
- | `passkeys` | `PasskeyStore` | new | Credential → user mapping |
85
- | `challenges` | `VerificationStore` | new | Registration / auth challenges |
86
- | `rpId` | `string` | `"localhost"` | Relying party id in options |
85
+ | Option | Type | Default | Meaning |
86
+ | ------------ | ------------------- | ------------------------------------------ | -------------------------------- |
87
+ | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
88
+ | `sessions` | `SessionStore` | active\* | Session store |
89
+ | `now` | `() => number` | `Date.now` | Injectable clock |
90
+ | `passkeys` | `PasskeyStore` | new | Credential → user mapping |
91
+ | `challenges` | `VerificationStore` | new | Registration / auth challenges |
92
+ | `rpId` | `string` | `"localhost"` | Relying party id |
93
+ | `origins` | `string[]` | `["http://localhost","https://localhost"]` | Allowed `clientDataJSON.origin` |
87
94
 
88
95
  ## Surfaces
89
96
 
@@ -94,8 +101,8 @@ v1 issues a session when the stored credential matches `userId`.
94
101
  | `auth.passkeyAuthenticateOptions` | `POST /auth/passkey/authenticate/options` | `gate.public` + otp rate |
95
102
  | `auth.passkeyAuthenticate` | `POST /auth/passkey/authenticate` | `gate.public` + otp rate |
96
103
 
97
- **Consequence:** production apps should replace the simplified verify with a standards-compliant
98
- WebAuthn verifier when you leave local/test.
104
+ **Consequence:** a stolen `credentialId` without the private key cannot mint a session. Set
105
+ `origins` to your real app origins before production.
99
106
 
100
107
  ## Troubleshooting
101
108
 
@@ -104,11 +111,16 @@ WebAuthn verifier when you leave local/test.
104
111
 
105
112
  Sign in with another method first. `userId` in the body must match the Bearer session.
106
113
 
114
+ </Accordion>
115
+ <Accordion title="authenticate returns invalid_origin">
116
+
117
+ `clientDataJSON.origin` must be in `passkey({ origins })`. Default allows only localhost HTTP/S.
118
+
107
119
  </Accordion>
108
120
  <Accordion title="authenticate returns invalid_credentials">
109
121
 
110
- Unknown `credentialId`, or `userId` does not match the stored credential. Re-run registration
111
- after a successful session.
122
+ Unknown `credentialId`, consumed/expired challenge, bad signature, or rpId hash mismatch.
123
+ Re-run authenticate options for a fresh challenge, then sign with the enrolled private key.
112
124
 
113
125
  </Accordion>
114
126
  </Accordions>
@@ -7,6 +7,7 @@ source: "docs/spec/unified-theory.md"
7
7
 
8
8
  `twoFactor()` adds RFC 6238 TOTP (HMAC-SHA1, 6 digits, 30s) after someone already has a session.
9
9
  Enable returns a secret, `otpauth://` URL, and recovery codes; verify mints a new session.
10
+ TOTP codes are compared in constant time (±1 step window).
10
11
 
11
12
  <Callout title="The one rule">
12
13
  Enable `gate.auth`, then `.plug(twoFactor())`. Enable and disable need a Bearer session —
@@ -103,7 +104,7 @@ enable. A recovery code works once, then is consumed.
103
104
 
104
105
  ## Learn more
105
106
 
106
- - [Passkey](/docs/plugins/passkey) — WebAuthn-shaped register / authenticate
107
+ - [Passkey](/docs/plugins/passkey) — WebAuthn register / authenticate
107
108
  - [Gate](/docs/elements/gate) — session + policies
108
109
  - [Username](/docs/plugins/username) — first factor to enroll against
109
110
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Constant-time equality for auth secrets (OTP, claim codes, challenges).
3
+ */
4
+
5
+ /**
6
+ * Constant-time string equality (UTF-16 code units).
7
+ * Length mismatches still scan to `max(len)` so timing does not leak length
8
+ * beyond the longer operand.
9
+ *
10
+ * @param a - Expected
11
+ * @param b - Candidate
12
+ */
13
+ export function constantTimeEqual(a: string, b: string): boolean {
14
+ const len = Math.max(a.length, b.length);
15
+ let mismatch = a.length === b.length ? 0 : 1;
16
+ for (let i = 0; i < len; i++) {
17
+ const ac = a.charCodeAt(i) || 0;
18
+ const bc = b.charCodeAt(i) || 0;
19
+ mismatch |= ac ^ bc;
20
+ }
21
+ return mismatch === 0;
22
+ }
package/src/auth/index.ts CHANGED
@@ -75,6 +75,8 @@ export {
75
75
  type VerificationRow,
76
76
  } from "./verification.ts";
77
77
 
78
+ export { constantTimeEqual } from "./constant-time.ts";
79
+
78
80
  export {
79
81
  createIdentityStore,
80
82
  createUserWithPassword,
@@ -2,7 +2,7 @@
2
2
  * Gate: named competitor comparisons stay gone from the tracked tree.
3
3
  *
4
4
  * Mirrors {@link ./doc-staleness.test.ts} — `git grep` must find zero hits
5
- * (the security-headers decoy header fixture may still use a common
5
+ * (the headers decoy header fixture may still use a common
6
6
  * `X-Powered-By` value; that path is allow-listed).
7
7
  */
8
8
 
@@ -28,8 +28,8 @@ const FORBIDDEN_COMPARISON_PATH = ["get-started/", "comparison"].join("");
28
28
  /** Word-boundary classic Node framework — `-F` false-positives on `CallExpression`. */
29
29
  const FORBIDDEN_EXPRESS = ["\\b", "Ex", "press", "\\b"].join("");
30
30
 
31
- /** Decoy `X-Powered-By` fixture value in the security-headers plugin tests. */
32
- const EXPRESS_ALLOW = ["src/plugins/security-headers.test.ts:"];
31
+ /** Decoy `X-Powered-By` fixture value in the headers plugin tests. */
32
+ const EXPRESS_ALLOW = ["src/plugins/headers.test.ts:"];
33
33
 
34
34
  /**
35
35
  * Run `git grep -F` and assert zero matches after ignoring allow-listed paths.