okengine 0.15.2 → 0.16.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 +1 -1
- package/site/content/docs/elements/flow.mdx +6 -0
- package/site/content/docs/elements/gate.mdx +55 -0
- package/site/content/docs/elements/store.mdx +13 -15
- package/site/content/docs/elements/vault.mdx +3 -0
- package/site/content/docs/reference/client.mdx +3 -0
- package/site/content/docs/reference/fx.mdx +29 -18
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +254 -0
- package/src/auth/api-keys.ts +273 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +5 -5
- package/src/auth/index.ts +25 -1
- package/src/auth/schema.ts +8 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/compiler/effects-infer.ts +17 -2
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +1 -0
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +8 -21
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +99 -41
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +3 -0
- package/src/index.ts +1 -0
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app.ts +14 -2
- package/src/kernel/auth-resolve.ts +10 -5
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +198 -0
- package/src/kernel/fx.test.ts +17 -1
- package/src/kernel/fx.ts +36 -18
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline.ts +4 -2
- package/src/manifest/types.ts +9 -3
- package/src/release/build-lib.ts +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host API-key durability — standalone `oke()` (no Console).
|
|
3
|
+
*
|
|
4
|
+
* Creates a key via `fx.auth.createApiKey`, stops, boots a fresh app against
|
|
5
|
+
* the same SQL URL, and verifies Bearer on the new `gate.auth.apiKeyStore`.
|
|
6
|
+
* Production uses postgres + `DATABASE_URL`; this suite uses file-backed
|
|
7
|
+
* PGlite at that same URL so the restart is hermetic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { AUTH_TABLES } from "../auth/tables.ts";
|
|
15
|
+
import { gate } from "../elements/gate.ts";
|
|
16
|
+
import { resetStores, store } from "../elements/store/declare.ts";
|
|
17
|
+
import { oke } from "./app.ts";
|
|
18
|
+
import { flow, resetFlowSeq } from "./flow.ts";
|
|
19
|
+
import { on, resetBindings } from "./on.ts";
|
|
20
|
+
import { http } from "./triggers.ts";
|
|
21
|
+
|
|
22
|
+
const PEPPER = "oke-host-api-key-persist-pepper";
|
|
23
|
+
const member = gate.policy("member", ({ auth }) => !!auth.verified);
|
|
24
|
+
|
|
25
|
+
function resetIsolated(): void {
|
|
26
|
+
resetBindings();
|
|
27
|
+
resetFlowSeq();
|
|
28
|
+
resetStores();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
resetIsolated();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("host API key persist via store.sql()", () => {
|
|
36
|
+
test("standalone oke() persists keys across restart; Bearer verifies on a fresh store", async () => {
|
|
37
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-host-keys-"));
|
|
38
|
+
const sqlUrl = join(dir, "pgdata");
|
|
39
|
+
const prevDb = process.env.DATABASE_URL;
|
|
40
|
+
const prevPglite = process.env.OKE_PGLITE_URL;
|
|
41
|
+
process.env.DATABASE_URL = sqlUrl;
|
|
42
|
+
process.env.OKE_PGLITE_URL = sqlUrl;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
resetIsolated();
|
|
46
|
+
const db1 = store.sql("app", { schema: {} });
|
|
47
|
+
on(
|
|
48
|
+
http.post("/keys").gate(member),
|
|
49
|
+
flow("keys.create", {
|
|
50
|
+
effects: { writes: ["auth:api-keys"] },
|
|
51
|
+
do: (_input, fx) => fx.auth.createApiKey({ name: "prod-ci", scopes: ["member"] }),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
on(
|
|
55
|
+
http.get("/secure").gate(member),
|
|
56
|
+
flow("secure.ping", {
|
|
57
|
+
effects: {},
|
|
58
|
+
do: () => ({ ok: true }),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const first = oke({
|
|
63
|
+
name: "host-keys-first",
|
|
64
|
+
stores: [db1],
|
|
65
|
+
gate: {
|
|
66
|
+
auth: { secret: PEPPER, http: false },
|
|
67
|
+
policies: [member],
|
|
68
|
+
},
|
|
69
|
+
env: "test",
|
|
70
|
+
startScheduler: false,
|
|
71
|
+
});
|
|
72
|
+
await first.boot({ env: "test", startScheduler: false });
|
|
73
|
+
expect(first.apiKeys?.persist).toBeTypeOf("function");
|
|
74
|
+
|
|
75
|
+
const created = await first.execute(
|
|
76
|
+
first.flow("keys.create")!,
|
|
77
|
+
{},
|
|
78
|
+
http.post("/keys").gate(member),
|
|
79
|
+
{
|
|
80
|
+
principal: {
|
|
81
|
+
plane: "user",
|
|
82
|
+
userId: "u_issuer",
|
|
83
|
+
scopes: new Set(["member"]),
|
|
84
|
+
verified: true,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
expect(created.failure).toBeUndefined();
|
|
89
|
+
const secret = (created.output as { secret?: string } | undefined)?.secret;
|
|
90
|
+
expect(secret?.startsWith("oke_")).toBe(true);
|
|
91
|
+
|
|
92
|
+
const firstStore = first.bootResult?.store;
|
|
93
|
+
await first.stop();
|
|
94
|
+
await firstStore?.close();
|
|
95
|
+
|
|
96
|
+
resetIsolated();
|
|
97
|
+
const db2 = store.sql("app", { schema: {} });
|
|
98
|
+
on(
|
|
99
|
+
http.get("/secure").gate(member),
|
|
100
|
+
flow("secure.ping", {
|
|
101
|
+
effects: {},
|
|
102
|
+
do: () => ({ ok: true }),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const second = oke({
|
|
106
|
+
name: "host-keys-second",
|
|
107
|
+
stores: [db2],
|
|
108
|
+
gate: {
|
|
109
|
+
auth: { secret: PEPPER, http: false },
|
|
110
|
+
policies: [member],
|
|
111
|
+
},
|
|
112
|
+
env: "test",
|
|
113
|
+
startScheduler: false,
|
|
114
|
+
});
|
|
115
|
+
await second.boot({ env: "test", startScheduler: false });
|
|
116
|
+
expect(second.apiKeys).not.toBe(first.apiKeys);
|
|
117
|
+
expect(second.apiKeys?.keys.size).toBeGreaterThan(0);
|
|
118
|
+
|
|
119
|
+
const conn = await second.bootResult?.store?.primarySql();
|
|
120
|
+
const rows = await conn?.query(`SELECT id, name FROM ${AUTH_TABLES.apiKeys}`);
|
|
121
|
+
expect(rows?.some((r) => r.name === "prod-ci")).toBe(true);
|
|
122
|
+
|
|
123
|
+
const res = await second.fetch(
|
|
124
|
+
new Request("http://localhost/secure", {
|
|
125
|
+
method: "GET",
|
|
126
|
+
headers: { authorization: `Bearer ${secret}` },
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
expect(await res.json()).toEqual({ data: { ok: true }, error: null });
|
|
131
|
+
|
|
132
|
+
const secondStore = second.bootResult?.store;
|
|
133
|
+
await second.stop();
|
|
134
|
+
await secondStore?.close();
|
|
135
|
+
} finally {
|
|
136
|
+
if (prevDb === undefined) delete process.env.DATABASE_URL;
|
|
137
|
+
else process.env.DATABASE_URL = prevDb;
|
|
138
|
+
if (prevPglite === undefined) delete process.env.OKE_PGLITE_URL;
|
|
139
|
+
else process.env.OKE_PGLITE_URL = prevPglite;
|
|
140
|
+
await rm(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}, 20_000);
|
|
143
|
+
});
|
package/src/kernel/app.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import type { BootOptions, BootResult, ElementRuntimes } from "./boot.ts";
|
|
25
25
|
import type { CapabilityToken } from "./capability.ts";
|
|
26
26
|
import type { AppAuthBinding } from "./auth-resolve.ts";
|
|
27
|
+
import type { ApiKeyStore } from "../auth/api-keys.ts";
|
|
27
28
|
import type { AuthHttpMaterialization } from "../auth/bindings.ts";
|
|
28
29
|
import type { WiredGateAuth } from "./app-auth.ts";
|
|
29
30
|
import {
|
|
@@ -371,6 +372,8 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
|
|
|
371
372
|
* Builtin auth binding after boot (session store + secret), when configured.
|
|
372
373
|
*/
|
|
373
374
|
readonly authBinding: AppAuthBinding | undefined;
|
|
375
|
+
/** Shared API key store when `gate.auth` is enabled. */
|
|
376
|
+
readonly apiKeys: ApiKeyStore | undefined;
|
|
374
377
|
/**
|
|
375
378
|
* Attach a plugin app-wide. Scope is the attachment point.
|
|
376
379
|
* Types accumulate — decorations are visible on downstream handlers.
|
|
@@ -1176,6 +1179,10 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1176
1179
|
};
|
|
1177
1180
|
const result = await bootApplication(merged);
|
|
1178
1181
|
bootResult = result;
|
|
1182
|
+
if (gateConfig.auth?.apiKeyStore && result.store) {
|
|
1183
|
+
const { bindHostApiKeySqlFromStore } = await import("../auth/api-key-sql.ts");
|
|
1184
|
+
await bindHostApiKeySqlFromStore(result.store, gateConfig.auth.apiKeyStore);
|
|
1185
|
+
}
|
|
1179
1186
|
// otp() provider / app mode capability — fail loud at boot, never silent downgrade.
|
|
1180
1187
|
if (result.channel) {
|
|
1181
1188
|
const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
|
|
@@ -1365,6 +1372,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1365
1372
|
userId: options.fx?.auth?.userId ?? null,
|
|
1366
1373
|
scopes: new Set(options.fx?.auth?.scopes ?? []),
|
|
1367
1374
|
verified: options.fx?.auth?.verified,
|
|
1375
|
+
apiKeyId: options.fx?.auth?.apiKeyId,
|
|
1368
1376
|
},
|
|
1369
1377
|
operator: { id: options.fx?.operator?.id ?? null },
|
|
1370
1378
|
};
|
|
@@ -1393,7 +1401,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1393
1401
|
: {}),
|
|
1394
1402
|
verifyBearer:
|
|
1395
1403
|
binding && wiredAuth
|
|
1396
|
-
?
|
|
1404
|
+
? (token, request) =>
|
|
1405
|
+
wiredAuth.verifyBearerOrApiKey(binding, token, apiKeyStore, request)
|
|
1397
1406
|
: undefined,
|
|
1398
1407
|
// Phase 1a: opt-in cookie → Bearer when Authorization is absent.
|
|
1399
1408
|
resolveToken:
|
|
@@ -1450,7 +1459,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1450
1459
|
catalogs: loadMessages().getMessageCatalogs(),
|
|
1451
1460
|
...options.fx?.i18n,
|
|
1452
1461
|
},
|
|
1453
|
-
...(principals ? { auth: principals.auth
|
|
1462
|
+
...(principals ? { auth: principals.auth, operator: principals.operator } : {}),
|
|
1463
|
+
apiKeyStore: gateConfig.auth?.apiKeyStore,
|
|
1454
1464
|
...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
|
|
1455
1465
|
...(extras?.trustedInvoke === true && extras.revealPii === true ? { revealPii: true } : {}),
|
|
1456
1466
|
rlsGateNames: gateNamesOf(trigger),
|
|
@@ -1604,6 +1614,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1604
1614
|
}
|
|
1605
1615
|
return await invoke(ctx.input);
|
|
1606
1616
|
} catch (err) {
|
|
1617
|
+
if (isFlowFailure(err)) return err;
|
|
1607
1618
|
// A durable sleep that has not yet elapsed suspends the run — this
|
|
1608
1619
|
// is a park, not a pipeline failure, so it must not throw upward.
|
|
1609
1620
|
if (flowDef.durable && journalSession && isJournalSuspend(err)) {
|
|
@@ -1780,6 +1791,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1780
1791
|
get authBinding() {
|
|
1781
1792
|
return authBinding;
|
|
1782
1793
|
},
|
|
1794
|
+
apiKeys: gateConfig.auth?.apiKeyStore,
|
|
1783
1795
|
async resumeDurable(now) {
|
|
1784
1796
|
const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
1785
1797
|
const { store, instanceId, leaseMs } = activeJournal();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* separately in the pipeline (`allowTestPrincipals`).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { authenticateApiKey, type ApiKeyStore } from "../auth/api-keys.ts";
|
|
9
|
+
import { authenticateApiKey, clientIpFromRequest, type ApiKeyStore } from "../auth/api-keys.ts";
|
|
10
10
|
import type { ApiKeyRow } from "../auth/tables.ts";
|
|
11
11
|
import {
|
|
12
12
|
createSessionStore,
|
|
@@ -89,7 +89,9 @@ export function claimsToPrincipal(claims: AccessClaims): ResolvedPrincipal {
|
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* Map an authenticated API key row to the pipeline principal shape.
|
|
92
|
-
*
|
|
92
|
+
*
|
|
93
|
+
* The key is the issuer with fewer gates: `userId` / `operatorId` is
|
|
94
|
+
* {@link ApiKeyRow.creatorId}. The credential id is {@link ResolvedPrincipal.apiKeyId}.
|
|
93
95
|
*
|
|
94
96
|
* @param row - Authenticated key
|
|
95
97
|
*/
|
|
@@ -97,7 +99,7 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
|
|
|
97
99
|
if (row.plane === "operator") {
|
|
98
100
|
return {
|
|
99
101
|
plane: "operator",
|
|
100
|
-
operatorId: row.
|
|
102
|
+
operatorId: row.creatorId,
|
|
101
103
|
userId: null,
|
|
102
104
|
scopes: row.scopes,
|
|
103
105
|
verified: true,
|
|
@@ -106,7 +108,7 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
|
|
|
106
108
|
}
|
|
107
109
|
return {
|
|
108
110
|
plane: "user",
|
|
109
|
-
userId: row.
|
|
111
|
+
userId: row.creatorId,
|
|
110
112
|
scopes: row.scopes,
|
|
111
113
|
verified: true,
|
|
112
114
|
apiKeyId: row.id,
|
|
@@ -119,17 +121,20 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
|
|
|
119
121
|
* @param auth - Session binding
|
|
120
122
|
* @param token - Raw Bearer token
|
|
121
123
|
* @param apiKeys - Optional key store
|
|
124
|
+
* @param request - Incoming request (allowlist IP is read here, not in `oke()`)
|
|
122
125
|
*/
|
|
123
126
|
export async function verifyBearerOrApiKey(
|
|
124
127
|
auth: AppAuthBinding,
|
|
125
128
|
token: string,
|
|
126
129
|
apiKeys?: ApiKeyStore,
|
|
130
|
+
request?: Request,
|
|
127
131
|
): Promise<ResolvedPrincipal> {
|
|
128
132
|
try {
|
|
129
133
|
return await verifyBearerToken(auth, token);
|
|
130
134
|
} catch (err) {
|
|
131
135
|
if (apiKeys) {
|
|
132
|
-
const
|
|
136
|
+
const ip = request ? clientIpFromRequest(request) : undefined;
|
|
137
|
+
const row = await authenticateApiKey(apiKeys, token, { now: auth.now, ip });
|
|
133
138
|
if (row) return apiKeyRowToPrincipal(row);
|
|
134
139
|
}
|
|
135
140
|
throw err;
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
|
|
|
79
79
|
expect(needs.signal).toBe(false);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
test("oke() Store-only graph stays under the prior
|
|
82
|
+
test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
|
|
83
83
|
const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
|
|
84
84
|
const entry = join(dir, "entry.ts");
|
|
85
85
|
const appPath = join(import.meta.dir, "app.ts");
|
|
@@ -116,9 +116,9 @@ describe("boot — lazy element needs", () => {
|
|
|
116
116
|
if (raw.byteLength === 0) continue;
|
|
117
117
|
total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
|
|
118
118
|
}
|
|
119
|
-
// Rebased after
|
|
120
|
-
// (~
|
|
121
|
-
expect(total).toBeLessThan(
|
|
119
|
+
// Rebased after host API-key persist + allowlist on `oke()`.
|
|
120
|
+
// (~50.3 kB gzip with export externals).
|
|
121
|
+
expect(total).toBeLessThan(51_000);
|
|
122
122
|
} finally {
|
|
123
123
|
await rm(dir, { recursive: true, force: true });
|
|
124
124
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-only `fx.auth` key methods — issuer derived, never from flow input.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { AttenuationError } from "../auth/attenuation.ts";
|
|
7
|
+
import { createApiKey, createApiKeyStore } from "../auth/api-keys.ts";
|
|
8
|
+
import { isFlowFailure } from "./hooks.ts";
|
|
9
|
+
import { createFx } from "./fx.ts";
|
|
10
|
+
|
|
11
|
+
describe("fx.auth key methods", () => {
|
|
12
|
+
test("createApiKey derives creator from the live session and rejects broader scopes", async () => {
|
|
13
|
+
const store = createApiKeyStore();
|
|
14
|
+
const fx = createFx({
|
|
15
|
+
flow: "keys.create",
|
|
16
|
+
effects: { writes: ["auth:api-keys"] },
|
|
17
|
+
apiKeyStore: store,
|
|
18
|
+
auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: null },
|
|
19
|
+
});
|
|
20
|
+
const created = await fx.auth.createApiKey({ name: "mine", scopes: ["member"] });
|
|
21
|
+
expect(created.key.scopes).toEqual(["member"]);
|
|
22
|
+
expect(created.secret.startsWith("oke_")).toBe(true);
|
|
23
|
+
expect(store.keys.get(created.key.id)?.creatorId).toBe("u1");
|
|
24
|
+
|
|
25
|
+
await expect(fx.auth.createApiKey({ name: "wide", scopes: ["admin"] })).rejects.toBeInstanceOf(
|
|
26
|
+
AttenuationError,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("key-authenticated caller cannot create / list / revoke / rotate / update", async () => {
|
|
31
|
+
const store = createApiKeyStore();
|
|
32
|
+
await createApiKey(store, {
|
|
33
|
+
plane: "user",
|
|
34
|
+
name: "existing",
|
|
35
|
+
scopes: ["member"],
|
|
36
|
+
creatorId: "u1",
|
|
37
|
+
creatorScopes: ["member"],
|
|
38
|
+
id: "key_existing",
|
|
39
|
+
});
|
|
40
|
+
const fx = createFx({
|
|
41
|
+
flow: "keys.as-key",
|
|
42
|
+
effects: { reads: ["auth:api-keys"], writes: ["auth:api-keys"] },
|
|
43
|
+
apiKeyStore: store,
|
|
44
|
+
auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: "key_existing" },
|
|
45
|
+
});
|
|
46
|
+
for (const call of [
|
|
47
|
+
() => fx.auth.createApiKey({ name: "nested", scopes: ["member"] }),
|
|
48
|
+
() => fx.auth.listApiKeys(),
|
|
49
|
+
() => fx.auth.revokeApiKey("key_existing"),
|
|
50
|
+
() => fx.auth.rotateApiKey("key_existing"),
|
|
51
|
+
() => fx.auth.updateApiKey("key_existing", { name: "nope" }),
|
|
52
|
+
]) {
|
|
53
|
+
try {
|
|
54
|
+
await call();
|
|
55
|
+
throw new Error("expected Forbidden");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
expect(isFlowFailure(err)).toBe(true);
|
|
58
|
+
if (isFlowFailure(err)) expect(err.error.code).toBe("Forbidden");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("list is self-only; cross-user revoke is Forbidden", async () => {
|
|
64
|
+
const store = createApiKeyStore();
|
|
65
|
+
await createApiKey(store, {
|
|
66
|
+
plane: "user",
|
|
67
|
+
name: "theirs",
|
|
68
|
+
scopes: ["member"],
|
|
69
|
+
creatorId: "u2",
|
|
70
|
+
creatorScopes: ["member"],
|
|
71
|
+
id: "key_theirs",
|
|
72
|
+
});
|
|
73
|
+
const fx = createFx({
|
|
74
|
+
flow: "keys.list",
|
|
75
|
+
effects: { reads: ["auth:api-keys"], writes: ["auth:api-keys"] },
|
|
76
|
+
apiKeyStore: store,
|
|
77
|
+
auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: null },
|
|
78
|
+
});
|
|
79
|
+
expect(await fx.auth.listApiKeys()).toEqual([]);
|
|
80
|
+
try {
|
|
81
|
+
await fx.auth.revokeApiKey("key_theirs");
|
|
82
|
+
throw new Error("expected Forbidden");
|
|
83
|
+
} catch (err) {
|
|
84
|
+
expect(isFlowFailure(err)).toBe(true);
|
|
85
|
+
if (isFlowFailure(err)) expect(err.error.code).toBe("Forbidden");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.auth` key-management methods — session-only, attenuated, one store.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AUTH_API_KEYS_RESOURCE,
|
|
7
|
+
createApiKey,
|
|
8
|
+
getApiKey,
|
|
9
|
+
listApiKeys,
|
|
10
|
+
revokeApiKey,
|
|
11
|
+
rotateApiKey,
|
|
12
|
+
toApiKeyPublicRow,
|
|
13
|
+
updateApiKey,
|
|
14
|
+
type ApiKeyPublicRow,
|
|
15
|
+
type ApiKeyStore,
|
|
16
|
+
type CreatedApiKey,
|
|
17
|
+
} from "../auth/api-keys.ts";
|
|
18
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
19
|
+
import type { EffectKind } from "./effects.ts";
|
|
20
|
+
import { fail, type FlowFailure } from "./errors.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Identity bag on {@link FxAuth} before key-management methods attach.
|
|
24
|
+
* Passed into {@link createFx} / {@link attach}.
|
|
25
|
+
*/
|
|
26
|
+
export interface FxAuthIdentity {
|
|
27
|
+
/** User-plane subject id, or null when unauthenticated. */
|
|
28
|
+
readonly userId: string | null;
|
|
29
|
+
/** Granted scopes for the live principal. */
|
|
30
|
+
readonly scopes: ReadonlySet<string>;
|
|
31
|
+
/** Whether the identity has completed verification (email / MFA). */
|
|
32
|
+
readonly verified?: boolean;
|
|
33
|
+
/** Authenticating API key id when Bearer was a key secret; otherwise null. */
|
|
34
|
+
readonly apiKeyId?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options for {@link FxAuth.createApiKey}. */
|
|
38
|
+
export interface FxCreateApiKeyInput {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly scopes: readonly string[];
|
|
41
|
+
readonly expiresIn?: string;
|
|
42
|
+
readonly ipAllowlist?: readonly string[];
|
|
43
|
+
readonly rateLimit?: { max: number; per: string } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Options for {@link FxAuth.updateApiKey}. */
|
|
47
|
+
export interface FxUpdateApiKeyInput {
|
|
48
|
+
readonly name?: string;
|
|
49
|
+
readonly scopes?: readonly string[];
|
|
50
|
+
readonly expiresIn?: string;
|
|
51
|
+
readonly ipAllowlist?: readonly string[];
|
|
52
|
+
readonly rateLimit?: { max: number; per: string } | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Create result for `fx.auth.createApiKey` / `rotateApiKey`. */
|
|
56
|
+
export interface FxCreatedApiKey {
|
|
57
|
+
readonly key: ApiKeyPublicRow;
|
|
58
|
+
readonly secret: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Session-only key management attached onto {@link FxAuthIdentity}.
|
|
63
|
+
* Methods throw {@link FlowFailure} values (`Unauthorized` / `Forbidden`).
|
|
64
|
+
*/
|
|
65
|
+
export interface FxAuthKeyMethods {
|
|
66
|
+
createApiKey(input: FxCreateApiKeyInput): Promise<FxCreatedApiKey>;
|
|
67
|
+
listApiKeys(): Promise<readonly ApiKeyPublicRow[]>;
|
|
68
|
+
revokeApiKey(id: string): Promise<ApiKeyPublicRow>;
|
|
69
|
+
rotateApiKey(id: string): Promise<FxCreatedApiKey>;
|
|
70
|
+
updateApiKey(id: string, input: FxUpdateApiKeyInput): Promise<ApiKeyPublicRow>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Dependencies for {@link attach}. */
|
|
74
|
+
export interface AttachAuthKeyMethodsOptions {
|
|
75
|
+
readonly auth: FxAuthIdentity;
|
|
76
|
+
readonly store: ApiKeyStore | undefined;
|
|
77
|
+
readonly now: () => number;
|
|
78
|
+
readonly gated: <T>(kind: EffectKind, resource: string, body: () => T | Promise<T>) => Promise<T>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sessionOnly(auth: FxAuthIdentity): FlowFailure | null {
|
|
82
|
+
if (auth.apiKeyId) {
|
|
83
|
+
return fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "session_only" });
|
|
84
|
+
}
|
|
85
|
+
if (!auth.userId) {
|
|
86
|
+
return fail("Unauthorized", {});
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requireStore(store: ApiKeyStore | undefined): ApiKeyStore {
|
|
92
|
+
if (!store) {
|
|
93
|
+
throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "no_store" });
|
|
94
|
+
}
|
|
95
|
+
return store;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireOwned(
|
|
99
|
+
store: ApiKeyStore,
|
|
100
|
+
id: string,
|
|
101
|
+
userId: string,
|
|
102
|
+
): ReturnType<typeof getApiKey> {
|
|
103
|
+
const row = getApiKey(store, id);
|
|
104
|
+
if (!row || row.creatorId !== userId) {
|
|
105
|
+
throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
|
|
106
|
+
}
|
|
107
|
+
return row;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function expiresAtFrom(expiresIn: string | undefined, now: () => number): number | undefined {
|
|
111
|
+
if (expiresIn === undefined) return undefined;
|
|
112
|
+
return now() + parseDurationMs(expiresIn);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Attach create / list / revoke / rotate / update onto a live identity bag.
|
|
117
|
+
*
|
|
118
|
+
* @param options - Auth bag + store + capability gate
|
|
119
|
+
*/
|
|
120
|
+
export function attach(options: AttachAuthKeyMethodsOptions): FxAuthIdentity & FxAuthKeyMethods {
|
|
121
|
+
const { auth, now, gated } = options;
|
|
122
|
+
|
|
123
|
+
const methods: FxAuthKeyMethods = {
|
|
124
|
+
createApiKey(input) {
|
|
125
|
+
return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
|
|
126
|
+
const denied = sessionOnly(auth);
|
|
127
|
+
if (denied) throw denied;
|
|
128
|
+
const store = requireStore(options.store);
|
|
129
|
+
const created: CreatedApiKey = await createApiKey(store, {
|
|
130
|
+
plane: "user",
|
|
131
|
+
name: input.name,
|
|
132
|
+
scopes: input.scopes,
|
|
133
|
+
creatorId: auth.userId!,
|
|
134
|
+
creatorScopes: auth.scopes,
|
|
135
|
+
expiresAt: expiresAtFrom(input.expiresIn, now) ?? null,
|
|
136
|
+
rateLimit: input.rateLimit ?? null,
|
|
137
|
+
ipAllowlist: input.ipAllowlist,
|
|
138
|
+
now,
|
|
139
|
+
});
|
|
140
|
+
return { key: toApiKeyPublicRow(created.row), secret: created.secret };
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
listApiKeys() {
|
|
144
|
+
return gated("read", AUTH_API_KEYS_RESOURCE, async () => {
|
|
145
|
+
const denied = sessionOnly(auth);
|
|
146
|
+
if (denied) throw denied;
|
|
147
|
+
const store = requireStore(options.store);
|
|
148
|
+
return listApiKeys(store, auth.userId!).map(toApiKeyPublicRow);
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
revokeApiKey(id) {
|
|
152
|
+
return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
|
|
153
|
+
const denied = sessionOnly(auth);
|
|
154
|
+
if (denied) throw denied;
|
|
155
|
+
const store = requireStore(options.store);
|
|
156
|
+
requireOwned(store, id, auth.userId!);
|
|
157
|
+
const row = revokeApiKey(store, id, now);
|
|
158
|
+
if (!row) throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
|
|
159
|
+
return toApiKeyPublicRow(row);
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
rotateApiKey(id) {
|
|
163
|
+
return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
|
|
164
|
+
const denied = sessionOnly(auth);
|
|
165
|
+
if (denied) throw denied;
|
|
166
|
+
const store = requireStore(options.store);
|
|
167
|
+
requireOwned(store, id, auth.userId!);
|
|
168
|
+
const rotated = await rotateApiKey(store, id, { now });
|
|
169
|
+
if (!rotated)
|
|
170
|
+
throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
|
|
171
|
+
return { key: toApiKeyPublicRow(rotated.row), secret: rotated.secret };
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
updateApiKey(id, input) {
|
|
175
|
+
return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
|
|
176
|
+
const denied = sessionOnly(auth);
|
|
177
|
+
if (denied) throw denied;
|
|
178
|
+
const store = requireStore(options.store);
|
|
179
|
+
const existing = requireOwned(store, id, auth.userId!);
|
|
180
|
+
if (!existing)
|
|
181
|
+
throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
|
|
182
|
+
const ceiling = new Set([...existing.creatorScopes].filter((s) => auth.scopes.has(s)));
|
|
183
|
+
const row = updateApiKey(store, id, {
|
|
184
|
+
ceiling,
|
|
185
|
+
name: input.name,
|
|
186
|
+
scopes: input.scopes,
|
|
187
|
+
expiresAt: expiresAtFrom(input.expiresIn, now),
|
|
188
|
+
rateLimit: input.rateLimit,
|
|
189
|
+
ipAllowlist: input.ipAllowlist,
|
|
190
|
+
});
|
|
191
|
+
if (!row) throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
|
|
192
|
+
return toApiKeyPublicRow(row);
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return Object.assign(auth, methods);
|
|
198
|
+
}
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -432,7 +432,23 @@ describe("fx — wholesale swap", () => {
|
|
|
432
432
|
t: (key) => key,
|
|
433
433
|
locale: "en",
|
|
434
434
|
id: () => "fixed-id",
|
|
435
|
-
auth: {
|
|
435
|
+
auth: {
|
|
436
|
+
userId: "u1",
|
|
437
|
+
scopes: new Set(["a"]),
|
|
438
|
+
createApiKey: async () => {
|
|
439
|
+
throw new Error("auth.createApiKey must not be used");
|
|
440
|
+
},
|
|
441
|
+
listApiKeys: async () => [],
|
|
442
|
+
revokeApiKey: async () => {
|
|
443
|
+
throw new Error("auth.revokeApiKey must not be used");
|
|
444
|
+
},
|
|
445
|
+
rotateApiKey: async () => {
|
|
446
|
+
throw new Error("auth.rotateApiKey must not be used");
|
|
447
|
+
},
|
|
448
|
+
updateApiKey: async () => {
|
|
449
|
+
throw new Error("auth.updateApiKey must not be used");
|
|
450
|
+
},
|
|
451
|
+
},
|
|
436
452
|
operator: { id: null },
|
|
437
453
|
principal: { userId: "u1", operatorId: null, scopes: new Set(["a"]) },
|
|
438
454
|
tenant: { id: "t1" },
|