okengine 0.8.0 → 0.9.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 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +3 -1
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +34 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +2 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -2
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/environment-variables.mdx +9 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +4 -0
- package/src/cli/load-config.ts +3 -0
- package/src/cli/registry.ts +1 -1
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +1 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +152 -114
- package/src/elements/channel.ts +12 -2
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +60 -4
- package/src/kernel/boot-bind/channel.ts +51 -0
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +18 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +5 -5
- package/src/kernel/fx.test.ts +3 -0
- package/src/kernel/fx.ts +49 -0
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +8 -0
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +19 -29
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +4 -6
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -172
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -206
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain-separated sealed OTP copy for Tier-2 redelivery.
|
|
3
|
+
*
|
|
4
|
+
* AES-GCM key is **never** the raw auth secret. It is derived via
|
|
5
|
+
* HKDF-SHA-256 with info `oke-otp-seal-v1` so session HMAC material stays
|
|
6
|
+
* cryptographically isolated from seal encryption.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** HKDF `info` — versioned; bump when the seal format changes. */
|
|
10
|
+
export const OTP_SEAL_HKDF_INFO = "oke-otp-seal-v1";
|
|
11
|
+
|
|
12
|
+
const INFO_BYTES = new TextEncoder().encode(OTP_SEAL_HKDF_INFO);
|
|
13
|
+
const EMPTY_SALT = new Uint8Array(0);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Derive the AES-256-GCM key for OTP sealing from the method secret.
|
|
17
|
+
*
|
|
18
|
+
* @param secret - Root auth secret (`resolveMethodSecret`)
|
|
19
|
+
*/
|
|
20
|
+
export async function deriveOtpSealKey(secret: string): Promise<CryptoKey> {
|
|
21
|
+
const ikm = new TextEncoder().encode(secret);
|
|
22
|
+
const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
|
|
23
|
+
return crypto.subtle.deriveKey(
|
|
24
|
+
{
|
|
25
|
+
name: "HKDF",
|
|
26
|
+
hash: "SHA-256",
|
|
27
|
+
salt: EMPTY_SALT,
|
|
28
|
+
info: INFO_BYTES,
|
|
29
|
+
},
|
|
30
|
+
baseKey,
|
|
31
|
+
{ name: "AES-GCM", length: 256 },
|
|
32
|
+
false,
|
|
33
|
+
["encrypt", "decrypt"],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Seal a plaintext OTP for later redelivery (base64 `IV || ciphertext+tag`).
|
|
39
|
+
*
|
|
40
|
+
* @param secret - Root auth secret
|
|
41
|
+
* @param otp - Raw OTP digits
|
|
42
|
+
*/
|
|
43
|
+
export async function sealOtp(secret: string, otp: string): Promise<string> {
|
|
44
|
+
const key = await deriveOtpSealKey(secret);
|
|
45
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
46
|
+
const cipher = await crypto.subtle.encrypt(
|
|
47
|
+
{ name: "AES-GCM", iv },
|
|
48
|
+
key,
|
|
49
|
+
new TextEncoder().encode(otp),
|
|
50
|
+
);
|
|
51
|
+
const packed = new Uint8Array(iv.length + cipher.byteLength);
|
|
52
|
+
packed.set(iv, 0);
|
|
53
|
+
packed.set(new Uint8Array(cipher), iv.length);
|
|
54
|
+
return Buffer.from(packed).toString("base64");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Unseal a previously sealed OTP. Call only at the deliberate delivery boundary.
|
|
59
|
+
*
|
|
60
|
+
* @param secret - Root auth secret
|
|
61
|
+
* @param blob - Base64 sealed blob from {@link sealOtp}
|
|
62
|
+
*/
|
|
63
|
+
export async function unsealOtp(secret: string, blob: string): Promise<string> {
|
|
64
|
+
const key = await deriveOtpSealKey(secret);
|
|
65
|
+
const packed = Buffer.from(blob, "base64");
|
|
66
|
+
if (packed.byteLength < 13) {
|
|
67
|
+
throw new Error("otp-seal: malformed sealed blob");
|
|
68
|
+
}
|
|
69
|
+
const iv = packed.subarray(0, 12);
|
|
70
|
+
const data = packed.subarray(12);
|
|
71
|
+
const plain = await crypto.subtle.decrypt(
|
|
72
|
+
{ name: "AES-GCM", iv: new Uint8Array(iv) },
|
|
73
|
+
key,
|
|
74
|
+
toArrayBuffer(data),
|
|
75
|
+
);
|
|
76
|
+
return new TextDecoder().decode(plain);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Copy into a real ArrayBuffer for Web Crypto BufferSource typing. */
|
|
80
|
+
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
|
81
|
+
const copy = new Uint8Array(view.byteLength);
|
|
82
|
+
copy.set(view);
|
|
83
|
+
return copy.buffer;
|
|
84
|
+
}
|
package/src/auth/schema.ts
CHANGED
|
@@ -73,6 +73,8 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
|
|
|
73
73
|
createdAt: "created_at",
|
|
74
74
|
expiresAt: "expires_at",
|
|
75
75
|
lastActiveAt: "last_active_at",
|
|
76
|
+
scopes: "scopes",
|
|
77
|
+
audience: "audience",
|
|
76
78
|
},
|
|
77
79
|
refreshToken: {
|
|
78
80
|
id: "id",
|
|
@@ -186,6 +188,7 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
|
|
|
186
188
|
value: "TEXT",
|
|
187
189
|
description: "TEXT",
|
|
188
190
|
scopes: "TEXT",
|
|
191
|
+
audience: "TEXT",
|
|
189
192
|
lastUsedAt: "INTEGER",
|
|
190
193
|
};
|
|
191
194
|
|
package/src/auth/sessions.ts
CHANGED
|
@@ -130,11 +130,10 @@ export async function issueSession(
|
|
|
130
130
|
createdAt: t,
|
|
131
131
|
expiresAt: t + absoluteCap,
|
|
132
132
|
lastActiveAt: t,
|
|
133
|
+
scopes: [...principal.scopes],
|
|
134
|
+
...(crypto.audience !== undefined ? { audience: crypto.audience } : {}),
|
|
133
135
|
};
|
|
134
136
|
store.sessions.set(sessionId, session);
|
|
135
|
-
if (crypto.audience !== undefined) {
|
|
136
|
-
sessionAudiences.set(sessionId, crypto.audience);
|
|
137
|
-
}
|
|
138
137
|
|
|
139
138
|
const refreshRaw = `rt_${cryptoRandomId()}`;
|
|
140
139
|
const refreshRow: RefreshTokenRow = {
|
|
@@ -223,20 +222,15 @@ export async function rotateRefresh(
|
|
|
223
222
|
};
|
|
224
223
|
store.refresh.set(newRefresh.id, newRefresh);
|
|
225
224
|
|
|
226
|
-
// Recover scopes from the previous access path — stored on session via
|
|
227
|
-
// a side map would be ideal; for builtin we re-sign with empty and let
|
|
228
|
-
// callers pass scopes through verify. We keep scopes on a claim cache:
|
|
229
|
-
const priorScopes = sessionScopes.get(session.id) ?? [];
|
|
230
|
-
const priorAud = sessionAudiences.get(session.id);
|
|
231
225
|
const accessExpiresAt = t + accessTtl;
|
|
232
226
|
const accessToken = await signAccess(crypto.secret, {
|
|
233
227
|
sub: session.principalId,
|
|
234
228
|
plane: session.plane,
|
|
235
229
|
sid: session.id,
|
|
236
|
-
scopes:
|
|
230
|
+
scopes: session.scopes,
|
|
237
231
|
iat: t,
|
|
238
232
|
exp: accessExpiresAt,
|
|
239
|
-
...(
|
|
233
|
+
...(session.audience !== undefined ? { aud: session.audience } : {}),
|
|
240
234
|
});
|
|
241
235
|
|
|
242
236
|
return {
|
|
@@ -247,34 +241,44 @@ export async function rotateRefresh(
|
|
|
247
241
|
};
|
|
248
242
|
}
|
|
249
243
|
|
|
250
|
-
/** Session id → scopes (access-token material). */
|
|
251
|
-
const sessionScopes = new Map<string, string[]>();
|
|
252
|
-
|
|
253
|
-
/** Session id → audience stamped at issue time. */
|
|
254
|
-
const sessionAudiences = new Map<string, string>();
|
|
255
|
-
|
|
256
244
|
/**
|
|
257
245
|
* Remember scopes for refresh rotation (call after {@link issueSession}).
|
|
246
|
+
* Scopes live on the session row — shared with any store hydrate/persist path.
|
|
258
247
|
*
|
|
248
|
+
* @param store - Session store
|
|
259
249
|
* @param sessionId - Session id
|
|
260
250
|
* @param scopes - Scopes
|
|
261
251
|
*/
|
|
262
|
-
export function bindSessionScopes(
|
|
263
|
-
|
|
252
|
+
export function bindSessionScopes(
|
|
253
|
+
store: SessionStore,
|
|
254
|
+
sessionId: string,
|
|
255
|
+
scopes: Iterable<string>,
|
|
256
|
+
): void {
|
|
257
|
+
const session = store.sessions.get(sessionId);
|
|
258
|
+
if (!session) return;
|
|
259
|
+
// Mutate in place — SessionRow is a store-owned mutable row.
|
|
260
|
+
(session as { scopes: string[] }).scopes = [...scopes];
|
|
264
261
|
}
|
|
265
262
|
|
|
266
263
|
/**
|
|
267
264
|
* Remember audience for refresh rotation (call after {@link issueSession}).
|
|
268
265
|
*
|
|
266
|
+
* @param store - Session store
|
|
269
267
|
* @param sessionId - Session id
|
|
270
268
|
* @param audience - Audience claim
|
|
271
269
|
*/
|
|
272
|
-
export function bindSessionAudience(
|
|
273
|
-
|
|
270
|
+
export function bindSessionAudience(
|
|
271
|
+
store: SessionStore,
|
|
272
|
+
sessionId: string,
|
|
273
|
+
audience: string,
|
|
274
|
+
): void {
|
|
275
|
+
const session = store.sessions.get(sessionId);
|
|
276
|
+
if (!session) return;
|
|
277
|
+
(session as { audience?: string }).audience = audience;
|
|
274
278
|
}
|
|
275
279
|
|
|
276
280
|
/**
|
|
277
|
-
* Issue a session and
|
|
281
|
+
* Issue a session with scopes (and optional audience) on the session row.
|
|
278
282
|
*
|
|
279
283
|
* @param store - Session store
|
|
280
284
|
* @param crypto - Crypto
|
|
@@ -289,12 +293,7 @@ export async function issueSessionWithScopes(
|
|
|
289
293
|
readonly scopes: Iterable<string>;
|
|
290
294
|
},
|
|
291
295
|
): Promise<IssuedSession> {
|
|
292
|
-
|
|
293
|
-
bindSessionScopes(issued.session.id, principal.scopes);
|
|
294
|
-
if (crypto.audience !== undefined) {
|
|
295
|
-
bindSessionAudience(issued.session.id, crypto.audience);
|
|
296
|
-
}
|
|
297
|
-
return issued;
|
|
296
|
+
return issueSession(store, crypto, principal);
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
/**
|
package/src/auth/tables.ts
CHANGED
|
@@ -121,6 +121,10 @@ export interface SessionRow {
|
|
|
121
121
|
expiresAt: number;
|
|
122
122
|
/** Last activity epoch-ms (idle timeout). Updated on refresh / access touch. */
|
|
123
123
|
lastActiveAt: number;
|
|
124
|
+
/** Access-token scopes — survives refresh / process restart with the session row. */
|
|
125
|
+
scopes: string[];
|
|
126
|
+
/** Audience stamped at issue (`oke-console` · `oke-mcp` · `oke-app`). */
|
|
127
|
+
audience?: string;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
/** Refresh token (hashed at rest); rotation with reuse detection. */
|
package/src/auth/verification.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Shared verification / challenge store for magic-link, OTP, phone, etc.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/** Delivery channel for Tier-2 OTP challenges. */
|
|
6
|
+
export type OtpChannel = "sms" | "whatsapp" | "email";
|
|
7
|
+
|
|
5
8
|
/** One pending challenge. */
|
|
6
9
|
export interface VerificationRow {
|
|
7
10
|
id: string;
|
|
@@ -11,6 +14,19 @@ export interface VerificationRow {
|
|
|
11
14
|
createdAt: number;
|
|
12
15
|
consumedAt: number | null;
|
|
13
16
|
attempts: number;
|
|
17
|
+
/**
|
|
18
|
+
* Tier-2 sealed OTP (AES-GCM via HKDF `oke-otp-seal-v1`).
|
|
19
|
+
* Wiped on consume / expire — never left after TTL.
|
|
20
|
+
*/
|
|
21
|
+
sealedOtp?: string | null;
|
|
22
|
+
/** Last successful delivery time (resend cooldown). */
|
|
23
|
+
lastDeliveredAt?: number;
|
|
24
|
+
/** Last channel used for delivery. */
|
|
25
|
+
lastChannel?: OtpChannel;
|
|
26
|
+
/** Email address captured at request (channel-neutral challenge). */
|
|
27
|
+
email?: string | null;
|
|
28
|
+
/** E.164 phone captured at request. */
|
|
29
|
+
phone?: string | null;
|
|
14
30
|
}
|
|
15
31
|
|
|
16
32
|
/** In-memory verification store. */
|
|
@@ -25,6 +41,15 @@ export function createVerificationStore(): VerificationStore {
|
|
|
25
41
|
return { rows: new Map() };
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Wipe the sealed OTP copy (and clear the field). Safe to call repeatedly.
|
|
46
|
+
*
|
|
47
|
+
* @param row - Challenge row
|
|
48
|
+
*/
|
|
49
|
+
export function wipeSealedOtp(row: VerificationRow): void {
|
|
50
|
+
row.sealedOtp = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
/**
|
|
29
54
|
* Store a challenge (hashed value recommended by callers).
|
|
30
55
|
*
|
|
@@ -37,6 +62,7 @@ export function putVerification(store: VerificationStore, row: VerificationRow):
|
|
|
37
62
|
|
|
38
63
|
/**
|
|
39
64
|
* Find a non-consumed, non-expired challenge by identifier.
|
|
65
|
+
* Expired active rows have their sealed OTP wiped before being skipped.
|
|
40
66
|
*
|
|
41
67
|
* @param store - Store
|
|
42
68
|
* @param identifier - Email / phone / username key
|
|
@@ -50,12 +76,46 @@ export function findActiveVerification(
|
|
|
50
76
|
for (const row of store.rows.values()) {
|
|
51
77
|
if (row.identifier !== identifier) continue;
|
|
52
78
|
if (row.consumedAt !== null) continue;
|
|
53
|
-
if (row.expiresAt <= now)
|
|
79
|
+
if (row.expiresAt <= now) {
|
|
80
|
+
wipeSealedOtp(row);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
54
83
|
return row;
|
|
55
84
|
}
|
|
56
85
|
return undefined;
|
|
57
86
|
}
|
|
58
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Consume a challenge: set `consumedAt`, wipe sealed OTP.
|
|
90
|
+
*
|
|
91
|
+
* @param row - Active challenge
|
|
92
|
+
* @param now - Clock
|
|
93
|
+
*/
|
|
94
|
+
export function consumeVerification(row: VerificationRow, now: number): void {
|
|
95
|
+
row.consumedAt = now;
|
|
96
|
+
wipeSealedOtp(row);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Invalidate every non-consumed challenge for an identifier (fresh request).
|
|
101
|
+
* Wipes sealed copies on the invalidated rows.
|
|
102
|
+
*
|
|
103
|
+
* @param store - Store
|
|
104
|
+
* @param identifier - Challenge key
|
|
105
|
+
* @param now - Clock
|
|
106
|
+
*/
|
|
107
|
+
export function invalidateVerifications(
|
|
108
|
+
store: VerificationStore,
|
|
109
|
+
identifier: string,
|
|
110
|
+
now: number,
|
|
111
|
+
): void {
|
|
112
|
+
for (const row of store.rows.values()) {
|
|
113
|
+
if (row.identifier === identifier && row.consumedAt === null) {
|
|
114
|
+
consumeVerification(row, now);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
59
119
|
/**
|
|
60
120
|
* SHA-256 hex hash for challenge secrets / OTPs.
|
|
61
121
|
*
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { resolve } from "node:path";
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { installGracefulShutdown, type GracefulShutdownApp } from "../kernel/graceful-shutdown.ts";
|
|
20
21
|
import { createBunRuntime } from "../runtime/bun.ts";
|
|
21
22
|
import { APP_PORT, type FetchApp } from "../runtime/types.ts";
|
|
22
23
|
import { clearTerminalScreen, formatAppReadyLine, formatDevHero } from "../term.ts";
|
|
@@ -33,6 +34,8 @@ type DevRunnerGlobals = typeof globalThis & {
|
|
|
33
34
|
/** App entry shape — FetchApp plus boot before serve. */
|
|
34
35
|
type BootableApp = FetchApp & {
|
|
35
36
|
boot(): Promise<unknown>;
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
readonly bootResult?: GracefulShutdownApp["bootResult"];
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
const entry = Bun.env["OKE_ENTRY"];
|
|
@@ -68,6 +71,7 @@ const handle = createBunRuntime().serve(mod.app, {
|
|
|
68
71
|
hostname,
|
|
69
72
|
id: DEV_APP_SERVE_ID,
|
|
70
73
|
});
|
|
74
|
+
installGracefulShutdown({ app: mod.app, handle });
|
|
71
75
|
|
|
72
76
|
const appUrl = `http://${formatHostForUrl(hostname)}:${handle.port}`;
|
|
73
77
|
const g = globalThis as DevRunnerGlobals;
|
package/src/cli/docker.ts
CHANGED
|
@@ -105,7 +105,10 @@ export async function runDockerDerive(
|
|
|
105
105
|
write(
|
|
106
106
|
`compose merge order (cwd ${composeDir}/):\n${result.composeFiles.map((f) => ` -f ${f}`).join("\n")}\n`,
|
|
107
107
|
);
|
|
108
|
-
write(
|
|
108
|
+
write(
|
|
109
|
+
`(${composeDir}/compose.override.yml is layer 4 — user-owned, never written by oke)\n` +
|
|
110
|
+
`(${composeDir}/compose.all.yml is layers 1–3 merged — single-file alternative for Swarm / one \`-c\`)\n`,
|
|
111
|
+
);
|
|
109
112
|
return { code: 0, result };
|
|
110
113
|
} catch (err) {
|
|
111
114
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -21,6 +21,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
21
21
|
});
|
|
22
22
|
expect(images).toEqual({
|
|
23
23
|
"store.sql": "postgres:18-alpine",
|
|
24
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
24
25
|
"store.kv": "redis:8-alpine",
|
|
25
26
|
});
|
|
26
27
|
});
|
|
@@ -35,6 +36,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
35
36
|
},
|
|
36
37
|
});
|
|
37
38
|
expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
|
|
39
|
+
expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.51");
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
test("clock postgres alone pulls store.sql image", () => {
|
|
@@ -45,6 +47,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
45
47
|
});
|
|
46
48
|
expect(images).toEqual({
|
|
47
49
|
"store.sql": "postgres:18-alpine",
|
|
50
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
48
51
|
});
|
|
49
52
|
});
|
|
50
53
|
|
|
@@ -56,6 +59,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
56
59
|
});
|
|
57
60
|
expect(images).toEqual({
|
|
58
61
|
"store.sql": "postgres:18-alpine",
|
|
62
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
59
63
|
});
|
|
60
64
|
});
|
|
61
65
|
|
package/src/cli/load-config.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { Manifest } from "../manifest/types.ts";
|
|
|
9
9
|
/** Default image pins when `images` is omitted but prod drivers need containers. */
|
|
10
10
|
const DEFAULT_SQL_IMAGE = "postgres:18-alpine";
|
|
11
11
|
const DEFAULT_PGVECTOR_IMAGE = "pgvector/pgvector:pg17";
|
|
12
|
+
const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.51";
|
|
12
13
|
const DEFAULT_KV_IMAGE = "redis:8-alpine";
|
|
13
14
|
const DEFAULT_VAULT_IMAGE = "openbao/openbao:2.6.1";
|
|
14
15
|
|
|
@@ -64,6 +65,8 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
|
|
|
64
65
|
if (needsSql) {
|
|
65
66
|
out["store.sql"] =
|
|
66
67
|
sql === "pgvector" || index === "pgvector" ? DEFAULT_PGVECTOR_IMAGE : DEFAULT_SQL_IMAGE;
|
|
68
|
+
// PgDog in front of Postgres — caps real server connections under horizontal scale.
|
|
69
|
+
out.pgdog = DEFAULT_PGDOG_IMAGE;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
const needsKv =
|
package/src/cli/registry.ts
CHANGED
|
@@ -329,7 +329,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
|
|
|
329
329
|
{
|
|
330
330
|
long: "--prod",
|
|
331
331
|
short: "-p",
|
|
332
|
-
summary: "Prod overlays (
|
|
332
|
+
summary: "Prod overlays (app /_/ready healthcheck, deploy, limits)",
|
|
333
333
|
},
|
|
334
334
|
OUT,
|
|
335
335
|
CONFIG,
|
|
@@ -158,7 +158,9 @@ export function migrateOperatorSchema(db: Database): void {
|
|
|
158
158
|
revoked_at INTEGER,
|
|
159
159
|
created_at INTEGER NOT NULL,
|
|
160
160
|
expires_at INTEGER NOT NULL,
|
|
161
|
-
last_active_at INTEGER
|
|
161
|
+
last_active_at INTEGER,
|
|
162
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
163
|
+
audience TEXT
|
|
162
164
|
);
|
|
163
165
|
CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
|
|
164
166
|
id TEXT PRIMARY KEY NOT NULL,
|
|
@@ -170,20 +172,28 @@ export function migrateOperatorSchema(db: Database): void {
|
|
|
170
172
|
revoked_at INTEGER
|
|
171
173
|
);
|
|
172
174
|
`);
|
|
173
|
-
|
|
175
|
+
ensureSessionColumns(db);
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
/**
|
|
177
|
-
* Add
|
|
179
|
+
* Add session columns when opening an older console.sqlite.
|
|
178
180
|
*
|
|
179
181
|
* @param db - Open database
|
|
180
182
|
*/
|
|
181
|
-
function
|
|
183
|
+
function ensureSessionColumns(db: Database): void {
|
|
182
184
|
const cols = db.query(`PRAGMA table_info(${AUTH_TABLES.sessions})`).all() as Array<{
|
|
183
185
|
name: string;
|
|
184
186
|
}>;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
const names = new Set(cols.map((c) => c.name));
|
|
188
|
+
if (!names.has("last_active_at")) {
|
|
189
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
|
|
190
|
+
}
|
|
191
|
+
if (!names.has("scopes")) {
|
|
192
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]'`);
|
|
193
|
+
}
|
|
194
|
+
if (!names.has("audience")) {
|
|
195
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN audience TEXT`);
|
|
196
|
+
}
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
/**
|
|
@@ -196,7 +206,7 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
196
206
|
|
|
197
207
|
const sessionRows = db
|
|
198
208
|
.query(
|
|
199
|
-
`SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at
|
|
209
|
+
`SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience
|
|
200
210
|
FROM ${AUTH_TABLES.sessions}`,
|
|
201
211
|
)
|
|
202
212
|
.all() as Array<{
|
|
@@ -208,9 +218,20 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
208
218
|
created_at: number;
|
|
209
219
|
expires_at: number;
|
|
210
220
|
last_active_at: number | null;
|
|
221
|
+
scopes: string | null;
|
|
222
|
+
audience: string | null;
|
|
211
223
|
}>;
|
|
212
224
|
|
|
213
225
|
for (const row of sessionRows) {
|
|
226
|
+
let scopes: string[] = [];
|
|
227
|
+
if (row.scopes) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(row.scopes) as unknown;
|
|
230
|
+
if (Array.isArray(parsed)) scopes = parsed.map(String);
|
|
231
|
+
} catch {
|
|
232
|
+
scopes = [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
214
235
|
const session: SessionRow = {
|
|
215
236
|
id: row.id,
|
|
216
237
|
plane: row.plane,
|
|
@@ -220,6 +241,8 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
220
241
|
createdAt: row.created_at,
|
|
221
242
|
expiresAt: row.expires_at,
|
|
222
243
|
lastActiveAt: row.last_active_at ?? row.created_at,
|
|
244
|
+
scopes,
|
|
245
|
+
...(row.audience ? { audience: row.audience } : {}),
|
|
223
246
|
};
|
|
224
247
|
store.sessions.set(session.id, session);
|
|
225
248
|
}
|
|
@@ -267,8 +290,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
|
|
|
267
290
|
|
|
268
291
|
const insertSession = db.query(
|
|
269
292
|
`INSERT INTO ${AUTH_TABLES.sessions}
|
|
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)`,
|
|
293
|
+
(id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience)
|
|
294
|
+
VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive, $scopes, $audience)`,
|
|
272
295
|
);
|
|
273
296
|
for (const session of store.sessions.values()) {
|
|
274
297
|
insertSession.run({
|
|
@@ -280,6 +303,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
|
|
|
280
303
|
$created: session.createdAt,
|
|
281
304
|
$expires: session.expiresAt,
|
|
282
305
|
$lastActive: session.lastActiveAt,
|
|
306
|
+
$scopes: JSON.stringify(session.scopes ?? []),
|
|
307
|
+
$audience: session.audience ?? null,
|
|
283
308
|
});
|
|
284
309
|
}
|
|
285
310
|
|