okengine 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/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} +1 -1
- 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 +128 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +116 -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/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 +117 -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/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.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/config-source.test.ts +1 -1
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/index.ts +31 -0
- 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.ts +216 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/security-headers.test.ts +14 -14
- package/src/plugins/two-factor.ts +249 -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/compiler/extract.ts
CHANGED
|
@@ -671,6 +671,25 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
671
671
|
}
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
if (obj === "gate" && prop === "scope") {
|
|
675
|
+
const scopeName = stringArg(call.arguments[0]);
|
|
676
|
+
if (scopeName) {
|
|
677
|
+
scope.gates[scopeName] = {
|
|
678
|
+
kind: "policy",
|
|
679
|
+
roles: [scopeName],
|
|
680
|
+
scopes: [scopeName],
|
|
681
|
+
};
|
|
682
|
+
const bindingName = enclosingConstName(call, program);
|
|
683
|
+
if (bindingName) {
|
|
684
|
+
scope.gateIds.set(bindingName, scopeName);
|
|
685
|
+
scope.bindings.set(bindingName, {
|
|
686
|
+
kind: "unknown",
|
|
687
|
+
ref: scopeName,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
674
693
|
if (obj === "gate" && prop === "rate") {
|
|
675
694
|
const opts = objectArg(call.arguments[0]);
|
|
676
695
|
const strategy = (stringProp(opts, "strategy") ?? "sliding-window-counter") as RateStrategy;
|
package/src/compiler/response.ts
CHANGED
|
@@ -91,8 +91,22 @@ export function encodeExecuteResult(result: {
|
|
|
91
91
|
}): Response {
|
|
92
92
|
if (result.response) return result.response;
|
|
93
93
|
if (result.failure) return encodeFailure(result.failure);
|
|
94
|
-
if (result.error !== undefined
|
|
95
|
-
|
|
94
|
+
if (result.error !== undefined) {
|
|
95
|
+
if (isFlowFailure(result.error)) {
|
|
96
|
+
return encodeFailure(result.error);
|
|
97
|
+
}
|
|
98
|
+
// Unhandled throws must never look like success (`undefined` → 204).
|
|
99
|
+
return Response.json(
|
|
100
|
+
{
|
|
101
|
+
data: null,
|
|
102
|
+
error: {
|
|
103
|
+
code: "InternalError",
|
|
104
|
+
data: {},
|
|
105
|
+
message: result.error instanceof Error ? result.error.message : "internal error",
|
|
106
|
+
},
|
|
107
|
+
} satisfies FailureEnvelope,
|
|
108
|
+
{ status: 500 },
|
|
109
|
+
);
|
|
96
110
|
}
|
|
97
111
|
return encodeSuccess(result.output);
|
|
98
112
|
}
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* ConsoleApp — operator-plane app built on `createClient<ConsoleApp>`.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { auth } from "../../auth/index.ts";
|
|
6
5
|
import { memorySignalDriver } from "../../drivers/signal-memory.ts";
|
|
7
6
|
import { signal as declareSignal } from "../../elements/signal/declare.ts";
|
|
8
7
|
import { createSignalRuntime } from "../../elements/signal/runtime.ts";
|
|
9
8
|
import { oke, type OkeApp } from "../../kernel/index.ts";
|
|
10
9
|
import { createManifestAiRuntime } from "./ai.ts";
|
|
10
|
+
import { CONSOLE_GATES } from "./console-gates.ts";
|
|
11
11
|
import { createConsoleBindings } from "./flows.ts";
|
|
12
12
|
import { consolePlugin } from "./plugin.ts";
|
|
13
13
|
import {
|
|
@@ -61,14 +61,18 @@ export function createConsoleApp(options: CreateConsoleAppOptions = {}): Console
|
|
|
61
61
|
bindings,
|
|
62
62
|
autoBoot: false,
|
|
63
63
|
fx: { now: state.now },
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
gate: {
|
|
65
|
+
auth: {
|
|
66
|
+
secret: state.secret,
|
|
67
|
+
sessions: state.sessions,
|
|
68
|
+
now: state.now,
|
|
69
|
+
// Console owns `/console/session/*` — no app `/auth/*` surfaces.
|
|
70
|
+
http: false,
|
|
71
|
+
},
|
|
72
|
+
policies: [...CONSOLE_GATES],
|
|
68
73
|
},
|
|
69
74
|
runs: { driver: "memory" },
|
|
70
75
|
})
|
|
71
|
-
.plug(auth({ secret: state.secret }))
|
|
72
76
|
.plug(consolePlugin())
|
|
73
77
|
.adopt({
|
|
74
78
|
console: {
|
|
@@ -53,18 +53,18 @@ describe("auth-rate strategy", () => {
|
|
|
53
53
|
await createOperator(store, {
|
|
54
54
|
email: "ops@example.com",
|
|
55
55
|
name: "Ops",
|
|
56
|
-
password: "
|
|
56
|
+
password: "password1234",
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
const tMissing = performance.now();
|
|
60
|
-
expect(await authenticateOperator(store, "missing@example.com", "
|
|
60
|
+
expect(await authenticateOperator(store, "missing@example.com", "password1234")).toBeNull();
|
|
61
61
|
const missingMs = performance.now() - tMissing;
|
|
62
62
|
|
|
63
63
|
const tBad = performance.now();
|
|
64
64
|
expect(await authenticateOperator(store, "ops@example.com", "wrong-password")).toBeNull();
|
|
65
65
|
const badMs = performance.now() - tBad;
|
|
66
66
|
|
|
67
|
-
expect(await authenticateOperator(store, "ops@example.com", "
|
|
67
|
+
expect(await authenticateOperator(store, "ops@example.com", "password1234")).not.toBeNull();
|
|
68
68
|
|
|
69
69
|
// Both paths pay an argon2 verify — neither should be near-instant.
|
|
70
70
|
expect(missingMs).toBeGreaterThan(5);
|
|
@@ -2,18 +2,29 @@
|
|
|
2
2
|
* Local HTTP bindings that do not touch the global {@link on} registry.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { gate } from "../../elements/gate.ts";
|
|
5
6
|
import type { AnyFlowDef } from "../../kernel/flow.ts";
|
|
6
7
|
import type { Binding } from "../../kernel/on.ts";
|
|
7
8
|
import { normalizeTrigger, type HttpTrigger, type Trigger } from "../../kernel/triggers.ts";
|
|
9
|
+
import { consoleOperatorGate } from "./console-gates.ts";
|
|
10
|
+
import { PUBLIC_CONSOLE_FLOWS } from "./public-flows.ts";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Bind an HTTP trigger to a flow without registering on the global `on` list.
|
|
14
|
+
* Attaches {@link gate.public} or {@link consoleOperatorGate} when the trigger
|
|
15
|
+
* has no declared auth posture yet.
|
|
11
16
|
*
|
|
12
17
|
* @param trigger - HTTP trigger
|
|
13
18
|
* @param flowDef - Flow definition
|
|
14
19
|
*/
|
|
15
20
|
export function bindHttp(trigger: HttpTrigger, flowDef: AnyFlowDef): Binding {
|
|
16
|
-
const
|
|
21
|
+
const withPosture =
|
|
22
|
+
trigger.gates.length > 0
|
|
23
|
+
? trigger
|
|
24
|
+
: PUBLIC_CONSOLE_FLOWS.has(flowDef.name)
|
|
25
|
+
? trigger.gate(gate.public)
|
|
26
|
+
: trigger.gate(consoleOperatorGate);
|
|
27
|
+
const normalized = normalizeTrigger(withPosture);
|
|
17
28
|
const list = flowDef.triggers as Trigger[];
|
|
18
29
|
list.push(normalized);
|
|
19
30
|
(flowDef as { $trigger: Trigger }).$trigger = normalized;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console HTTP auth-posture gates — public sentinel vs operator policy.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { gate, type PolicyGateDecl } from "../../elements/gate.ts";
|
|
6
|
+
|
|
7
|
+
/** Operator must be present (Console plane). */
|
|
8
|
+
export const consoleOperatorGate: PolicyGateDecl = gate.policy(
|
|
9
|
+
"console:operator",
|
|
10
|
+
({ operator }) => operator.id !== null,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/** Gates registered on the Console app runtime. */
|
|
14
|
+
export const CONSOLE_GATES = [gate.public, consoleOperatorGate] as const;
|
|
@@ -69,7 +69,7 @@ describe("console kernel", () => {
|
|
|
69
69
|
claimCode: code,
|
|
70
70
|
email: "ops@example.com",
|
|
71
71
|
name: "Ops",
|
|
72
|
-
password: "
|
|
72
|
+
password: "password1234",
|
|
73
73
|
}),
|
|
74
74
|
}),
|
|
75
75
|
);
|
|
@@ -89,7 +89,7 @@ describe("console kernel", () => {
|
|
|
89
89
|
claimCode: code,
|
|
90
90
|
email: "other@example.com",
|
|
91
91
|
name: "Other",
|
|
92
|
-
password: "
|
|
92
|
+
password: "password1234",
|
|
93
93
|
}),
|
|
94
94
|
}),
|
|
95
95
|
);
|
|
@@ -127,7 +127,7 @@ describe("console kernel", () => {
|
|
|
127
127
|
claimCode: handle.state.claim.code,
|
|
128
128
|
email: "ops@example.com",
|
|
129
129
|
name: "Ops",
|
|
130
|
-
password: "
|
|
130
|
+
password: "password1234",
|
|
131
131
|
}),
|
|
132
132
|
}),
|
|
133
133
|
);
|
|
@@ -175,7 +175,7 @@ describe("console kernel", () => {
|
|
|
175
175
|
claimCode: handle.state.claim.code,
|
|
176
176
|
email: "ops@example.com",
|
|
177
177
|
name: "Ops",
|
|
178
|
-
password: "
|
|
178
|
+
password: "password1234",
|
|
179
179
|
}),
|
|
180
180
|
}),
|
|
181
181
|
);
|
|
@@ -223,7 +223,7 @@ describe("console kernel", () => {
|
|
|
223
223
|
claimCode: handle.state.claim.code,
|
|
224
224
|
email: "ops@example.com",
|
|
225
225
|
name: "Ops",
|
|
226
|
-
password: "
|
|
226
|
+
password: "password1234",
|
|
227
227
|
}),
|
|
228
228
|
}),
|
|
229
229
|
);
|
|
@@ -317,7 +317,7 @@ describe("console serve security", () => {
|
|
|
317
317
|
claimCode: server.console.state.claim.code,
|
|
318
318
|
email: "ops@example.com",
|
|
319
319
|
name: "Ops",
|
|
320
|
-
password: "
|
|
320
|
+
password: "password1234",
|
|
321
321
|
}),
|
|
322
322
|
}),
|
|
323
323
|
);
|
|
@@ -31,7 +31,7 @@ describe("console flows invoke", () => {
|
|
|
31
31
|
claimCode: code,
|
|
32
32
|
email: "ops@example.com",
|
|
33
33
|
name: "Ops",
|
|
34
|
-
password: "
|
|
34
|
+
password: "password1234",
|
|
35
35
|
}),
|
|
36
36
|
}),
|
|
37
37
|
);
|
|
@@ -100,7 +100,7 @@ describe("console flows invoke", () => {
|
|
|
100
100
|
claimCode: code,
|
|
101
101
|
email: "ops@example.com",
|
|
102
102
|
name: "Ops",
|
|
103
|
-
password: "
|
|
103
|
+
password: "password1234",
|
|
104
104
|
}),
|
|
105
105
|
}),
|
|
106
106
|
);
|
|
@@ -751,6 +751,7 @@ const GatesListOut = z.object({
|
|
|
751
751
|
plane: z.enum(["user", "operator"]),
|
|
752
752
|
gates: z.array(z.string()),
|
|
753
753
|
unguarded: z.boolean(),
|
|
754
|
+
explicitPublic: z.boolean(),
|
|
754
755
|
}),
|
|
755
756
|
),
|
|
756
757
|
gates: z.array(
|
|
@@ -2817,6 +2818,7 @@ function createGatesList(state: ConsoleState) {
|
|
|
2817
2818
|
plane: f.plane,
|
|
2818
2819
|
gates: [...f.gates],
|
|
2819
2820
|
unguarded: f.unguarded,
|
|
2821
|
+
explicitPublic: f.explicitPublic,
|
|
2820
2822
|
})),
|
|
2821
2823
|
gates: projection.gates.map((g) => ({
|
|
2822
2824
|
name: g.name,
|
|
@@ -66,8 +66,13 @@ export interface ConsoleFlowGatesRow {
|
|
|
66
66
|
readonly flowId: string;
|
|
67
67
|
readonly plane: "user" | "operator";
|
|
68
68
|
readonly gates: readonly string[];
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* User-plane with empty chain — missing auth posture (boot error when
|
|
71
|
+
* `unguardedHttp: "deny"`). Distinct from {@link explicitPublic}.
|
|
72
|
+
*/
|
|
70
73
|
readonly unguarded: boolean;
|
|
74
|
+
/** Chain includes the reserved `public` sentinel (`gate.public`). */
|
|
75
|
+
readonly explicitPublic: boolean;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/**
|
|
@@ -449,11 +454,13 @@ function projectFlows(manifest: Manifest | null): ConsoleFlowGatesRow[] {
|
|
|
449
454
|
for (const [flowId, flow] of Object.entries(manifest.flows)) {
|
|
450
455
|
const plane = flow.plane === "operator" ? "operator" : "user";
|
|
451
456
|
const gates = [...(flow.gates ?? [])];
|
|
457
|
+
const explicitPublic = gates.includes("public");
|
|
452
458
|
rows.push({
|
|
453
459
|
flowId,
|
|
454
460
|
plane,
|
|
455
461
|
gates,
|
|
456
462
|
unguarded: plane === "user" && gates.length === 0,
|
|
463
|
+
explicitPublic,
|
|
457
464
|
});
|
|
458
465
|
}
|
|
459
466
|
return rows.sort((a, b) => a.flowId.localeCompare(b.flowId));
|
|
@@ -36,7 +36,7 @@ describe("console operator persistence", () => {
|
|
|
36
36
|
const op = await createOperator(first.operators, {
|
|
37
37
|
email: "ops@example.com",
|
|
38
38
|
name: "Ops",
|
|
39
|
-
password: "
|
|
39
|
+
password: "password1234",
|
|
40
40
|
});
|
|
41
41
|
first.persistOperator(op.id);
|
|
42
42
|
first.close();
|
|
@@ -77,7 +77,7 @@ describe("console operator persistence", () => {
|
|
|
77
77
|
const op = await createOperator(first.operators, {
|
|
78
78
|
email: "ops@example.com",
|
|
79
79
|
name: "Ops",
|
|
80
|
-
password: "
|
|
80
|
+
password: "password1234",
|
|
81
81
|
});
|
|
82
82
|
first.persistOperator(op.id);
|
|
83
83
|
const issued = await issueSession(
|
|
@@ -120,7 +120,7 @@ describe("console operator persistence", () => {
|
|
|
120
120
|
claimCode: first.state.claim.code,
|
|
121
121
|
email: "ops@example.com",
|
|
122
122
|
name: "Ops",
|
|
123
|
-
password: "
|
|
123
|
+
password: "password1234",
|
|
124
124
|
}),
|
|
125
125
|
}),
|
|
126
126
|
);
|
|
@@ -169,7 +169,7 @@ describe("console operator persistence", () => {
|
|
|
169
169
|
const op = await createOperator(persistence.operators, {
|
|
170
170
|
email: "ops@example.com",
|
|
171
171
|
name: "Ops",
|
|
172
|
-
password: "
|
|
172
|
+
password: "password1234",
|
|
173
173
|
});
|
|
174
174
|
persistence.persistOperator(op.id);
|
|
175
175
|
|
|
@@ -157,7 +157,8 @@ export function migrateOperatorSchema(db: Database): void {
|
|
|
157
157
|
family_id TEXT NOT NULL,
|
|
158
158
|
revoked_at INTEGER,
|
|
159
159
|
created_at INTEGER NOT NULL,
|
|
160
|
-
expires_at INTEGER NOT NULL
|
|
160
|
+
expires_at INTEGER NOT NULL,
|
|
161
|
+
last_active_at INTEGER
|
|
161
162
|
);
|
|
162
163
|
CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
|
|
163
164
|
id TEXT PRIMARY KEY NOT NULL,
|
|
@@ -169,6 +170,20 @@ export function migrateOperatorSchema(db: Database): void {
|
|
|
169
170
|
revoked_at INTEGER
|
|
170
171
|
);
|
|
171
172
|
`);
|
|
173
|
+
ensureSessionLastActiveColumn(db);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Add `last_active_at` when opening an older console.sqlite that predates idle TTL.
|
|
178
|
+
*
|
|
179
|
+
* @param db - Open database
|
|
180
|
+
*/
|
|
181
|
+
function ensureSessionLastActiveColumn(db: Database): void {
|
|
182
|
+
const cols = db.query(`PRAGMA table_info(${AUTH_TABLES.sessions})`).all() as Array<{
|
|
183
|
+
name: string;
|
|
184
|
+
}>;
|
|
185
|
+
if (cols.some((c) => c.name === "last_active_at")) return;
|
|
186
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
/**
|
|
@@ -181,7 +196,7 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
181
196
|
|
|
182
197
|
const sessionRows = db
|
|
183
198
|
.query(
|
|
184
|
-
`SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at
|
|
199
|
+
`SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at
|
|
185
200
|
FROM ${AUTH_TABLES.sessions}`,
|
|
186
201
|
)
|
|
187
202
|
.all() as Array<{
|
|
@@ -192,6 +207,7 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
192
207
|
revoked_at: number | null;
|
|
193
208
|
created_at: number;
|
|
194
209
|
expires_at: number;
|
|
210
|
+
last_active_at: number | null;
|
|
195
211
|
}>;
|
|
196
212
|
|
|
197
213
|
for (const row of sessionRows) {
|
|
@@ -203,6 +219,7 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
203
219
|
revokedAt: row.revoked_at,
|
|
204
220
|
createdAt: row.created_at,
|
|
205
221
|
expiresAt: row.expires_at,
|
|
222
|
+
lastActiveAt: row.last_active_at ?? row.created_at,
|
|
206
223
|
};
|
|
207
224
|
store.sessions.set(session.id, session);
|
|
208
225
|
}
|
|
@@ -250,8 +267,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
|
|
|
250
267
|
|
|
251
268
|
const insertSession = db.query(
|
|
252
269
|
`INSERT INTO ${AUTH_TABLES.sessions}
|
|
253
|
-
(id, plane, principal_id, family_id, revoked_at, created_at, expires_at)
|
|
254
|
-
VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires)`,
|
|
270
|
+
(id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at)
|
|
271
|
+
VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive)`,
|
|
255
272
|
);
|
|
256
273
|
for (const session of store.sessions.values()) {
|
|
257
274
|
insertSession.run({
|
|
@@ -262,6 +279,7 @@ export function persistSessions(db: Database, store: SessionStore): void {
|
|
|
262
279
|
$revoked: session.revokedAt,
|
|
263
280
|
$created: session.createdAt,
|
|
264
281
|
$expires: session.expiresAt,
|
|
282
|
+
$lastActive: session.lastActiveAt,
|
|
265
283
|
});
|
|
266
284
|
}
|
|
267
285
|
|
|
@@ -69,7 +69,7 @@ describe("console security gates (whole surface)", () => {
|
|
|
69
69
|
claimCode: handle.state.claim.code,
|
|
70
70
|
email: "ops@example.com",
|
|
71
71
|
name: "Ops",
|
|
72
|
-
password: "
|
|
72
|
+
password: "password1234",
|
|
73
73
|
}),
|
|
74
74
|
}),
|
|
75
75
|
);
|
|
@@ -234,7 +234,7 @@ describe("console security gates (whole surface)", () => {
|
|
|
234
234
|
headers: { "content-type": "application/json" },
|
|
235
235
|
body: JSON.stringify({
|
|
236
236
|
email: "ops@example.com",
|
|
237
|
-
password: "
|
|
237
|
+
password: "password1234",
|
|
238
238
|
}),
|
|
239
239
|
}),
|
|
240
240
|
);
|
|
@@ -252,7 +252,7 @@ describe("console security gates (whole surface)", () => {
|
|
|
252
252
|
headers: { "content-type": "application/json" },
|
|
253
253
|
body: JSON.stringify({
|
|
254
254
|
email: "other@example.com",
|
|
255
|
-
password: "
|
|
255
|
+
password: "password1234",
|
|
256
256
|
}),
|
|
257
257
|
}),
|
|
258
258
|
);
|
|
@@ -20,24 +20,28 @@ export const GATES_LIST_FIXTURE: GatesListResponse = {
|
|
|
20
20
|
plane: "user",
|
|
21
21
|
gates: ["member", "booking:create", "rate:sliding-window-counter:300/1m"],
|
|
22
22
|
unguarded: false,
|
|
23
|
+
explicitPublic: false,
|
|
23
24
|
},
|
|
24
25
|
{
|
|
25
26
|
flowId: "health.ping",
|
|
26
27
|
plane: "user",
|
|
27
28
|
gates: [],
|
|
28
29
|
unguarded: true,
|
|
30
|
+
explicitPublic: false,
|
|
29
31
|
},
|
|
30
32
|
{
|
|
31
33
|
flowId: "reports.export",
|
|
32
34
|
plane: "user",
|
|
33
35
|
gates: ["staff"],
|
|
34
36
|
unguarded: false,
|
|
37
|
+
explicitPublic: false,
|
|
35
38
|
},
|
|
36
39
|
{
|
|
37
40
|
flowId: "console.store.query",
|
|
38
41
|
plane: "operator",
|
|
39
42
|
gates: [],
|
|
40
43
|
unguarded: false,
|
|
44
|
+
explicitPublic: false,
|
|
41
45
|
},
|
|
42
46
|
],
|
|
43
47
|
gates: [
|
|
@@ -11,6 +11,8 @@ export interface FlowGatesRecord {
|
|
|
11
11
|
readonly plane: "user" | "operator";
|
|
12
12
|
readonly gates: readonly string[];
|
|
13
13
|
readonly unguarded: boolean;
|
|
14
|
+
/** Chain includes the reserved `public` sentinel. */
|
|
15
|
+
readonly explicitPublic?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** Declared gate definition. */
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate boot audit — every HTTP trigger must declare auth posture.
|
|
3
|
+
*
|
|
4
|
+
* Fail loud: missing gate and missing {@link gate.public} → {@link GateBootError}.
|
|
5
|
+
* `unguardedHttp: "allow"` is honoured **only** when `env === "test"` — never a
|
|
6
|
+
* production-wide bypass. Migrate real apps with per-trigger `gate.public`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { GATE_PUBLIC_NAME } from "./declare.ts";
|
|
10
|
+
|
|
11
|
+
/** One HTTP flow missing declared auth posture. */
|
|
12
|
+
export interface GatePostureGap {
|
|
13
|
+
/** Flow id (`unit.name` or flow name). */
|
|
14
|
+
readonly flowId: string;
|
|
15
|
+
/** HTTP method. */
|
|
16
|
+
readonly method: string;
|
|
17
|
+
/** HTTP path template. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Thrown when HTTP triggers omit both a gate chain and {@link gate.public}.
|
|
23
|
+
* Lists every gap (Vault-style — fail once with the full set).
|
|
24
|
+
*/
|
|
25
|
+
export class GateBootError extends Error {
|
|
26
|
+
readonly gaps: readonly GatePostureGap[];
|
|
27
|
+
|
|
28
|
+
constructor(gaps: readonly GatePostureGap[]) {
|
|
29
|
+
const lines = gaps.map((g) => ` - ${g.flowId} ${g.method} ${g.path}`);
|
|
30
|
+
super(
|
|
31
|
+
`gate boot failed — ${gaps.length} HTTP trigger(s) missing auth posture (attach a gate or gate.public):\n${lines.join("\n")}`,
|
|
32
|
+
);
|
|
33
|
+
this.name = "GateBootError";
|
|
34
|
+
this.gaps = gaps;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal HTTP trigger shape for posture audit. */
|
|
39
|
+
export interface HttpTriggerPosture {
|
|
40
|
+
readonly kind: "http";
|
|
41
|
+
readonly method: string;
|
|
42
|
+
readonly path: string;
|
|
43
|
+
readonly gates: readonly (string | { readonly name: string })[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Binding shape consumed by the audit. */
|
|
47
|
+
export interface BindingPosture {
|
|
48
|
+
readonly trigger: { readonly kind: string } & Omit<Partial<HttpTriggerPosture>, "kind">;
|
|
49
|
+
readonly flow: { readonly name: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options for {@link assertHttpGatePosture}. */
|
|
53
|
+
export interface AssertHttpGatePostureOptions {
|
|
54
|
+
/**
|
|
55
|
+
* `"allow"` skips the audit **only** when {@link env} is `"test"`.
|
|
56
|
+
* Outside test, `"allow"` has no effect — enforcement still runs.
|
|
57
|
+
*/
|
|
58
|
+
readonly unguardedHttp?: "deny" | "allow";
|
|
59
|
+
/**
|
|
60
|
+
* Boot environment. Hard-coded gate: `"allow"` is ignored unless this is
|
|
61
|
+
* `"test"` (not developer-overridable beyond the env value itself).
|
|
62
|
+
*/
|
|
63
|
+
readonly env?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a gate ref is the public sentinel.
|
|
68
|
+
*
|
|
69
|
+
* @param ref - Gate name or named handle
|
|
70
|
+
*/
|
|
71
|
+
export function isPublicGateRef(ref: string | { readonly name: string }): boolean {
|
|
72
|
+
return (typeof ref === "string" ? ref : ref.name) === GATE_PUBLIC_NAME;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether an HTTP trigger has declared auth posture (any gate or public).
|
|
77
|
+
*
|
|
78
|
+
* @param gates - Trigger gate chain
|
|
79
|
+
*/
|
|
80
|
+
export function hasHttpGatePosture(
|
|
81
|
+
gates: readonly (string | { readonly name: string })[],
|
|
82
|
+
): boolean {
|
|
83
|
+
return gates.length > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Collect HTTP triggers with neither a gate nor {@link gate.public}.
|
|
88
|
+
*
|
|
89
|
+
* @param bindings - Adopted / registered bindings
|
|
90
|
+
*/
|
|
91
|
+
export function collectUnguardedHttpGaps(bindings: readonly BindingPosture[]): GatePostureGap[] {
|
|
92
|
+
const gaps: GatePostureGap[] = [];
|
|
93
|
+
for (const b of bindings) {
|
|
94
|
+
if (b.trigger.kind !== "http") continue;
|
|
95
|
+
const t = b.trigger as HttpTriggerPosture;
|
|
96
|
+
if (hasHttpGatePosture(t.gates)) continue;
|
|
97
|
+
gaps.push({
|
|
98
|
+
flowId: b.flow.name,
|
|
99
|
+
method: t.method,
|
|
100
|
+
path: t.path,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return gaps;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether `unguardedHttp: "allow"` may skip the audit for this boot.
|
|
108
|
+
* Hard-coded: only `env === "test"`.
|
|
109
|
+
*
|
|
110
|
+
* @param unguardedHttp - Requested opt-out
|
|
111
|
+
* @param env - Boot environment
|
|
112
|
+
*/
|
|
113
|
+
export function unguardedHttpAllowActive(
|
|
114
|
+
unguardedHttp: "deny" | "allow" | undefined,
|
|
115
|
+
env: string | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
return unguardedHttp === "allow" && env === "test";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Assert every HTTP trigger declares auth posture. Throws {@link GateBootError}.
|
|
122
|
+
*
|
|
123
|
+
* @param bindings - Adopted / registered bindings
|
|
124
|
+
* @param options - Opt-out + env (allow only in test)
|
|
125
|
+
*/
|
|
126
|
+
export function assertHttpGatePosture(
|
|
127
|
+
bindings: readonly BindingPosture[],
|
|
128
|
+
options: AssertHttpGatePostureOptions | "deny" | "allow" = "deny",
|
|
129
|
+
): void {
|
|
130
|
+
// Back-compat: second arg was previously just `"deny" | "allow"`.
|
|
131
|
+
const opts: AssertHttpGatePostureOptions =
|
|
132
|
+
typeof options === "string" ? { unguardedHttp: options } : options;
|
|
133
|
+
if (unguardedHttpAllowActive(opts.unguardedHttp, opts.env)) return;
|
|
134
|
+
const gaps = collectUnguardedHttpGaps(bindings);
|
|
135
|
+
if (gaps.length > 0) throw new GateBootError(gaps);
|
|
136
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke({ gate })` — nested Gate bag (auth · policies · rate · posture).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolveGateAuth, type GateAuthOptions, type ResolvedGateAuth } from "../../auth/config.ts";
|
|
6
|
+
import type { GateDecl } from "./declare.ts";
|
|
7
|
+
|
|
8
|
+
/** Rate-limit defaults under Gate. */
|
|
9
|
+
export interface GateRateLimitOptions {
|
|
10
|
+
/** When true, auth Flows attach stricter rate presets (default true when auth enabled). */
|
|
11
|
+
readonly enabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Nested Gate options for {@link oke}.
|
|
16
|
+
*
|
|
17
|
+
* Replaces root `auth` / `gates` / `unguardedHttp`.
|
|
18
|
+
*/
|
|
19
|
+
export interface GateOptions {
|
|
20
|
+
/** Builtin hybrid-session auth + core Flows under `basePath`. */
|
|
21
|
+
readonly auth?: GateAuthOptions;
|
|
22
|
+
/** Named policy / rate declarations for the Gate runtime. */
|
|
23
|
+
readonly policies?: readonly GateDecl[];
|
|
24
|
+
/** Global rate-limit posture (auth path presets). */
|
|
25
|
+
readonly rateLimit?: GateRateLimitOptions;
|
|
26
|
+
/**
|
|
27
|
+
* HTTP auth-posture enforcement at boot.
|
|
28
|
+
* Default `"deny"`. `"allow"` is honoured only when `env === "test"`.
|
|
29
|
+
*/
|
|
30
|
+
readonly unguardedHttp?: "deny" | "allow";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fully resolved Gate config consumed by `oke` / boot. */
|
|
34
|
+
export interface ResolvedGateConfig {
|
|
35
|
+
readonly auth: ResolvedGateAuth | undefined;
|
|
36
|
+
readonly policies: readonly GateDecl[];
|
|
37
|
+
readonly rateLimitEnabled: boolean;
|
|
38
|
+
readonly unguardedHttp: "deny" | "allow";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link resolveGateConfig}. */
|
|
42
|
+
export interface ResolveGateConfigOptions {
|
|
43
|
+
readonly gate?: GateOptions;
|
|
44
|
+
readonly env?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the nested `gate` bag for construction / boot.
|
|
49
|
+
*
|
|
50
|
+
* @param options - Gate bag + env (for auth secret rules)
|
|
51
|
+
*/
|
|
52
|
+
export function resolveGateConfig(options: ResolveGateConfigOptions = {}): ResolvedGateConfig {
|
|
53
|
+
const bag = options.gate ?? {};
|
|
54
|
+
const auth =
|
|
55
|
+
bag.auth !== undefined ? resolveGateAuth({ auth: bag.auth, env: options.env }) : undefined;
|
|
56
|
+
const rateLimitEnabled =
|
|
57
|
+
bag.rateLimit?.enabled !== undefined
|
|
58
|
+
? bag.rateLimit.enabled
|
|
59
|
+
: auth !== undefined
|
|
60
|
+
? true
|
|
61
|
+
: false;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
auth,
|
|
65
|
+
policies: bag.policies ?? [],
|
|
66
|
+
rateLimitEnabled,
|
|
67
|
+
unguardedHttp: bag.unguardedHttp ?? "deny",
|
|
68
|
+
};
|
|
69
|
+
}
|