okengine 0.4.3 → 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 +3 -1
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/gate.mdx +187 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +4 -3
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +11 -11
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +140 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +117 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/fx.mdx +20 -5
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +119 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/index.ts +11 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +60 -0
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +12 -0
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.security.test.ts +762 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +12 -12
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +26 -26
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +42 -3
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +282 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +253 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/plugins/security-headers.ts +0 -255
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 Gate auth method plugins — username, anonymous, magic-link, email OTP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { oke } from "../kernel/app.ts";
|
|
7
|
+
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
8
|
+
import { resetBindings } from "../kernel/on.ts";
|
|
9
|
+
import { anonymous } from "./anonymous.ts";
|
|
10
|
+
import { emailOtp } from "./email-otp.ts";
|
|
11
|
+
import { magicLink } from "./magic-link.ts";
|
|
12
|
+
import { username } from "./username.ts";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetBindings();
|
|
16
|
+
resetFlowSeq();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function authApp() {
|
|
20
|
+
return oke({
|
|
21
|
+
name: "auth-plugins",
|
|
22
|
+
env: "test",
|
|
23
|
+
registry: "ignore",
|
|
24
|
+
gate: {
|
|
25
|
+
auth: {
|
|
26
|
+
secret: "test-secret-at-least-16",
|
|
27
|
+
emailAndPassword: { enabled: true },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
.plug(username())
|
|
32
|
+
.plug(anonymous())
|
|
33
|
+
.plug(magicLink({ exposeDevToken: true }))
|
|
34
|
+
.plug(emailOtp({ exposeDevOtp: true }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("auth method plugins", () => {
|
|
38
|
+
test("bindings appear on router after .plug()", async () => {
|
|
39
|
+
const app = authApp();
|
|
40
|
+
expect(app.router.match("POST", "/auth/sign-up/username")).toBeTruthy();
|
|
41
|
+
expect(app.router.match("POST", "/auth/sign-in/username")).toBeTruthy();
|
|
42
|
+
expect(app.router.match("POST", "/auth/sign-in/anonymous")).toBeTruthy();
|
|
43
|
+
expect(app.router.match("POST", "/auth/magic-link/request")).toBeTruthy();
|
|
44
|
+
expect(app.router.match("POST", "/auth/magic-link/verify")).toBeTruthy();
|
|
45
|
+
expect(app.router.match("POST", "/auth/email-otp/request")).toBeTruthy();
|
|
46
|
+
expect(app.router.match("POST", "/auth/email-otp/verify")).toBeTruthy();
|
|
47
|
+
await app.boot({ env: "test" });
|
|
48
|
+
await app.stop();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("username sign-up + sign-in", async () => {
|
|
52
|
+
const app = authApp();
|
|
53
|
+
await app.boot({ env: "test" });
|
|
54
|
+
|
|
55
|
+
const signUp = await app.fetch(
|
|
56
|
+
new Request("http://localhost/auth/sign-up/username", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "content-type": "application/json" },
|
|
59
|
+
body: JSON.stringify({ username: "alice", password: "CorrectHorse1" }),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
expect(signUp.status).toBe(200);
|
|
63
|
+
const created = (await signUp.json()) as {
|
|
64
|
+
data: { accessToken: string; userId: string };
|
|
65
|
+
};
|
|
66
|
+
expect(created.data.accessToken).toBeTruthy();
|
|
67
|
+
expect(created.data.userId).toBeTruthy();
|
|
68
|
+
|
|
69
|
+
const bad = await app.fetch(
|
|
70
|
+
new Request("http://localhost/auth/sign-in/username", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "content-type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ username: "alice", password: "wrong-password" }),
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
const badBody = (await bad.json()) as { error: { data?: { reason?: string } } };
|
|
77
|
+
expect(badBody.error.data?.reason).toBe("invalid_credentials");
|
|
78
|
+
|
|
79
|
+
const signIn = await app.fetch(
|
|
80
|
+
new Request("http://localhost/auth/sign-in/username", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ username: "alice", password: "CorrectHorse1" }),
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
expect(signIn.status).toBe(200);
|
|
87
|
+
const session = (await signIn.json()) as { data: { accessToken: string } };
|
|
88
|
+
expect(session.data.accessToken).toBeTruthy();
|
|
89
|
+
|
|
90
|
+
await app.stop();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("anonymous sign-in issues session", async () => {
|
|
94
|
+
const app = authApp();
|
|
95
|
+
await app.boot({ env: "test" });
|
|
96
|
+
|
|
97
|
+
const res = await app.fetch(
|
|
98
|
+
new Request("http://localhost/auth/sign-in/anonymous", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
101
|
+
body: "{}",
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
expect(res.status).toBe(200);
|
|
105
|
+
const body = (await res.json()) as {
|
|
106
|
+
data: { accessToken: string; userId: string };
|
|
107
|
+
};
|
|
108
|
+
expect(body.data.accessToken).toBeTruthy();
|
|
109
|
+
expect(body.data.userId).toBeTruthy();
|
|
110
|
+
|
|
111
|
+
await app.stop();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("magic-link request + verify with devToken", async () => {
|
|
115
|
+
const app = authApp();
|
|
116
|
+
await app.boot({ env: "test" });
|
|
117
|
+
|
|
118
|
+
const req = await app.fetch(
|
|
119
|
+
new Request("http://localhost/auth/magic-link/request", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ email: "ml@example.com" }),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
expect(req.status).toBe(200);
|
|
126
|
+
const requested = (await req.json()) as { data: { ok: true; devToken?: string } };
|
|
127
|
+
expect(requested.data.ok).toBe(true);
|
|
128
|
+
expect(requested.data.devToken).toBeTruthy();
|
|
129
|
+
|
|
130
|
+
const verify = await app.fetch(
|
|
131
|
+
new Request("http://localhost/auth/magic-link/verify", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ token: requested.data.devToken }),
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
expect(verify.status).toBe(200);
|
|
138
|
+
const session = (await verify.json()) as { data: { accessToken: string; userId: string } };
|
|
139
|
+
expect(session.data.accessToken).toBeTruthy();
|
|
140
|
+
expect(session.data.userId).toBeTruthy();
|
|
141
|
+
|
|
142
|
+
await app.stop();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("email OTP request + verify with devOtp", async () => {
|
|
146
|
+
const app = authApp();
|
|
147
|
+
await app.boot({ env: "test" });
|
|
148
|
+
|
|
149
|
+
const req = await app.fetch(
|
|
150
|
+
new Request("http://localhost/auth/email-otp/request", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "content-type": "application/json" },
|
|
153
|
+
body: JSON.stringify({ email: "otp@example.com" }),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
expect(req.status).toBe(200);
|
|
157
|
+
const requested = (await req.json()) as { data: { ok: true; devOtp?: string } };
|
|
158
|
+
expect(requested.data.devOtp).toMatch(/^\d{6}$/);
|
|
159
|
+
|
|
160
|
+
const verify = await app.fetch(
|
|
161
|
+
new Request("http://localhost/auth/email-otp/verify", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json" },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
email: "otp@example.com",
|
|
166
|
+
otp: requested.data.devOtp,
|
|
167
|
+
}),
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
expect(verify.status).toBe(200);
|
|
171
|
+
const session = (await verify.json()) as { data: { accessToken: string } };
|
|
172
|
+
expect(session.data.accessToken).toBeTruthy();
|
|
173
|
+
|
|
174
|
+
await app.stop();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -38,7 +38,7 @@ describe("acceptsGzip", () => {
|
|
|
38
38
|
describe("compression plugin", () => {
|
|
39
39
|
test("gzips a large JSON body when the client accepts gzip", async () => {
|
|
40
40
|
on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
|
|
41
|
-
const app = oke({ name: "zip" }).plug(compression());
|
|
41
|
+
const app = oke({ autoBoot: false, name: "zip" }).plug(compression());
|
|
42
42
|
|
|
43
43
|
const res = await app.fetch(
|
|
44
44
|
new Request("http://localhost/big", { headers: { "accept-encoding": "gzip" } }),
|
|
@@ -59,7 +59,7 @@ describe("compression plugin", () => {
|
|
|
59
59
|
|
|
60
60
|
test("passes through untouched without Accept-Encoding: gzip", async () => {
|
|
61
61
|
on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
|
|
62
|
-
const app = oke({ name: "zip-plain" }).plug(compression());
|
|
62
|
+
const app = oke({ autoBoot: false, name: "zip-plain" }).plug(compression());
|
|
63
63
|
|
|
64
64
|
const res = await app.fetch(new Request("http://localhost/big"));
|
|
65
65
|
|
|
@@ -70,7 +70,7 @@ describe("compression plugin", () => {
|
|
|
70
70
|
|
|
71
71
|
test("skips bodies under minSize", async () => {
|
|
72
72
|
on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
|
|
73
|
-
const app = oke({ name: "zip-small" }).plug(compression());
|
|
73
|
+
const app = oke({ autoBoot: false, name: "zip-small" }).plug(compression());
|
|
74
74
|
|
|
75
75
|
const res = await app.fetch(
|
|
76
76
|
new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
|
|
@@ -81,7 +81,7 @@ describe("compression plugin", () => {
|
|
|
81
81
|
|
|
82
82
|
test("minSize: 0 compresses even tiny bodies", async () => {
|
|
83
83
|
on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
|
|
84
|
-
const app = oke({ name: "zip-zero" }).plug(compression({ minSize: 0 }));
|
|
84
|
+
const app = oke({ autoBoot: false, name: "zip-zero" }).plug(compression({ minSize: 0 }));
|
|
85
85
|
|
|
86
86
|
const res = await app.fetch(
|
|
87
87
|
new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
|
|
@@ -112,7 +112,7 @@ describe("compression plugin", () => {
|
|
|
112
112
|
),
|
|
113
113
|
}),
|
|
114
114
|
);
|
|
115
|
-
const app = oke({ name: "zip-skip" }).plug(compression());
|
|
115
|
+
const app = oke({ autoBoot: false, name: "zip-skip" }).plug(compression());
|
|
116
116
|
|
|
117
117
|
const img = await app.fetch(
|
|
118
118
|
new Request("http://localhost/img", { headers: { "accept-encoding": "gzip" } }),
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
withConfigTable,
|
|
12
12
|
type ConfigSource,
|
|
13
13
|
} from "./config-source.ts";
|
|
14
|
-
import { appendVary } from "./headers.ts";
|
|
14
|
+
import { appendVary } from "./response-headers.ts";
|
|
15
15
|
|
|
16
16
|
/** Options for {@link compression}. */
|
|
17
17
|
export interface CompressionOptions {
|
|
@@ -19,7 +19,7 @@ import { createPluginRegistry } from "../kernel/registry.ts";
|
|
|
19
19
|
import { http } from "../kernel/triggers.ts";
|
|
20
20
|
import { configSource, resolvePluginOptions } from "./config-source.ts";
|
|
21
21
|
import { maintenanceMode } from "./maintenance-mode.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { headers } from "./headers.ts";
|
|
23
23
|
|
|
24
24
|
beforeEach(() => {
|
|
25
25
|
resetBindings();
|
|
@@ -74,7 +74,7 @@ describe("configSource — box semantics", () => {
|
|
|
74
74
|
};
|
|
75
75
|
const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
|
|
76
76
|
|
|
77
|
-
const table = defineTable("
|
|
77
|
+
const table = defineTable("headers_config", { key: true, value: true });
|
|
78
78
|
const sql = fx.store(db) as SqlStoreHandle;
|
|
79
79
|
await sql.ensureTable(table);
|
|
80
80
|
await sql
|
|
@@ -83,7 +83,7 @@ describe("configSource — box semantics", () => {
|
|
|
83
83
|
.execute();
|
|
84
84
|
|
|
85
85
|
const source = configSource({
|
|
86
|
-
plugin: "
|
|
86
|
+
plugin: "headers",
|
|
87
87
|
code: { hsts: false },
|
|
88
88
|
db: { store: db },
|
|
89
89
|
kv: cfg,
|
|
@@ -110,11 +110,11 @@ describe("configSource — box semantics", () => {
|
|
|
110
110
|
|
|
111
111
|
const effects = { reads: ["sql:cfgdb" as const], writes: ["sql:cfgdb" as const] };
|
|
112
112
|
const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
|
|
113
|
-
const table = defineTable("
|
|
113
|
+
const table = defineTable("headers_config", { key: true, value: true });
|
|
114
114
|
await (fx.store(db) as SqlStoreHandle).ensureTable(table);
|
|
115
115
|
|
|
116
116
|
const source = configSource({
|
|
117
|
-
plugin: "
|
|
117
|
+
plugin: "headers",
|
|
118
118
|
code: { frameOptions: "DENY" as const },
|
|
119
119
|
db: { store: db },
|
|
120
120
|
});
|
|
@@ -132,13 +132,13 @@ describe("configSource — box semantics", () => {
|
|
|
132
132
|
|
|
133
133
|
const effects = { reads: ["sql:cfgdb" as const], writes: ["sql:cfgdb" as const] };
|
|
134
134
|
const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
|
|
135
|
-
const table = defineTable("
|
|
135
|
+
const table = defineTable("headers_config", { key: true, value: true });
|
|
136
136
|
const sql = fx.store(db) as SqlStoreHandle;
|
|
137
137
|
await sql.ensureTable(table);
|
|
138
138
|
await sql.insert(table).values({ key: "config", value: "{not json" }).execute();
|
|
139
139
|
|
|
140
140
|
const source = configSource({
|
|
141
|
-
plugin: "
|
|
141
|
+
plugin: "headers",
|
|
142
142
|
code: {},
|
|
143
143
|
db: { store: db },
|
|
144
144
|
});
|
|
@@ -156,7 +156,7 @@ describe("configSource — plugin wiring", () => {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
159
|
-
const app = oke({ name: "cfg-live" }).plug(maintenanceMode(source));
|
|
159
|
+
const app = oke({ autoBoot: false, name: "cfg-live" }).plug(maintenanceMode(source));
|
|
160
160
|
|
|
161
161
|
expect((await app.fetch(new Request("http://localhost/x"))).status).toBe(200);
|
|
162
162
|
|
|
@@ -171,17 +171,17 @@ describe("configSource — plugin wiring", () => {
|
|
|
171
171
|
test("DB-backed sources contribute their config table; static options do not", () => {
|
|
172
172
|
const db = store.sql("cfgdb");
|
|
173
173
|
const source = configSource({
|
|
174
|
-
plugin: "
|
|
174
|
+
plugin: "headers",
|
|
175
175
|
code: {},
|
|
176
176
|
db: { store: db },
|
|
177
177
|
});
|
|
178
178
|
|
|
179
179
|
const registry = createPluginRegistry();
|
|
180
|
-
const registration = registry.plug(
|
|
181
|
-
expect(registration?.tables.map((t) => t.name)).toEqual(["
|
|
180
|
+
const registration = registry.plug(headers(source), { kind: "app" });
|
|
181
|
+
expect(registration?.tables.map((t) => t.name)).toEqual(["headers_config"]);
|
|
182
182
|
|
|
183
183
|
const staticRegistry = createPluginRegistry();
|
|
184
|
-
const staticRegistration = staticRegistry.plug(
|
|
184
|
+
const staticRegistration = staticRegistry.plug(headers({}), { kind: "app" });
|
|
185
185
|
expect(staticRegistration?.tables).toEqual([]);
|
|
186
186
|
});
|
|
187
187
|
|
|
@@ -35,8 +35,8 @@ export interface ConfigSourceDb {
|
|
|
35
35
|
/** Options for {@link configSource}. */
|
|
36
36
|
export interface ConfigSourceOptions<T extends object> {
|
|
37
37
|
/**
|
|
38
|
-
* Plugin id this config belongs to (e.g. `"
|
|
39
|
-
*
|
|
38
|
+
* Plugin id this config belongs to (e.g. `"headers"`) — derives the DB
|
|
39
|
+
* row key, the KV key, and the default table name.
|
|
40
40
|
*/
|
|
41
41
|
readonly plugin: string;
|
|
42
42
|
/**
|
package/src/plugins/cors.test.ts
CHANGED
|
@@ -29,7 +29,9 @@ function preflight(path = "/x", headers: Record<string, string> = {}): Request {
|
|
|
29
29
|
describe("cors plugin — preflight", () => {
|
|
30
30
|
test("answers preflight for a path bound to another method (edge)", async () => {
|
|
31
31
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
32
|
-
const app = oke({ name: "cors-pre" }).plug(
|
|
32
|
+
const app = oke({ autoBoot: false, name: "cors-pre" }).plug(
|
|
33
|
+
cors({ origin: "https://app.example.com" }),
|
|
34
|
+
);
|
|
33
35
|
|
|
34
36
|
const res = await app.fetch(preflight());
|
|
35
37
|
|
|
@@ -41,7 +43,9 @@ describe("cors plugin — preflight", () => {
|
|
|
41
43
|
|
|
42
44
|
test("denied origins get a 204 without CORS headers — the browser blocks quietly", async () => {
|
|
43
45
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
44
|
-
const app = oke({ name: "cors-deny" }).plug(
|
|
46
|
+
const app = oke({ autoBoot: false, name: "cors-deny" }).plug(
|
|
47
|
+
cors({ origin: "https://other.example.com" }),
|
|
48
|
+
);
|
|
45
49
|
|
|
46
50
|
const res = await app.fetch(preflight());
|
|
47
51
|
|
|
@@ -51,7 +55,7 @@ describe("cors plugin — preflight", () => {
|
|
|
51
55
|
|
|
52
56
|
test("no origin configured answers nothing — cross-origin closed by default", async () => {
|
|
53
57
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
54
|
-
const app = oke({ name: "cors-closed" }).plug(cors());
|
|
58
|
+
const app = oke({ autoBoot: false, name: "cors-closed" }).plug(cors());
|
|
55
59
|
|
|
56
60
|
const res = await app.fetch(preflight());
|
|
57
61
|
expect(res.headers.get("access-control-allow-origin")).toBeNull();
|
|
@@ -59,7 +63,7 @@ describe("cors plugin — preflight", () => {
|
|
|
59
63
|
|
|
60
64
|
test("a plain unmatched OPTIONS without preflight headers still 404s", async () => {
|
|
61
65
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
62
|
-
const app = oke({ name: "cors-404" }).plug(cors({ origin: "*" }));
|
|
66
|
+
const app = oke({ autoBoot: false, name: "cors-404" }).plug(cors({ origin: "*" }));
|
|
63
67
|
|
|
64
68
|
const res = await app.fetch(new Request("http://localhost/x", { method: "OPTIONS" }));
|
|
65
69
|
expect(res.status).toBe(404);
|
|
@@ -67,7 +71,7 @@ describe("cors plugin — preflight", () => {
|
|
|
67
71
|
|
|
68
72
|
test("reflects request headers by default, honors configured lists and maxAge", async () => {
|
|
69
73
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
70
|
-
const app = oke({ name: "cors-hdr" }).plug(
|
|
74
|
+
const app = oke({ autoBoot: false, name: "cors-hdr" }).plug(
|
|
71
75
|
cors({ origin: "*", maxAge: 600, allowedHeaders: ["x-custom"] }),
|
|
72
76
|
);
|
|
73
77
|
|
|
@@ -88,7 +92,7 @@ describe("cors plugin — preflight", () => {
|
|
|
88
92
|
|
|
89
93
|
test("allowlist + credentials reflects only listed origins", async () => {
|
|
90
94
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
91
|
-
const app = oke({ name: "cors-cred" }).plug(
|
|
95
|
+
const app = oke({ autoBoot: false, name: "cors-cred" }).plug(
|
|
92
96
|
cors({ origin: ["https://app.example.com", "https://admin.example.com"], credentials: true }),
|
|
93
97
|
);
|
|
94
98
|
|
|
@@ -106,7 +110,7 @@ describe("cors plugin — preflight", () => {
|
|
|
106
110
|
describe("cors plugin — actual requests", () => {
|
|
107
111
|
test("allowed origins get allow-origin (+ exposed headers) on matched responses", async () => {
|
|
108
112
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
109
|
-
const app = oke({ name: "cors-actual" }).plug(
|
|
113
|
+
const app = oke({ autoBoot: false, name: "cors-actual" }).plug(
|
|
110
114
|
cors({ origin: ["https://app.example.com"], exposedHeaders: ["x-total"] }),
|
|
111
115
|
);
|
|
112
116
|
|
|
@@ -121,7 +125,7 @@ describe("cors plugin — actual requests", () => {
|
|
|
121
125
|
|
|
122
126
|
test("allowlist + credentials on matched responses reflects only listed origins", async () => {
|
|
123
127
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
124
|
-
const app = oke({ name: "cors-actual-cred" }).plug(
|
|
128
|
+
const app = oke({ autoBoot: false, name: "cors-actual-cred" }).plug(
|
|
125
129
|
cors({ origin: ["https://app.example.com"], credentials: true }),
|
|
126
130
|
);
|
|
127
131
|
|
|
@@ -142,7 +146,9 @@ describe("cors plugin — actual requests", () => {
|
|
|
142
146
|
|
|
143
147
|
test("denied origins get no CORS headers on matched responses", async () => {
|
|
144
148
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
145
|
-
const app = oke({ name: "cors-actual-deny" }).plug(
|
|
149
|
+
const app = oke({ autoBoot: false, name: "cors-actual-deny" }).plug(
|
|
150
|
+
cors({ origin: "https://ok.example.com" }),
|
|
151
|
+
);
|
|
146
152
|
|
|
147
153
|
const res = await app.fetch(
|
|
148
154
|
new Request("http://localhost/x", { headers: { origin: "https://evil.example.com" } }),
|
|
@@ -154,7 +160,7 @@ describe("cors plugin — actual requests", () => {
|
|
|
154
160
|
|
|
155
161
|
test("requests without an Origin header are untouched", async () => {
|
|
156
162
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
157
|
-
const app = oke({ name: "cors-no-origin" }).plug(cors({ origin: "*" }));
|
|
163
|
+
const app = oke({ autoBoot: false, name: "cors-no-origin" }).plug(cors({ origin: "*" }));
|
|
158
164
|
|
|
159
165
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
160
166
|
expect(res.headers.get("access-control-allow-origin")).toBeNull();
|
package/src/plugins/cors.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
isConfigSource,
|
|
14
14
|
type ConfigSource,
|
|
15
15
|
} from "./config-source.ts";
|
|
16
|
-
import { appendVary, withHeaders } from "./headers.ts";
|
|
16
|
+
import { appendVary, withHeaders } from "./response-headers.ts";
|
|
17
17
|
|
|
18
18
|
/** Options for {@link cors}. */
|
|
19
19
|
export interface CorsOptions {
|
package/src/plugins/csrf.test.ts
CHANGED
|
@@ -22,7 +22,7 @@ function mutating(headers: Record<string, string> = {}): Request {
|
|
|
22
22
|
function appWith(options: Parameters<typeof csrf>[0]) {
|
|
23
23
|
on(http.post("/transfer"), flow({ name: "transfer.create", do: () => ({ moved: true }) }));
|
|
24
24
|
on(http.get("/balance"), flow({ name: "balance.get", do: () => ({ balance: 5 }) }));
|
|
25
|
-
return oke({ name: `csrf-${crypto.randomUUID()}` }).plug(csrf(options));
|
|
25
|
+
return oke({ autoBoot: false, name: `csrf-${crypto.randomUUID()}` }).plug(csrf(options));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
describe("csrf plugin", () => {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email OTP Gate auth method plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createIdentityStore,
|
|
7
|
+
normalizeEmail,
|
|
8
|
+
type IdentityStore,
|
|
9
|
+
type UserIdentityRow,
|
|
10
|
+
} from "../auth/identity.ts";
|
|
11
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
12
|
+
import {
|
|
13
|
+
createVerificationStore,
|
|
14
|
+
findActiveVerification,
|
|
15
|
+
generateOtp,
|
|
16
|
+
hashChallenge,
|
|
17
|
+
putVerification,
|
|
18
|
+
type VerificationStore,
|
|
19
|
+
} from "../auth/verification.ts";
|
|
20
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
21
|
+
import {
|
|
22
|
+
AuthFailed,
|
|
23
|
+
AuthRateLimited,
|
|
24
|
+
SessionTokensOut,
|
|
25
|
+
bindPublicAuth,
|
|
26
|
+
createMethodRuntime,
|
|
27
|
+
fail,
|
|
28
|
+
flow,
|
|
29
|
+
z,
|
|
30
|
+
type AuthMethodOptions,
|
|
31
|
+
} from "./auth/shared.ts";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
34
|
+
const MAX_ATTEMPTS = 5;
|
|
35
|
+
|
|
36
|
+
/** Options for {@link emailOtp}. */
|
|
37
|
+
export interface EmailOtpOptions extends AuthMethodOptions {
|
|
38
|
+
/** Challenge TTL (default 10m). */
|
|
39
|
+
readonly ttlMs?: number;
|
|
40
|
+
readonly identities?: IdentityStore;
|
|
41
|
+
readonly verifications?: VerificationStore;
|
|
42
|
+
/** Return raw OTP in the request response (test / local). */
|
|
43
|
+
readonly exposeDevOtp?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Email OTP request + verify (6-digit, hashed, 5 attempts).
|
|
48
|
+
*
|
|
49
|
+
* @param opts - TTL / stores / dev OTP
|
|
50
|
+
*/
|
|
51
|
+
export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
|
|
52
|
+
const runtime = createMethodRuntime(opts);
|
|
53
|
+
const identities = opts.identities ?? createIdentityStore();
|
|
54
|
+
const verifications = opts.verifications ?? createVerificationStore();
|
|
55
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
56
|
+
|
|
57
|
+
const request = flow({
|
|
58
|
+
name: "auth.requestEmailOtp",
|
|
59
|
+
unit: "auth",
|
|
60
|
+
plane: "user",
|
|
61
|
+
in: z.object({ email: z.string().min(3) }),
|
|
62
|
+
out: z.object({
|
|
63
|
+
ok: z.literal(true),
|
|
64
|
+
devOtp: z.string().optional(),
|
|
65
|
+
}),
|
|
66
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
67
|
+
do: async (input) => {
|
|
68
|
+
const email = normalizeEmail(input.email);
|
|
69
|
+
if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
|
|
70
|
+
const otp = generateOtp(6);
|
|
71
|
+
const now = runtime.now();
|
|
72
|
+
// Invalidate prior active challenges for this email.
|
|
73
|
+
for (const row of verifications.rows.values()) {
|
|
74
|
+
if (row.identifier === `email-otp:${email}` && row.consumedAt === null) {
|
|
75
|
+
row.consumedAt = now;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
putVerification(verifications, {
|
|
79
|
+
id: crypto.randomUUID(),
|
|
80
|
+
identifier: `email-otp:${email}`,
|
|
81
|
+
value: await hashChallenge(otp),
|
|
82
|
+
expiresAt: now + ttlMs,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
consumedAt: null,
|
|
85
|
+
attempts: 0,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
ok: true as const,
|
|
89
|
+
...(opts.exposeDevOtp ? { devOtp: otp } : {}),
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const verify = flow({
|
|
95
|
+
name: "auth.verifyEmailOtp",
|
|
96
|
+
unit: "auth",
|
|
97
|
+
plane: "user",
|
|
98
|
+
in: z.object({
|
|
99
|
+
email: z.string().min(3),
|
|
100
|
+
otp: z.string().min(4).max(8),
|
|
101
|
+
}),
|
|
102
|
+
out: SessionTokensOut,
|
|
103
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
104
|
+
do: async (input) => {
|
|
105
|
+
const email = normalizeEmail(input.email);
|
|
106
|
+
const now = runtime.now();
|
|
107
|
+
const row = findActiveVerification(verifications, `email-otp:${email}`, now);
|
|
108
|
+
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
109
|
+
if (row.attempts >= MAX_ATTEMPTS) {
|
|
110
|
+
row.consumedAt = now;
|
|
111
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
112
|
+
}
|
|
113
|
+
const hash = await hashChallenge(input.otp.trim());
|
|
114
|
+
if (hash !== row.value) {
|
|
115
|
+
row.attempts += 1;
|
|
116
|
+
if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
|
|
117
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
118
|
+
}
|
|
119
|
+
row.consumedAt = now;
|
|
120
|
+
const user = ensureUserByEmail(identities, email, now);
|
|
121
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
122
|
+
id: user.id,
|
|
123
|
+
plane: "user",
|
|
124
|
+
scopes: [],
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
accessToken: issued.accessToken,
|
|
128
|
+
refreshToken: issued.refreshToken,
|
|
129
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
130
|
+
userId: user.id,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return plugin("emailOtp", { version: "0.0.1", config: { method: "email-otp" } })
|
|
136
|
+
.needs("auth")
|
|
137
|
+
.binding(bindPublicAuth("/email-otp/request", request, "otp"))
|
|
138
|
+
.binding(bindPublicAuth("/email-otp/verify", verify, "otp"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
|
|
142
|
+
const existingId = store.byEmail.get(email);
|
|
143
|
+
if (existingId) {
|
|
144
|
+
const existing = store.users.get(existingId);
|
|
145
|
+
if (existing) return existing;
|
|
146
|
+
}
|
|
147
|
+
const id = crypto.randomUUID();
|
|
148
|
+
const user: UserIdentityRow = {
|
|
149
|
+
id,
|
|
150
|
+
email,
|
|
151
|
+
name: email.split("@")[0] || "user",
|
|
152
|
+
emailVerified: true,
|
|
153
|
+
status: "active",
|
|
154
|
+
createdAt: now,
|
|
155
|
+
updatedAt: now,
|
|
156
|
+
extra: {},
|
|
157
|
+
};
|
|
158
|
+
store.users.set(id, user);
|
|
159
|
+
store.byEmail.set(email, id);
|
|
160
|
+
return user;
|
|
161
|
+
}
|