okengine 0.2.8 → 0.3.2
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/AGENTS.md +17 -15
- package/README.md +57 -29
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +92 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +92 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +147 -0
- package/src/cli/openbao-bootstrap.ts +280 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +138 -0
- package/src/plugins/cors.ts +129 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +105 -0
- package/src/plugins/ip-allowlist.ts +76 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `cors` plugin — cross-origin rules at the edge: preflight
|
|
3
|
+
* `OPTIONS` answered even for paths bound to other methods, plus CORS
|
|
4
|
+
* headers on matched responses. Uses the public plugin API (§14) —
|
|
5
|
+
* including the edge contribution for requests that match no flow.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
9
|
+
import {
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
isConfigSource,
|
|
14
|
+
type ConfigSource,
|
|
15
|
+
} from "./config-source.ts";
|
|
16
|
+
import { appendVary, withHeaders } from "./headers.ts";
|
|
17
|
+
|
|
18
|
+
/** Options for {@link cors}. */
|
|
19
|
+
export interface CorsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Origins allowed cross-origin: `"*"` for any, one origin string, or an
|
|
22
|
+
* exact-match list (`"https://app.example.com"`). Default **none** —
|
|
23
|
+
* cross-origin is closed until you open it. Same-origin traffic needs
|
|
24
|
+
* no CORS headers at all.
|
|
25
|
+
*/
|
|
26
|
+
readonly origin?: "*" | string | readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* Methods answered on preflight. Default covers the verbs `http.*`
|
|
29
|
+
* triggers bind: GET · HEAD · POST · PUT · PATCH · DELETE · OPTIONS.
|
|
30
|
+
*/
|
|
31
|
+
readonly methods?: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* `Access-Control-Allow-Headers` on preflight. Default reflects the
|
|
34
|
+
* request's `Access-Control-Request-Headers` back to it.
|
|
35
|
+
*/
|
|
36
|
+
readonly allowedHeaders?: readonly string[];
|
|
37
|
+
/** `Access-Control-Expose-Headers` on actual responses. Omitted unless provided. */
|
|
38
|
+
readonly exposedHeaders?: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Send `Access-Control-Allow-Credentials: true`. When set, an `origin`
|
|
41
|
+
* of `"*"` is answered by reflecting the request origin — browsers
|
|
42
|
+
* reject `"*"` together with credentials.
|
|
43
|
+
*/
|
|
44
|
+
readonly credentials?: boolean;
|
|
45
|
+
/** `Access-Control-Max-Age` seconds on preflight. Omitted unless provided. */
|
|
46
|
+
readonly maxAge?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const;
|
|
50
|
+
|
|
51
|
+
/** True when `origin` is permitted by the configured origin rule. */
|
|
52
|
+
export function originAllowed(origin: string, rule: CorsOptions["origin"]): boolean {
|
|
53
|
+
if (rule === undefined) return false;
|
|
54
|
+
if (rule === "*") return true;
|
|
55
|
+
if (typeof rule === "string") return origin === rule;
|
|
56
|
+
return rule.includes(origin);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `Access-Control-Allow-Origin` value for a permitted origin. */
|
|
60
|
+
function allowOriginValue(origin: string, options: CorsOptions): string {
|
|
61
|
+
return options.origin === "*" && options.credentials !== true ? "*" : origin;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Is this request a CORS preflight? */
|
|
65
|
+
function isPreflight(request: Request, method: string): boolean {
|
|
66
|
+
return (
|
|
67
|
+
method === "OPTIONS" &&
|
|
68
|
+
request.headers.get("origin") !== null &&
|
|
69
|
+
request.headers.get("access-control-request-method") !== null
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Enforce cross-origin rules. A preflight that matches no flow is answered
|
|
75
|
+
* by the plugin's edge handler; matched responses get their CORS headers
|
|
76
|
+
* at `onResponse`. A denied preflight returns `204` **without** CORS
|
|
77
|
+
* headers — the browser blocks it, which is the correct, quiet failure.
|
|
78
|
+
*
|
|
79
|
+
* Accepts static options or a {@link ConfigSource} for DB-driven origin
|
|
80
|
+
* lists (open an emergency integration origin without a redeploy).
|
|
81
|
+
*
|
|
82
|
+
* @param options - Origin rules and friends, or a config source
|
|
83
|
+
*/
|
|
84
|
+
export function cors(options: CorsOptions | ConfigSource<CorsOptions> = {}): PluginDef {
|
|
85
|
+
const def = plugin("cors", { version: "0.0.1", config: pluginConfigSnapshot(options) })
|
|
86
|
+
.edge((request, info) => {
|
|
87
|
+
if (!isPreflight(request, info.method)) return undefined;
|
|
88
|
+
const resolved = resolvePluginOptions(options);
|
|
89
|
+
const origin = request.headers.get("origin")!;
|
|
90
|
+
if (!originAllowed(origin, resolved.origin)) {
|
|
91
|
+
return new Response(null, { status: 204 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const headers = new Headers();
|
|
95
|
+
headers.set("access-control-allow-origin", allowOriginValue(origin, resolved));
|
|
96
|
+
headers.set("access-control-allow-methods", (resolved.methods ?? DEFAULT_METHODS).join(", "));
|
|
97
|
+
const allowed =
|
|
98
|
+
resolved.allowedHeaders?.join(", ") ??
|
|
99
|
+
request.headers.get("access-control-request-headers");
|
|
100
|
+
if (allowed !== null && allowed !== undefined && allowed.length > 0) {
|
|
101
|
+
headers.set("access-control-allow-headers", allowed);
|
|
102
|
+
}
|
|
103
|
+
if (resolved.credentials === true) headers.set("access-control-allow-credentials", "true");
|
|
104
|
+
if (resolved.maxAge !== undefined)
|
|
105
|
+
headers.set("access-control-max-age", String(resolved.maxAge));
|
|
106
|
+
appendVary(headers, "origin");
|
|
107
|
+
appendVary(headers, "access-control-request-method");
|
|
108
|
+
appendVary(headers, "access-control-request-headers");
|
|
109
|
+
return new Response(null, { status: 204, headers });
|
|
110
|
+
})
|
|
111
|
+
.hook("onResponse", (ctx) => {
|
|
112
|
+
if (!ctx.request || !ctx.response) return;
|
|
113
|
+
const origin = ctx.request.headers.get("origin");
|
|
114
|
+
if (origin === null) return;
|
|
115
|
+
const resolved = resolvePluginOptions(options);
|
|
116
|
+
if (!originAllowed(origin, resolved.origin)) return;
|
|
117
|
+
|
|
118
|
+
ctx.response = withHeaders(ctx.response, (headers) => {
|
|
119
|
+
headers.set("access-control-allow-origin", allowOriginValue(origin, resolved));
|
|
120
|
+
if (resolved.credentials === true) headers.set("access-control-allow-credentials", "true");
|
|
121
|
+
if (resolved.exposedHeaders !== undefined && resolved.exposedHeaders.length > 0) {
|
|
122
|
+
headers.set("access-control-expose-headers", resolved.exposedHeaders.join(", "));
|
|
123
|
+
}
|
|
124
|
+
appendVary(headers, "origin");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
129
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `csrf` plugin — fetch-metadata defense with an Origin fallback, through
|
|
3
|
+
* the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { csrf } from "./csrf.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function mutating(headers: Record<string, string> = {}): Request {
|
|
19
|
+
return new Request("http://localhost/transfer", { method: "POST", headers });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function appWith(options: Parameters<typeof csrf>[0]) {
|
|
23
|
+
on(http.post("/transfer"), flow({ name: "transfer.create", do: () => ({ moved: true }) }));
|
|
24
|
+
on(http.get("/balance"), flow({ name: "balance.get", do: () => ({ balance: 5 }) }));
|
|
25
|
+
return oke({ name: `csrf-${crypto.randomUUID()}` }).plug(csrf(options));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("csrf plugin", () => {
|
|
29
|
+
test("safe methods always pass", async () => {
|
|
30
|
+
const app = appWith({});
|
|
31
|
+
const res = await app.fetch(
|
|
32
|
+
new Request("http://localhost/balance", {
|
|
33
|
+
headers: { "sec-fetch-site": "cross-site", origin: "https://evil.com" },
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("same-origin and none pass; cross-site is denied with typed Forbidden", async () => {
|
|
40
|
+
const app = appWith({});
|
|
41
|
+
|
|
42
|
+
const same = await app.fetch(mutating({ "sec-fetch-site": "same-origin" }));
|
|
43
|
+
expect(same.status).toBe(200);
|
|
44
|
+
|
|
45
|
+
const cross = await app.fetch(
|
|
46
|
+
mutating({ "sec-fetch-site": "cross-site", origin: "https://evil.com" }),
|
|
47
|
+
);
|
|
48
|
+
expect(cross.status).toBe(403);
|
|
49
|
+
const body = (await cross.json()) as { error: { code: string; data: { reason: string } } };
|
|
50
|
+
expect(body.error.code).toBe("Forbidden");
|
|
51
|
+
expect(body.error.data.reason).toBe("csrf");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("same-site passes by default, blocked when allowSameSite: false", async () => {
|
|
55
|
+
const lax = appWith({});
|
|
56
|
+
expect(
|
|
57
|
+
(
|
|
58
|
+
await lax.fetch(
|
|
59
|
+
mutating({ "sec-fetch-site": "same-site", origin: "https://sub.localhost" }),
|
|
60
|
+
)
|
|
61
|
+
).status,
|
|
62
|
+
).toBe(200);
|
|
63
|
+
|
|
64
|
+
const strict = appWith({ allowSameSite: false });
|
|
65
|
+
const res = await strict.fetch(
|
|
66
|
+
mutating({ "sec-fetch-site": "same-site", origin: "https://sub.localhost" }),
|
|
67
|
+
);
|
|
68
|
+
expect(res.status).toBe(403);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Origin fallback: same-origin host passes, allow-listed origins pass, others fail", async () => {
|
|
72
|
+
const app = appWith({ allowOrigins: ["https://admin.example.com"] });
|
|
73
|
+
|
|
74
|
+
const sameHost = await app.fetch(mutating({ origin: "http://localhost" }));
|
|
75
|
+
expect(sameHost.status).toBe(200);
|
|
76
|
+
|
|
77
|
+
const listed = await app.fetch(mutating({ origin: "https://admin.example.com" }));
|
|
78
|
+
expect(listed.status).toBe(200);
|
|
79
|
+
|
|
80
|
+
const evil = await app.fetch(mutating({ origin: "https://evil.com" }));
|
|
81
|
+
expect(evil.status).toBe(403);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("headerless mutating requests pass by default (curl, webhooks), fail closed on demand", async () => {
|
|
85
|
+
const lax = appWith({});
|
|
86
|
+
expect((await lax.fetch(mutating())).status).toBe(200);
|
|
87
|
+
|
|
88
|
+
const closed = appWith({ allowNoHeader: false });
|
|
89
|
+
const res = await closed.fetch(mutating());
|
|
90
|
+
expect(res.status).toBe(403);
|
|
91
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
92
|
+
expect(body.error.code).toBe("Forbidden");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("cross-site with an allow-listed origin passes (metadata wins are not required)", async () => {
|
|
96
|
+
const app = appWith({ allowOrigins: ["https://admin.example.com"] });
|
|
97
|
+
const res = await app.fetch(
|
|
98
|
+
mutating({ "sec-fetch-site": "cross-site", origin: "https://admin.example.com" }),
|
|
99
|
+
);
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `csrf` plugin — cross-site request forgery defense using fetch
|
|
3
|
+
* metadata (`Sec-Fetch-Site`) with an `Origin` fallback. Stateless: no
|
|
4
|
+
* tokens, no cookies, no session reads. Uses only the public plugin API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { fail } from "../kernel/errors.ts";
|
|
8
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
9
|
+
import {
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
isConfigSource,
|
|
14
|
+
type ConfigSource,
|
|
15
|
+
} from "./config-source.ts";
|
|
16
|
+
|
|
17
|
+
/** Options for {@link csrf}. */
|
|
18
|
+
export interface CsrfOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Absolute origins allowed to send mutating requests from another site
|
|
21
|
+
* (e.g. `"https://admin.example.com"` for a separately-hosted console).
|
|
22
|
+
* Same-origin is always allowed.
|
|
23
|
+
*/
|
|
24
|
+
readonly allowOrigins?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* Allow `Sec-Fetch-Site: same-site` (subdomains of your site). Default
|
|
27
|
+
* `true`. Set `false` when subdomains host untrusted content.
|
|
28
|
+
*/
|
|
29
|
+
readonly allowSameSite?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Allow mutating requests carrying neither `Sec-Fetch-Site` nor `Origin`
|
|
32
|
+
* — the shape of curl, server-to-server calls, and webhooks, which are
|
|
33
|
+
* not browser CSRF vectors. Default `true`. Set `false` to fail closed
|
|
34
|
+
* when your auth is purely cookie-based.
|
|
35
|
+
*/
|
|
36
|
+
readonly allowNoHeader?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Methods browsers can mutate state with (safe verbs pass untouched). */
|
|
40
|
+
function isMutating(method: string): boolean {
|
|
41
|
+
return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Block cross-site state changes at `onAuth`, before gate policies and the
|
|
46
|
+
* flow body. Decision order: safe methods pass → `Sec-Fetch-Site`
|
|
47
|
+
* (`same-origin` / `none` pass; `same-site` per option) → `Origin`
|
|
48
|
+
* fallback (must be same-origin or allow-listed) → headerless clients per
|
|
49
|
+
* `allowNoHeader`. Denials are the gate element's typed `Forbidden`.
|
|
50
|
+
*
|
|
51
|
+
* Accepts static options or a {@link ConfigSource} for DB-driven origin
|
|
52
|
+
* rules.
|
|
53
|
+
*
|
|
54
|
+
* @param options - Origin rules, or a config source
|
|
55
|
+
*/
|
|
56
|
+
export function csrf(options: CsrfOptions | ConfigSource<CsrfOptions> = {}): PluginDef {
|
|
57
|
+
const def = plugin("csrf", { version: "0.0.1", config: pluginConfigSnapshot(options) }).hook(
|
|
58
|
+
"onAuth",
|
|
59
|
+
(ctx) => {
|
|
60
|
+
if (!ctx.request) return;
|
|
61
|
+
const method = ctx.request.method.toUpperCase();
|
|
62
|
+
if (!isMutating(method)) return;
|
|
63
|
+
|
|
64
|
+
const resolved = resolvePluginOptions(options);
|
|
65
|
+
const site = ctx.request.headers.get("sec-fetch-site")?.toLowerCase();
|
|
66
|
+
if (site === "same-origin" || site === "none") return;
|
|
67
|
+
if (site === "same-site" && (resolved.allowSameSite ?? true)) return;
|
|
68
|
+
|
|
69
|
+
const origin = ctx.request.headers.get("origin");
|
|
70
|
+
if (origin !== null) {
|
|
71
|
+
const host = new URL(ctx.request.url).origin;
|
|
72
|
+
if (origin === host || resolved.allowOrigins?.includes(origin) === true) return;
|
|
73
|
+
return fail("Forbidden", {
|
|
74
|
+
reason: "csrf",
|
|
75
|
+
site: site ?? "unknown",
|
|
76
|
+
origin,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (resolved.allowNoHeader ?? true) return;
|
|
81
|
+
return fail("Forbidden", { reason: "csrf", site: site ?? "unknown" });
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
86
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared response-header mutation for HTTP middleware plugins.
|
|
3
|
+
* `Headers` are immutable-in-place on a built `Response` — rebuild instead.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rebuild a response with headers mutated by `fn`.
|
|
8
|
+
*
|
|
9
|
+
* @param response - Original response
|
|
10
|
+
* @param fn - Header mutation
|
|
11
|
+
*/
|
|
12
|
+
export function withHeaders(response: Response, fn: (headers: Headers) => void): Response {
|
|
13
|
+
const headers = new Headers(response.headers);
|
|
14
|
+
fn(headers);
|
|
15
|
+
return new Response(response.body, {
|
|
16
|
+
status: response.status,
|
|
17
|
+
statusText: response.statusText,
|
|
18
|
+
headers,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set a header only when absent (or when `override` is on).
|
|
24
|
+
*
|
|
25
|
+
* @param headers - Mutable headers
|
|
26
|
+
* @param name - Header name
|
|
27
|
+
* @param value - Header value
|
|
28
|
+
* @param override - Replace an app-set value
|
|
29
|
+
*/
|
|
30
|
+
export function setUnlessPresent(
|
|
31
|
+
headers: Headers,
|
|
32
|
+
name: string,
|
|
33
|
+
value: string,
|
|
34
|
+
override: boolean,
|
|
35
|
+
): void {
|
|
36
|
+
if (!override && headers.has(name)) return;
|
|
37
|
+
headers.set(name, value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Append a token to `Vary` without duplicating it.
|
|
42
|
+
*
|
|
43
|
+
* @param headers - Mutable headers
|
|
44
|
+
* @param token - Vary token (e.g. `"origin"`)
|
|
45
|
+
*/
|
|
46
|
+
export function appendVary(headers: Headers, token: string): void {
|
|
47
|
+
const vary = headers.get("vary");
|
|
48
|
+
if (vary === null) {
|
|
49
|
+
headers.set("vary", token);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const pattern = new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
53
|
+
if (!pattern.test(vary)) headers.set("vary", `${vary}, ${token}`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official plugins — first-party extensions built entirely on the public
|
|
3
|
+
* plugin API (`okengine/plugins`). Import what you need; attach with
|
|
4
|
+
* `.plug()`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { compression, type CompressionOptions } from "./compression.ts";
|
|
8
|
+
export {
|
|
9
|
+
configSource,
|
|
10
|
+
isConfigSource,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
type ConfigSource,
|
|
13
|
+
type ConfigSourceDb,
|
|
14
|
+
type ConfigSourceOptions,
|
|
15
|
+
} from "./config-source.ts";
|
|
16
|
+
export { cors, type CorsOptions } from "./cors.ts";
|
|
17
|
+
export { csrf, type CsrfOptions } from "./csrf.ts";
|
|
18
|
+
export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
|
|
19
|
+
export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
|
|
20
|
+
export {
|
|
21
|
+
defaultCspDirectives,
|
|
22
|
+
securityHeaders,
|
|
23
|
+
type CspOptions,
|
|
24
|
+
type HstsOptions,
|
|
25
|
+
type SecurityHeadersOptions,
|
|
26
|
+
} from "./security-headers.ts";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ip-allowlist` plugin — allow/deny semantics, XFF parsing, and
|
|
3
|
+
* missing-header behavior through the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { ipAllowlist } from "./ip-allowlist.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Shorthand: GET /x with an optional x-forwarded-for header. */
|
|
19
|
+
function get(ip?: string): Request {
|
|
20
|
+
return new Request(
|
|
21
|
+
"http://localhost/x",
|
|
22
|
+
ip === undefined ? undefined : { headers: { "x-forwarded-for": ip } },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("ipAllowlist plugin", () => {
|
|
27
|
+
test("allow list: listed IPs pass, others get 403 Forbidden", async () => {
|
|
28
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
29
|
+
const app = oke({ name: "ips" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
30
|
+
|
|
31
|
+
const ok = await app.fetch(get("203.0.113.7"));
|
|
32
|
+
expect(ok.status).toBe(200);
|
|
33
|
+
|
|
34
|
+
const denied = await app.fetch(get("198.51.100.9"));
|
|
35
|
+
expect(denied.status).toBe(403);
|
|
36
|
+
const body = (await denied.json()) as { error: { code: string; data: { reason: string } } };
|
|
37
|
+
expect(body.error.code).toBe("Forbidden");
|
|
38
|
+
expect(body.error.data.reason).toBe("ip_not_allowed");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("deny list: blocked IPs get 403 ip_denied, others pass", async () => {
|
|
42
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
43
|
+
const app = oke({ name: "ips-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
|
|
44
|
+
|
|
45
|
+
const blocked = await app.fetch(get("198.51.100.9"));
|
|
46
|
+
expect(blocked.status).toBe(403);
|
|
47
|
+
const body = (await blocked.json()) as { error: { data: { reason: string } } };
|
|
48
|
+
expect(body.error.data.reason).toBe("ip_denied");
|
|
49
|
+
|
|
50
|
+
const ok = await app.fetch(get("203.0.113.7"));
|
|
51
|
+
expect(ok.status).toBe(200);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("deny wins over allow on overlap", async () => {
|
|
55
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
56
|
+
const app = oke({ name: "ips-both" }).plug(
|
|
57
|
+
ipAllowlist({ allow: ["203.0.113.7"], deny: ["203.0.113.7"] }),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const res = await app.fetch(get("203.0.113.7"));
|
|
61
|
+
expect(res.status).toBe(403);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("XFF first hop is the client; later hops are ignored", async () => {
|
|
65
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
66
|
+
const app = oke({ name: "ips-xff" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
67
|
+
|
|
68
|
+
const proxied = await app.fetch(get("203.0.113.7, 10.0.0.1, 10.0.0.2"));
|
|
69
|
+
expect(proxied.status).toBe(200);
|
|
70
|
+
|
|
71
|
+
const spoofedTail = await app.fetch(get("198.51.100.9, 203.0.113.7"));
|
|
72
|
+
expect(spoofedTail.status).toBe(403);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("missing header: denied when allow is set, permitted for deny-only", async () => {
|
|
76
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
77
|
+
const strict = oke({ name: "ips-missing-allow" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
78
|
+
|
|
79
|
+
const denied = await strict.fetch(get());
|
|
80
|
+
expect(denied.status).toBe(403);
|
|
81
|
+
|
|
82
|
+
resetBindings();
|
|
83
|
+
resetFlowSeq();
|
|
84
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
85
|
+
|
|
86
|
+
const lax = oke({ name: "ips-missing-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
|
|
87
|
+
const ok = await lax.fetch(get());
|
|
88
|
+
expect(ok.status).toBe(200);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("custom header name is honored", async () => {
|
|
92
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
93
|
+
const app = oke({ name: "ips-custom" }).plug(
|
|
94
|
+
ipAllowlist({ allow: ["203.0.113.7"], header: "x-real-ip" }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const res = await app.fetch(
|
|
98
|
+
new Request("http://localhost/x", { headers: { "x-real-ip": "203.0.113.7" } }),
|
|
99
|
+
);
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
|
|
102
|
+
const wrongHeader = await app.fetch(get("203.0.113.7"));
|
|
103
|
+
expect(wrongHeader.status).toBe(403);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `ip-allowlist` plugin — client-IP allow/deny rules at the edge
|
|
3
|
+
* of the pipeline. Uses only the public plugin API (unified-theory §14).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fail } from "../kernel/errors.ts";
|
|
7
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
8
|
+
import {
|
|
9
|
+
isConfigSource,
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
type ConfigSource,
|
|
14
|
+
} from "./config-source.ts";
|
|
15
|
+
|
|
16
|
+
/** Options for {@link ipAllowlist}. */
|
|
17
|
+
export interface IpAllowlistOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Client IPs permitted to call any flow. When set, every other client
|
|
20
|
+
* gets `Forbidden`. Exact IPv4/IPv6 strings.
|
|
21
|
+
*/
|
|
22
|
+
readonly allow?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Client IPs blocked from calling any flow. Checked before `allow`, so
|
|
25
|
+
* a deny always wins on overlap.
|
|
26
|
+
*/
|
|
27
|
+
readonly deny?: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Header carrying the client IP. Default `"x-forwarded-for"` (first hop
|
|
30
|
+
* wins, the value your proxy appends). Only trustworthy behind a proxy
|
|
31
|
+
* that sets or overwrites this header — a direct client can lie.
|
|
32
|
+
*/
|
|
33
|
+
readonly header?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract the client IP from the configured header (first hop for XFF). */
|
|
37
|
+
function clientIp(request: Request, header: string): string | undefined {
|
|
38
|
+
const raw = request.headers.get(header);
|
|
39
|
+
if (raw === null) return undefined;
|
|
40
|
+
const first = raw.split(",")[0]?.trim();
|
|
41
|
+
return first === undefined || first.length === 0 ? undefined : first;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Enforce IP allow/deny rules at `onAuth`, before any gate policy or flow
|
|
46
|
+
* body runs. Denied clients get the same typed `Forbidden` denial the gate
|
|
47
|
+
* element produces. A missing/empty IP header is denied when `allow` is
|
|
48
|
+
* set, permitted otherwise. Non-HTTP triggers no-op.
|
|
49
|
+
*
|
|
50
|
+
* Accepts static options or a {@link ConfigSource} — block an abusive IP
|
|
51
|
+
* from the database and have every instance pick it up on the next sync.
|
|
52
|
+
*
|
|
53
|
+
* @param options - Allow/deny lists and header name, or a config source
|
|
54
|
+
*/
|
|
55
|
+
export function ipAllowlist(
|
|
56
|
+
options: IpAllowlistOptions | ConfigSource<IpAllowlistOptions>,
|
|
57
|
+
): PluginDef {
|
|
58
|
+
const def = plugin("ip-allowlist", {
|
|
59
|
+
version: "0.0.2",
|
|
60
|
+
config: pluginConfigSnapshot(options),
|
|
61
|
+
}).hook("onAuth", (ctx) => {
|
|
62
|
+
if (!ctx.request) return;
|
|
63
|
+
const resolved = resolvePluginOptions(options);
|
|
64
|
+
const header = (resolved.header ?? "x-forwarded-for").toLowerCase();
|
|
65
|
+
const ip = clientIp(ctx.request, header);
|
|
66
|
+
|
|
67
|
+
if (ip !== undefined && (resolved.deny ?? []).includes(ip)) {
|
|
68
|
+
return fail("Forbidden", { reason: "ip_denied", ip });
|
|
69
|
+
}
|
|
70
|
+
if (resolved.allow !== undefined && (ip === undefined || !resolved.allow.includes(ip))) {
|
|
71
|
+
return fail("Forbidden", { reason: "ip_not_allowed", ip });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
76
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `maintenance-mode` plugin — 503 drain behavior, allow-paths, bypass
|
|
3
|
+
* header, and non-HTTP no-op through the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { maintenanceMode } from "./maintenance-mode.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("maintenanceMode plugin", () => {
|
|
19
|
+
test("returns 503 with ServiceUnavailable envelope and Retry-After", async () => {
|
|
20
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
21
|
+
const app = oke({ name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
|
|
22
|
+
|
|
23
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
24
|
+
|
|
25
|
+
expect(res.status).toBe(503);
|
|
26
|
+
expect(res.headers.get("retry-after")).toBe("120");
|
|
27
|
+
const body = (await res.json()) as {
|
|
28
|
+
data: null;
|
|
29
|
+
error: { code: string; data: { retryAfter?: number }; message?: string };
|
|
30
|
+
};
|
|
31
|
+
expect(body.data).toBeNull();
|
|
32
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
33
|
+
expect(body.error.data.retryAfter).toBe(120);
|
|
34
|
+
expect(body.error.message).toBe("Service is under maintenance.");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("enabled: false passes traffic through", async () => {
|
|
38
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
39
|
+
const app = oke({ name: "maint-off" }).plug(maintenanceMode({ enabled: false }));
|
|
40
|
+
|
|
41
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
42
|
+
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("allowPaths prefixes keep serving", async () => {
|
|
47
|
+
on(http.get("/health"), flow({ name: "health.get", do: () => ({ up: true }) }));
|
|
48
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
49
|
+
const app = oke({ name: "maint-allow" }).plug(maintenanceMode({ allowPaths: ["/health"] }));
|
|
50
|
+
|
|
51
|
+
const health = await app.fetch(new Request("http://localhost/health"));
|
|
52
|
+
expect(health.status).toBe(200);
|
|
53
|
+
|
|
54
|
+
const x = await app.fetch(new Request("http://localhost/x"));
|
|
55
|
+
expect(x.status).toBe(503);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("bypass header lets operators through (any non-empty value)", async () => {
|
|
59
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
60
|
+
const app = oke({ name: "maint-bypass" }).plug(
|
|
61
|
+
maintenanceMode({ bypassHeader: "x-ops-token" }),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const blocked = await app.fetch(new Request("http://localhost/x"));
|
|
65
|
+
expect(blocked.status).toBe(503);
|
|
66
|
+
|
|
67
|
+
const allowed = await app.fetch(
|
|
68
|
+
new Request("http://localhost/x", { headers: { "x-ops-token": "let-me-in" } }),
|
|
69
|
+
);
|
|
70
|
+
expect(allowed.status).toBe(200);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the 503 still flows through onResponse (other plugins stamp it)", async () => {
|
|
74
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
75
|
+
const app = oke({ name: "maint-stamp" }).plug(maintenanceMode());
|
|
76
|
+
app.hook("onResponse", (ctx) => {
|
|
77
|
+
if (!ctx.response) return;
|
|
78
|
+
const headers = new Headers(ctx.response.headers);
|
|
79
|
+
headers.set("x-stamped", "yes");
|
|
80
|
+
ctx.response = new Response(ctx.response.body, {
|
|
81
|
+
status: ctx.response.status,
|
|
82
|
+
headers,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(503);
|
|
89
|
+
expect(res.headers.get("x-stamped")).toBe("yes");
|
|
90
|
+
});
|
|
91
|
+
});
|