okengine 0.15.2 → 0.17.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/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +20 -12
- package/site/content/docs/elements/gate.mdx +108 -3
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +36 -32
- package/site/content/docs/elements/vault.mdx +26 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +75 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +39 -22
- 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 +261 -0
- package/src/auth/api-keys.ts +276 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +24 -5
- package/src/auth/index.ts +62 -1
- package/src/auth/plugin.ts +6 -1
- package/src/auth/schema.ts +8 -0
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +37 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +45 -11
- package/src/console/server/console.test.ts +6 -8
- 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 +10 -30
- 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/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.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-XDpAJO8f.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-BBj2QJCu.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-BFaay44m.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-CS5-aETQ.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-BtyhmWb4.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.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-BmOeAFwg.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/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- 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-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +162 -44
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- 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 +5 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +2 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +196 -57
- package/src/kernel/auth-resolve.ts +13 -5
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +203 -0
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +108 -1
- package/src/kernel/fx.ts +206 -28
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +41 -4
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +58 -6
- package/src/release/build-lib.ts +14 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- package/src/client/live-gap.test.ts +0 -35
- 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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL persist / hydrate for {@link ApiKeyRow} on `oke_api_keys`.
|
|
3
|
+
*
|
|
4
|
+
* Host path: public schema on the app `store.sql()` connection
|
|
5
|
+
* (`sharedSqlConn` / `DATABASE_URL`). Console attaches that same
|
|
6
|
+
* {@link ApiKeyStore} — it does not own a second key table.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SqlConnection } from "../drivers/types.ts";
|
|
10
|
+
import type { ApiKeyRow } from "./tables.ts";
|
|
11
|
+
import { AUTH_TABLES } from "./tables.ts";
|
|
12
|
+
import type { ApiKeyStore } from "./api-keys.ts";
|
|
13
|
+
|
|
14
|
+
/** Minimal SQL executor for the auth key table. */
|
|
15
|
+
export interface ApiKeySqlExec {
|
|
16
|
+
execute(sql: string, params: readonly unknown[]): Promise<void>;
|
|
17
|
+
all(sql: string): Promise<readonly Record<string, unknown>[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Persist one key row to `oke_api_keys`.
|
|
22
|
+
*
|
|
23
|
+
* @param sql - Executor
|
|
24
|
+
* @param row - Key row
|
|
25
|
+
* @param table - Physical table (`oke_api_keys` or schema-qualified)
|
|
26
|
+
*/
|
|
27
|
+
export async function persistApiKeyRow(
|
|
28
|
+
sql: ApiKeySqlExec,
|
|
29
|
+
row: ApiKeyRow,
|
|
30
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
await sql.execute(
|
|
33
|
+
`INSERT INTO ${table} (
|
|
34
|
+
id, plane, hash, name, scopes, expires_at, rate_limit, ip_allowlist,
|
|
35
|
+
creator_id, creator_scopes, created_at, last_used_at, revoked_at, tenant_id
|
|
36
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
37
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
38
|
+
hash = excluded.hash,
|
|
39
|
+
name = excluded.name,
|
|
40
|
+
scopes = excluded.scopes,
|
|
41
|
+
expires_at = excluded.expires_at,
|
|
42
|
+
rate_limit = excluded.rate_limit,
|
|
43
|
+
ip_allowlist = excluded.ip_allowlist,
|
|
44
|
+
creator_id = excluded.creator_id,
|
|
45
|
+
creator_scopes = excluded.creator_scopes,
|
|
46
|
+
last_used_at = excluded.last_used_at,
|
|
47
|
+
revoked_at = excluded.revoked_at,
|
|
48
|
+
tenant_id = excluded.tenant_id`,
|
|
49
|
+
[
|
|
50
|
+
row.id,
|
|
51
|
+
row.plane,
|
|
52
|
+
row.hash,
|
|
53
|
+
row.name,
|
|
54
|
+
JSON.stringify(row.scopes),
|
|
55
|
+
row.expiresAt,
|
|
56
|
+
row.rateLimit ? JSON.stringify(row.rateLimit) : null,
|
|
57
|
+
JSON.stringify(row.ipAllowlist),
|
|
58
|
+
row.creatorId,
|
|
59
|
+
JSON.stringify(row.creatorScopes),
|
|
60
|
+
row.createdAt,
|
|
61
|
+
row.lastUsedAt,
|
|
62
|
+
row.revokedAt,
|
|
63
|
+
row.tenantId ?? null,
|
|
64
|
+
],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load every key row from `oke_api_keys` into a store.
|
|
70
|
+
*
|
|
71
|
+
* @param sql - Executor
|
|
72
|
+
* @param store - Destination store
|
|
73
|
+
* @param table - Physical table
|
|
74
|
+
*/
|
|
75
|
+
export async function hydrateApiKeyStore(
|
|
76
|
+
sql: ApiKeySqlExec,
|
|
77
|
+
store: ApiKeyStore,
|
|
78
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const rows = await sql.all(`SELECT * FROM ${table}`);
|
|
81
|
+
for (const raw of rows) {
|
|
82
|
+
const row = rowFromSql(raw);
|
|
83
|
+
if (row) store.keys.set(row.id, row);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bind write-through persist on a store.
|
|
89
|
+
*
|
|
90
|
+
* @param store - Key store
|
|
91
|
+
* @param sql - Executor
|
|
92
|
+
* @param table - Physical table
|
|
93
|
+
*/
|
|
94
|
+
export function bindApiKeySqlPersist(
|
|
95
|
+
store: ApiKeyStore,
|
|
96
|
+
sql: ApiKeySqlExec,
|
|
97
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
98
|
+
): void {
|
|
99
|
+
store.persist = (row) => persistApiKeyRow(sql, row, table);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Runtime that can open the app's shared primary SQL connection. */
|
|
103
|
+
export interface HostApiKeySqlRuntime {
|
|
104
|
+
primarySql(): Promise<SqlConnection | undefined>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adapt a driver connection to {@link ApiKeySqlExec}.
|
|
109
|
+
*
|
|
110
|
+
* @param conn - Shared `store.sql()` connection
|
|
111
|
+
*/
|
|
112
|
+
export function apiKeySqlExec(conn: SqlConnection): ApiKeySqlExec {
|
|
113
|
+
return {
|
|
114
|
+
execute: async (sql, params) => {
|
|
115
|
+
await conn.exec(sql, params);
|
|
116
|
+
},
|
|
117
|
+
all: async (sql) => conn.query(sql),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Create public `oke_api_keys` when missing (host schema, not `oke_console`).
|
|
123
|
+
*
|
|
124
|
+
* @param conn - Shared SQL connection
|
|
125
|
+
* @param table - Physical table
|
|
126
|
+
*/
|
|
127
|
+
export async function ensureApiKeyTable(
|
|
128
|
+
conn: SqlConnection,
|
|
129
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
await conn.exec(`
|
|
132
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
133
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
134
|
+
plane TEXT NOT NULL,
|
|
135
|
+
hash TEXT NOT NULL,
|
|
136
|
+
name TEXT NOT NULL,
|
|
137
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
138
|
+
expires_at BIGINT,
|
|
139
|
+
rate_limit TEXT,
|
|
140
|
+
ip_allowlist TEXT NOT NULL DEFAULT '[]',
|
|
141
|
+
creator_id TEXT NOT NULL,
|
|
142
|
+
creator_scopes TEXT NOT NULL DEFAULT '[]',
|
|
143
|
+
created_at BIGINT NOT NULL,
|
|
144
|
+
last_used_at BIGINT,
|
|
145
|
+
revoked_at BIGINT,
|
|
146
|
+
tenant_id TEXT
|
|
147
|
+
)
|
|
148
|
+
`);
|
|
149
|
+
await conn.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS tenant_id TEXT`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Hydrate and write-through-persist a host {@link ApiKeyStore} on `conn`.
|
|
154
|
+
*
|
|
155
|
+
* Leaves an already-bound `persist` hook in place (injected test stores).
|
|
156
|
+
*
|
|
157
|
+
* @param conn - App SQL connection (`sharedSqlConn`)
|
|
158
|
+
* @param store - `gate.auth.apiKeyStore`
|
|
159
|
+
* @param table - Physical table (`oke_api_keys` in `public`)
|
|
160
|
+
*/
|
|
161
|
+
export async function bindHostApiKeySql(
|
|
162
|
+
conn: SqlConnection,
|
|
163
|
+
store: ApiKeyStore,
|
|
164
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
await ensureApiKeyTable(conn, table);
|
|
167
|
+
const exec = apiKeySqlExec(conn);
|
|
168
|
+
await hydrateApiKeyStore(exec, store, table);
|
|
169
|
+
if (!store.persist) bindApiKeySqlPersist(store, exec, table);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Bind host key persist through {@link HostApiKeySqlRuntime.primarySql}.
|
|
174
|
+
*
|
|
175
|
+
* No-ops when the store runtime has no SQL driver (in-memory-only apps).
|
|
176
|
+
*
|
|
177
|
+
* @param runtime - Booted `store` element
|
|
178
|
+
* @param store - `gate.auth.apiKeyStore`
|
|
179
|
+
*/
|
|
180
|
+
export async function bindHostApiKeySqlFromStore(
|
|
181
|
+
runtime: HostApiKeySqlRuntime,
|
|
182
|
+
store: ApiKeyStore,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const conn = await runtime.primarySql();
|
|
185
|
+
if (!conn) return;
|
|
186
|
+
await bindHostApiKeySql(conn, store);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function rowFromSql(raw: Record<string, unknown>): ApiKeyRow | null {
|
|
190
|
+
const id = asString(raw.id ?? raw.ID);
|
|
191
|
+
const plane = raw.plane === "operator" ? "operator" : "user";
|
|
192
|
+
const hash = asString(raw.hash);
|
|
193
|
+
const name = asString(raw.name);
|
|
194
|
+
const creatorId = asString(raw.creator_id ?? raw.creatorId);
|
|
195
|
+
if (!id || !hash || !name || !creatorId) return null;
|
|
196
|
+
return {
|
|
197
|
+
id,
|
|
198
|
+
plane,
|
|
199
|
+
hash,
|
|
200
|
+
name,
|
|
201
|
+
scopes: asStringArray(raw.scopes),
|
|
202
|
+
expiresAt: asNumberOrNull(raw.expires_at ?? raw.expiresAt),
|
|
203
|
+
rateLimit: asRateLimit(raw.rate_limit ?? raw.rateLimit),
|
|
204
|
+
ipAllowlist: asStringArray(raw.ip_allowlist ?? raw.ipAllowlist),
|
|
205
|
+
creatorId,
|
|
206
|
+
creatorScopes: asStringArray(raw.creator_scopes ?? raw.creatorScopes),
|
|
207
|
+
createdAt: asNumberOrNull(raw.created_at ?? raw.createdAt) ?? 0,
|
|
208
|
+
lastUsedAt: asNumberOrNull(raw.last_used_at ?? raw.lastUsedAt),
|
|
209
|
+
revokedAt: asNumberOrNull(raw.revoked_at ?? raw.revokedAt),
|
|
210
|
+
...(asString(raw.tenant_id ?? raw.tenantId) !== null
|
|
211
|
+
? { tenantId: asString(raw.tenant_id ?? raw.tenantId) }
|
|
212
|
+
: {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function asString(value: unknown): string | null {
|
|
217
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function asNumberOrNull(value: unknown): number | null {
|
|
221
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
222
|
+
if (typeof value === "string" && value.length > 0) {
|
|
223
|
+
const n = Number(value);
|
|
224
|
+
return Number.isFinite(n) ? n : null;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function asStringArray(value: unknown): string[] {
|
|
230
|
+
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
|
|
231
|
+
if (typeof value === "string" && value.length > 0) {
|
|
232
|
+
try {
|
|
233
|
+
const parsed: unknown = JSON.parse(value);
|
|
234
|
+
if (Array.isArray(parsed)) {
|
|
235
|
+
return parsed.filter((item): item is string => typeof item === "string");
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function asRateLimit(value: unknown): { max: number; per: string } | null {
|
|
245
|
+
const parsed: unknown =
|
|
246
|
+
typeof value === "string" && value.length > 0
|
|
247
|
+
? (() => {
|
|
248
|
+
try {
|
|
249
|
+
return JSON.parse(value) as unknown;
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
})()
|
|
254
|
+
: value;
|
|
255
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
256
|
+
const rec = parsed as { max?: unknown; per?: unknown };
|
|
257
|
+
if (typeof rec.max === "number" && typeof rec.per === "string") {
|
|
258
|
+
return { max: rec.max, per: rec.per };
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
package/src/auth/api-keys.ts
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A key can never exceed the permissions of whoever created it.
|
|
5
5
|
* The raw secret is returned exactly once on create / rotate.
|
|
6
|
+
* Hash is HMAC-SHA-256 with the auth pepper (gate.auth.secret).
|
|
6
7
|
*
|
|
7
8
|
* @see docs/spec/console.md §3.3 · §9.14
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
12
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
10
13
|
import { assertAttenuated } from "./attenuation.ts";
|
|
11
14
|
import type { AuthPlane } from "./planes.ts";
|
|
12
15
|
import type { ApiKeyRow } from "./tables.ts";
|
|
13
16
|
|
|
17
|
+
/** Capability / Manifest resource for `fx.auth` key methods. */
|
|
18
|
+
export const AUTH_API_KEYS_RESOURCE = "auth:api-keys";
|
|
19
|
+
|
|
20
|
+
/** Default HMAC pepper when no `gate.auth.secret` is bound (tests). */
|
|
21
|
+
const DEFAULT_PEPPER = "oke.api-key";
|
|
22
|
+
|
|
14
23
|
/** Options when creating an API key. */
|
|
15
24
|
export interface CreateApiKeyOptions {
|
|
16
25
|
readonly plane: AuthPlane;
|
|
@@ -22,11 +31,26 @@ export interface CreateApiKeyOptions {
|
|
|
22
31
|
readonly expiresAt?: number | null;
|
|
23
32
|
readonly rateLimit?: { max: number; per: string } | null;
|
|
24
33
|
readonly ipAllowlist?: readonly string[];
|
|
34
|
+
/** Optional tenant claim copied onto the key principal. */
|
|
35
|
+
readonly tenantId?: string | null;
|
|
25
36
|
/** Injectable id / secret / hash (tests). */
|
|
26
37
|
readonly id?: string;
|
|
27
38
|
readonly secret?: string;
|
|
28
39
|
readonly hash?: string;
|
|
29
40
|
readonly now?: () => number;
|
|
41
|
+
/** HMAC pepper override (defaults to store pepper). */
|
|
42
|
+
readonly pepper?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Patch for {@link updateApiKey}. */
|
|
46
|
+
export interface UpdateApiKeyOptions {
|
|
47
|
+
readonly name?: string;
|
|
48
|
+
readonly scopes?: readonly string[];
|
|
49
|
+
readonly expiresAt?: number | null;
|
|
50
|
+
readonly rateLimit?: { max: number; per: string } | null;
|
|
51
|
+
readonly ipAllowlist?: readonly string[];
|
|
52
|
+
/** Ceiling for scope changes (stored creatorScopes ∩ live scopes). */
|
|
53
|
+
readonly ceiling: ReadonlySet<string> | Iterable<string>;
|
|
30
54
|
}
|
|
31
55
|
|
|
32
56
|
/** Result of key creation — secret shown exactly once. */
|
|
@@ -36,27 +60,76 @@ export interface CreatedApiKey {
|
|
|
36
60
|
readonly secret: string;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
|
-
/**
|
|
63
|
+
/** Public key row returned by `fx.auth.listApiKeys` (never includes hash). */
|
|
64
|
+
export interface ApiKeyPublicRow {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly plane: AuthPlane;
|
|
68
|
+
readonly scopes: readonly string[];
|
|
69
|
+
readonly createdAt: number;
|
|
70
|
+
readonly lastUsedAt: number | null;
|
|
71
|
+
readonly expiresAt: number | null;
|
|
72
|
+
readonly revokedAt: number | null;
|
|
73
|
+
readonly rateLimit: { max: number; per: string } | null;
|
|
74
|
+
readonly ipAllowlist: readonly string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* API key store — in-memory Map, optionally HMAC-peppered.
|
|
79
|
+
* SQL persist is a write-through adapter bound at boot.
|
|
80
|
+
*/
|
|
40
81
|
export interface ApiKeyStore {
|
|
41
82
|
keys: Map<string, ApiKeyRow>;
|
|
83
|
+
/** HMAC pepper (`gate.auth.secret`). */
|
|
84
|
+
pepper?: string;
|
|
85
|
+
/** Optional write-through persist (SQL). */
|
|
86
|
+
persist?: (row: ApiKeyRow) => void | Promise<void>;
|
|
87
|
+
/** Sliding-window hits for per-key rateLimit (subject = key id). */
|
|
88
|
+
rateHits?: Map<string, number[]>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Options for {@link createApiKeyStore}. */
|
|
92
|
+
export interface CreateApiKeyStoreOptions {
|
|
93
|
+
readonly pepper?: string;
|
|
94
|
+
readonly persist?: (row: ApiKeyRow) => void | Promise<void>;
|
|
42
95
|
}
|
|
43
96
|
|
|
44
97
|
/**
|
|
45
98
|
* Create an empty API key store.
|
|
99
|
+
*
|
|
100
|
+
* @param options - Optional HMAC pepper / persist hook
|
|
46
101
|
*/
|
|
47
|
-
export function createApiKeyStore(): ApiKeyStore {
|
|
48
|
-
return {
|
|
102
|
+
export function createApiKeyStore(options: CreateApiKeyStoreOptions = {}): ApiKeyStore {
|
|
103
|
+
return {
|
|
104
|
+
keys: new Map(),
|
|
105
|
+
...(options.pepper !== undefined ? { pepper: options.pepper } : {}),
|
|
106
|
+
...(options.persist !== undefined ? { persist: options.persist } : {}),
|
|
107
|
+
};
|
|
49
108
|
}
|
|
50
109
|
|
|
51
110
|
/**
|
|
52
|
-
*
|
|
111
|
+
* HMAC-SHA-256 hex of an API key secret.
|
|
53
112
|
*
|
|
54
113
|
* @param secret - Raw secret
|
|
114
|
+
* @param pepper - HMAC key (`gate.auth.secret`); defaults to a test pepper
|
|
55
115
|
*/
|
|
56
|
-
export async function hashApiKeySecret(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
116
|
+
export async function hashApiKeySecret(
|
|
117
|
+
secret: string,
|
|
118
|
+
pepper: string = DEFAULT_PEPPER,
|
|
119
|
+
): Promise<string> {
|
|
120
|
+
const key = await crypto.subtle.importKey(
|
|
121
|
+
"raw",
|
|
122
|
+
new TextEncoder().encode(pepper),
|
|
123
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
124
|
+
false,
|
|
125
|
+
["sign"],
|
|
126
|
+
);
|
|
127
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(secret));
|
|
128
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function persistRow(store: ApiKeyStore, row: ApiKeyRow): Promise<void> {
|
|
132
|
+
await store.persist?.(row);
|
|
60
133
|
}
|
|
61
134
|
|
|
62
135
|
/**
|
|
@@ -74,7 +147,8 @@ export async function createApiKey(
|
|
|
74
147
|
const now = options.now ?? (() => Date.now());
|
|
75
148
|
const id = options.id ?? crypto.randomUUID();
|
|
76
149
|
const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
|
|
77
|
-
const
|
|
150
|
+
const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
|
|
151
|
+
const hash = options.hash ?? (await hashApiKeySecret(secret, pepper));
|
|
78
152
|
const creatorScopes = [...options.creatorScopes];
|
|
79
153
|
|
|
80
154
|
const row: ApiKeyRow = {
|
|
@@ -91,34 +165,80 @@ export async function createApiKey(
|
|
|
91
165
|
createdAt: now(),
|
|
92
166
|
lastUsedAt: null,
|
|
93
167
|
revokedAt: null,
|
|
168
|
+
...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
|
|
94
169
|
};
|
|
95
170
|
store.keys.set(id, row);
|
|
171
|
+
await persistRow(store, row);
|
|
96
172
|
return { row, secret };
|
|
97
173
|
}
|
|
98
174
|
|
|
175
|
+
/** Resolve a stored hostname to addresses (injected in tests). */
|
|
176
|
+
export type AllowlistLookup = (host: string) => Promise<readonly string[]>;
|
|
177
|
+
|
|
178
|
+
/** Options for {@link authenticateApiKey}. */
|
|
179
|
+
export interface AuthenticateApiKeyOptions {
|
|
180
|
+
readonly now?: () => number;
|
|
181
|
+
/** Client IP for allowlist enforcement. */
|
|
182
|
+
readonly ip?: string;
|
|
183
|
+
/** Hostname → A/AAAA (defaults to system DNS). */
|
|
184
|
+
readonly lookup?: AllowlistLookup;
|
|
185
|
+
}
|
|
186
|
+
|
|
99
187
|
/**
|
|
100
|
-
* Resolve a key by raw secret; enforces expiry and
|
|
188
|
+
* Resolve a key by raw secret; enforces expiry, revocation, and allowlist.
|
|
101
189
|
*
|
|
102
190
|
* @param store - Key store
|
|
103
191
|
* @param secret - Raw secret
|
|
104
|
-
* @param
|
|
192
|
+
* @param nowOrOptions - Clock, or clock + client IP
|
|
105
193
|
*/
|
|
106
194
|
export async function authenticateApiKey(
|
|
107
195
|
store: ApiKeyStore,
|
|
108
196
|
secret: string,
|
|
109
|
-
|
|
197
|
+
nowOrOptions: (() => number) | AuthenticateApiKeyOptions = () => Date.now(),
|
|
110
198
|
): Promise<ApiKeyRow | null> {
|
|
111
|
-
const
|
|
199
|
+
const options: AuthenticateApiKeyOptions =
|
|
200
|
+
typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
|
|
201
|
+
const now = options.now ?? (() => Date.now());
|
|
202
|
+
const pepper = store.pepper ?? DEFAULT_PEPPER;
|
|
203
|
+
const hash = await hashApiKeySecret(secret, pepper);
|
|
112
204
|
for (const row of store.keys.values()) {
|
|
113
205
|
if (row.hash !== hash) continue;
|
|
114
206
|
if (row.revokedAt !== null) return null;
|
|
115
207
|
if (row.expiresAt !== null && row.expiresAt <= now()) return null;
|
|
208
|
+
if (row.ipAllowlist.length > 0) {
|
|
209
|
+
const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
|
|
210
|
+
if (!allowed) return null;
|
|
211
|
+
}
|
|
212
|
+
if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
|
|
116
213
|
row.lastUsedAt = now();
|
|
214
|
+
await persistRow(store, row);
|
|
117
215
|
return row;
|
|
118
216
|
}
|
|
119
217
|
return null;
|
|
120
218
|
}
|
|
121
219
|
|
|
220
|
+
/**
|
|
221
|
+
* List keys created by `creatorId` (includes revoked rows for audit).
|
|
222
|
+
*
|
|
223
|
+
* @param store - Key store
|
|
224
|
+
* @param creatorId - Issuer id
|
|
225
|
+
*/
|
|
226
|
+
export function listApiKeys(store: ApiKeyStore, creatorId: string): ApiKeyRow[] {
|
|
227
|
+
return [...store.keys.values()]
|
|
228
|
+
.filter((row) => row.creatorId === creatorId)
|
|
229
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Load one key by id.
|
|
234
|
+
*
|
|
235
|
+
* @param store - Key store
|
|
236
|
+
* @param id - Key id
|
|
237
|
+
*/
|
|
238
|
+
export function getApiKey(store: ApiKeyStore, id: string): ApiKeyRow | undefined {
|
|
239
|
+
return store.keys.get(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
122
242
|
/**
|
|
123
243
|
* Revoke an API key — irreversible. The row remains for audit / hygiene.
|
|
124
244
|
*
|
|
@@ -134,6 +254,7 @@ export function revokeApiKey(
|
|
|
134
254
|
const row = store.keys.get(id);
|
|
135
255
|
if (!row || row.revokedAt !== null) return null;
|
|
136
256
|
row.revokedAt = now();
|
|
257
|
+
void persistRow(store, row);
|
|
137
258
|
return row;
|
|
138
259
|
}
|
|
139
260
|
|
|
@@ -150,15 +271,156 @@ export async function rotateApiKey(
|
|
|
150
271
|
options: {
|
|
151
272
|
readonly secret?: string;
|
|
152
273
|
readonly now?: () => number;
|
|
274
|
+
readonly pepper?: string;
|
|
153
275
|
} = {},
|
|
154
276
|
): Promise<CreatedApiKey | null> {
|
|
155
277
|
const row = store.keys.get(id);
|
|
156
278
|
if (!row || row.revokedAt !== null) return null;
|
|
157
279
|
const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
|
|
158
|
-
|
|
280
|
+
const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
|
|
281
|
+
row.hash = await hashApiKeySecret(secret, pepper);
|
|
282
|
+
await persistRow(store, row);
|
|
159
283
|
return { row, secret };
|
|
160
284
|
}
|
|
161
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Update key metadata. Scope changes re-run {@link assertAttenuated}.
|
|
288
|
+
*
|
|
289
|
+
* @param store - Key store
|
|
290
|
+
* @param id - Key id
|
|
291
|
+
* @param options - Patch + attenuation ceiling
|
|
292
|
+
*/
|
|
293
|
+
export function updateApiKey(
|
|
294
|
+
store: ApiKeyStore,
|
|
295
|
+
id: string,
|
|
296
|
+
options: UpdateApiKeyOptions,
|
|
297
|
+
): ApiKeyRow | null {
|
|
298
|
+
const row = store.keys.get(id);
|
|
299
|
+
if (!row || row.revokedAt !== null) return null;
|
|
300
|
+
if (options.scopes !== undefined) {
|
|
301
|
+
assertAttenuated(options.ceiling, options.scopes, "api key");
|
|
302
|
+
row.scopes = [...options.scopes];
|
|
303
|
+
}
|
|
304
|
+
if (options.name !== undefined) row.name = options.name;
|
|
305
|
+
if (options.expiresAt !== undefined) row.expiresAt = options.expiresAt;
|
|
306
|
+
if (options.rateLimit !== undefined) row.rateLimit = options.rateLimit;
|
|
307
|
+
if (options.ipAllowlist !== undefined) row.ipAllowlist = [...options.ipAllowlist];
|
|
308
|
+
void persistRow(store, row);
|
|
309
|
+
return row;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Public projection of a key row (no hash / creatorScopes).
|
|
314
|
+
*
|
|
315
|
+
* @param row - Stored row
|
|
316
|
+
*/
|
|
317
|
+
export function toApiKeyPublicRow(row: ApiKeyRow): ApiKeyPublicRow {
|
|
318
|
+
return {
|
|
319
|
+
id: row.id,
|
|
320
|
+
name: row.name,
|
|
321
|
+
plane: row.plane,
|
|
322
|
+
scopes: [...row.scopes],
|
|
323
|
+
createdAt: row.createdAt,
|
|
324
|
+
lastUsedAt: row.lastUsedAt,
|
|
325
|
+
expiresAt: row.expiresAt,
|
|
326
|
+
revokedAt: row.revokedAt,
|
|
327
|
+
rateLimit: row.rateLimit,
|
|
328
|
+
ipAllowlist: [...row.ipAllowlist],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether the client IP matches an allowlist entry.
|
|
334
|
+
* Literals compare exactly. Hostnames resolve at verify time (fail closed).
|
|
335
|
+
*
|
|
336
|
+
* @param entries - IPs and/or hostnames
|
|
337
|
+
* @param ip - Client IP
|
|
338
|
+
* @param lookup - Hostname resolver
|
|
339
|
+
*/
|
|
340
|
+
export async function allowlistAllowsIp(
|
|
341
|
+
entries: readonly string[],
|
|
342
|
+
ip: string | undefined,
|
|
343
|
+
lookup: AllowlistLookup = lookupAllowlistHost,
|
|
344
|
+
): Promise<boolean> {
|
|
345
|
+
if (entries.length === 0) return true;
|
|
346
|
+
if (ip === undefined) return false;
|
|
347
|
+
const client = ip.trim().toLowerCase();
|
|
348
|
+
for (const raw of entries) {
|
|
349
|
+
const entry = normalizeAllowlistEntry(raw);
|
|
350
|
+
if (entry.length === 0) continue;
|
|
351
|
+
if (isIpLiteral(entry)) {
|
|
352
|
+
if (entry === client) return true;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const addrs = await lookup(entry);
|
|
357
|
+
if (addrs.some((addr) => addr.trim().toLowerCase() === client)) return true;
|
|
358
|
+
} catch {
|
|
359
|
+
// This host fails closed; other entries may still match.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function normalizeAllowlistEntry(raw: string): string {
|
|
366
|
+
let entry = raw.trim().toLowerCase();
|
|
367
|
+
entry = entry.replace(/^https?:\/\//, "");
|
|
368
|
+
const slash = entry.indexOf("/");
|
|
369
|
+
if (slash >= 0) entry = entry.slice(0, slash);
|
|
370
|
+
if (entry.endsWith(".")) entry = entry.slice(0, -1);
|
|
371
|
+
return entry;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isIpLiteral(entry: string): boolean {
|
|
375
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(entry)) {
|
|
376
|
+
return entry.split(".").every((octet) => Number(octet) <= 255);
|
|
377
|
+
}
|
|
378
|
+
return entry.includes(":") && /^[0-9a-f:.]+$/.test(entry);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function lookupAllowlistHost(host: string): Promise<readonly string[]> {
|
|
382
|
+
const rows = await dnsLookup(host, { all: true, verbatim: true });
|
|
383
|
+
return rows.map((row) => row.address);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Client IP from X-Forwarded-For (right-most trusted hop) or X-Real-IP.
|
|
388
|
+
*
|
|
389
|
+
* @param request - Incoming request
|
|
390
|
+
* @param trustedProxyDepth - Hops from the right (default 1)
|
|
391
|
+
*/
|
|
392
|
+
export function clientIpFromRequest(request: Request, trustedProxyDepth = 1): string | undefined {
|
|
393
|
+
const xff = request.headers.get("x-forwarded-for");
|
|
394
|
+
if (xff) {
|
|
395
|
+
const hops = xff
|
|
396
|
+
.split(",")
|
|
397
|
+
.map((h) => h.trim())
|
|
398
|
+
.filter((h) => h.length > 0);
|
|
399
|
+
const index = hops.length - trustedProxyDepth;
|
|
400
|
+
if (index >= 0) return hops[index];
|
|
401
|
+
}
|
|
402
|
+
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function takeKeyRate(
|
|
406
|
+
store: ApiKeyStore,
|
|
407
|
+
keyId: string,
|
|
408
|
+
limit: { max: number; per: string },
|
|
409
|
+
nowMs: number,
|
|
410
|
+
): boolean {
|
|
411
|
+
const windowMs = parseDurationMs(limit.per);
|
|
412
|
+
const hits = store.rateHits ?? (store.rateHits = new Map<string, number[]>());
|
|
413
|
+
const prev = hits.get(keyId) ?? [];
|
|
414
|
+
const kept = prev.filter((t) => nowMs - t < windowMs);
|
|
415
|
+
if (kept.length >= limit.max) {
|
|
416
|
+
hits.set(keyId, kept);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
kept.push(nowMs);
|
|
420
|
+
hits.set(keyId, kept);
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
162
424
|
function randomSecret(): string {
|
|
163
425
|
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
|
164
426
|
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|