@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
package/src/api/index.ts
CHANGED
|
@@ -5,10 +5,10 @@ export type {
|
|
|
5
5
|
AuthMiddlewareOptions,
|
|
6
6
|
AuthSessionChecker,
|
|
7
7
|
AuthSessionStatus,
|
|
8
|
-
PatResolver,
|
|
9
8
|
TenantExists,
|
|
10
9
|
TenantLifecycleStatusResolver,
|
|
11
10
|
TenantResolver,
|
|
11
|
+
TokenVerifier,
|
|
12
12
|
} from "./auth-middleware";
|
|
13
13
|
export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
|
|
14
14
|
export type {
|
|
@@ -34,8 +34,8 @@ export {
|
|
|
34
34
|
etagMatches,
|
|
35
35
|
parseIfNoneMatch,
|
|
36
36
|
} from "./http-cache";
|
|
37
|
-
export type { JwtHelper, JwtPayload } from "./jwt";
|
|
38
|
-
export { createJwtHelper } from "./jwt";
|
|
37
|
+
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
38
|
+
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
39
39
|
export { patAllows, qnMatches } from "./pat-scope";
|
|
40
40
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
41
41
|
export { requestIdMiddleware } from "./request-id-middleware";
|
package/src/api/jwt.ts
CHANGED
|
@@ -23,10 +23,82 @@ export type JwtPayload = {
|
|
|
23
23
|
export type JwtHelper = {
|
|
24
24
|
sign(user: SessionUser): Promise<string>;
|
|
25
25
|
verify(token: string): Promise<JwtPayload>;
|
|
26
|
+
// The TTL this helper signs tokens with, in seconds — the single source for
|
|
27
|
+
// callers (e.g. the auth-cookie's maxAge) that must stay coupled to the JWT's exp.
|
|
28
|
+
readonly ttlSeconds: number;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
// kid → secret. All entries verify; `signKid` picks the sign-key. Rotation:
|
|
32
|
+
// add the new kid, flip signKid, keep the old kid around until in-flight
|
|
33
|
+
// tokens expire.
|
|
34
|
+
export type JwtKeyring = {
|
|
35
|
+
readonly keys: Readonly<Record<string, string>>;
|
|
36
|
+
readonly signKid: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type NormalizedKeyring = {
|
|
40
|
+
readonly verifyKeys: ReadonlyMap<string, Uint8Array>;
|
|
41
|
+
readonly signKid: string | undefined;
|
|
42
|
+
readonly signKey: Uint8Array;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function normalizeKeyring(secretOrKeyring: string | JwtKeyring): NormalizedKeyring {
|
|
46
|
+
if (typeof secretOrKeyring === "string") {
|
|
47
|
+
const key = new TextEncoder().encode(secretOrKeyring);
|
|
48
|
+
return { verifyKeys: new Map(), signKid: undefined, signKey: key };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const verifyKeys = new Map<string, Uint8Array>();
|
|
52
|
+
for (const [kid, secret] of Object.entries(secretOrKeyring.keys)) {
|
|
53
|
+
verifyKeys.set(kid, new TextEncoder().encode(secret));
|
|
54
|
+
}
|
|
55
|
+
const signKey = verifyKeys.get(secretOrKeyring.signKid);
|
|
56
|
+
if (!signKey) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`createJwtHelper: signKid "${secretOrKeyring.signKid}" is not present in the keyring`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { verifyKeys, signKid: secretOrKeyring.signKid, signKey };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tokens carry `kid` in the protected header when signed from a keyring — pick the
|
|
65
|
+
// matching verify-key directly. Tokens without `kid` (single-secret form, or in-flight
|
|
66
|
+
// tokens signed before a rotation) fall back to trying every verify-key.
|
|
67
|
+
async function verifyWithKeyring(token: string, keyring: NormalizedKeyring, issuer: string) {
|
|
68
|
+
const { kid } = jose.decodeProtectedHeader(token);
|
|
69
|
+
if (typeof kid === "string" && keyring.verifyKeys.size > 0) {
|
|
70
|
+
const key = keyring.verifyKeys.get(kid);
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error(`JWT verification failed: unknown kid "${kid}"`);
|
|
73
|
+
}
|
|
74
|
+
return jose.jwtVerify(token, key, { issuer });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ponytail: tries every key in the ring (O(keys) per legacy-token verify) — fine for a
|
|
78
|
+
// rotation window of a handful of keys, revisit if the keyring ever grows large.
|
|
79
|
+
const candidates =
|
|
80
|
+
keyring.verifyKeys.size > 0 ? [...keyring.verifyKeys.values()] : [keyring.signKey];
|
|
81
|
+
let lastError: unknown;
|
|
82
|
+
for (const key of candidates) {
|
|
83
|
+
try {
|
|
84
|
+
return await jose.jwtVerify(token, key, { issuer });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
lastError = err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError instanceof Error
|
|
90
|
+
? lastError
|
|
91
|
+
: new Error("JWT verification failed: no matching key");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
95
|
+
|
|
96
|
+
export function createJwtHelper(
|
|
97
|
+
secretOrKeyring: string | JwtKeyring,
|
|
98
|
+
issuer = "kumiko",
|
|
99
|
+
ttlSeconds = DEFAULT_JWT_TTL_SECONDS,
|
|
100
|
+
): JwtHelper {
|
|
101
|
+
const keyring = normalizeKeyring(secretOrKeyring);
|
|
30
102
|
|
|
31
103
|
return {
|
|
32
104
|
async sign(user) {
|
|
@@ -36,19 +108,27 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
36
108
|
};
|
|
37
109
|
if (user.claims) body.claims = { ...user.claims };
|
|
38
110
|
|
|
111
|
+
const header: jose.JWTHeaderParameters = keyring.signKid
|
|
112
|
+
? { alg: "HS256", kid: keyring.signKid }
|
|
113
|
+
: { alg: "HS256" };
|
|
114
|
+
|
|
115
|
+
// iat/exp share one `now` — jose's setIssuedAt()/setExpirationTime(Date)
|
|
116
|
+
// each read the clock separately, letting `exp - iat` drift by a
|
|
117
|
+
// second and making TTL-precision tests flaky.
|
|
118
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
39
119
|
const builder = new jose.SignJWT(body)
|
|
40
|
-
.setProtectedHeader(
|
|
120
|
+
.setProtectedHeader(header)
|
|
41
121
|
.setSubject(String(user.id))
|
|
42
122
|
.setIssuer(issuer)
|
|
43
|
-
.setIssuedAt()
|
|
44
|
-
.setExpirationTime(
|
|
123
|
+
.setIssuedAt(nowSec)
|
|
124
|
+
.setExpirationTime(nowSec + ttlSeconds);
|
|
45
125
|
if (user.sid) builder.setJti(user.sid);
|
|
46
126
|
|
|
47
|
-
return builder.sign(
|
|
127
|
+
return builder.sign(keyring.signKey);
|
|
48
128
|
},
|
|
49
129
|
|
|
50
130
|
async verify(token) {
|
|
51
|
-
const { payload } = await
|
|
131
|
+
const { payload } = await verifyWithKeyring(token, keyring, issuer);
|
|
52
132
|
|
|
53
133
|
// defence-in-depth: valid sig ≠ well-formed claims; malformed payload → throw → 401
|
|
54
134
|
const tenantId = parseTenantId(payload["tenantId"]);
|
|
@@ -84,5 +164,66 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
84
164
|
}
|
|
85
165
|
return result;
|
|
86
166
|
},
|
|
167
|
+
ttlSeconds,
|
|
87
168
|
};
|
|
88
169
|
}
|
|
170
|
+
|
|
171
|
+
const JWT_KEY_VAR_PATTERN = /^JWT_SECRET_V(\d+)$/;
|
|
172
|
+
const JWT_CURRENT_VERSION_VAR = "JWT_SECRET_CURRENT_VERSION";
|
|
173
|
+
// Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) — HS256 minimum.
|
|
174
|
+
// JWT_SECRET_V<n> bypasses that zod schema entirely (it only validates the
|
|
175
|
+
// plain JWT_SECRET name), so this loader is the only gate for the rotation path.
|
|
176
|
+
const MIN_JWT_SECRET_LENGTH = 32;
|
|
177
|
+
|
|
178
|
+
function assertMinLength(name: string, value: string): void {
|
|
179
|
+
if (value.length < MIN_JWT_SECRET_LENGTH) {
|
|
180
|
+
throw new Error(`[jwt] ${name} must be ≥${MIN_JWT_SECRET_LENGTH} chars (HS256 minimum)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Env-loader for createJwtHelper's secret-or-keyring param, analog to
|
|
185
|
+
// secrets' loadKeyring: JWT_SECRET_V<n> (+ JWT_SECRET_CURRENT_VERSION picking
|
|
186
|
+
// the active signKid) for rotation, falling back to plain JWT_SECRET when no
|
|
187
|
+
// JWT_SECRET_V<n> is set — so a non-rotating deployment needs no new env vars.
|
|
188
|
+
export function loadJwtSecretOrKeyring(
|
|
189
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
190
|
+
): string | JwtKeyring {
|
|
191
|
+
const keys: Record<string, string> = {};
|
|
192
|
+
for (const [name, value] of Object.entries(env)) {
|
|
193
|
+
const match = name.match(JWT_KEY_VAR_PATTERN);
|
|
194
|
+
if (!match || !value) continue;
|
|
195
|
+
assertMinLength(name, value);
|
|
196
|
+
// biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
|
|
197
|
+
keys[`v${match[1]!}`] = value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// skip: no JWT_SECRET_V<n> found — single-secret fallback.
|
|
201
|
+
if (Object.keys(keys).length === 0) {
|
|
202
|
+
const secret = env["JWT_SECRET"];
|
|
203
|
+
if (!secret) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"[jwt] JWT_SECRET not set — set JWT_SECRET for a single key, or " +
|
|
206
|
+
"JWT_SECRET_V1 (+ JWT_SECRET_CURRENT_VERSION=1) for a rotatable keyring.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
assertMinLength("JWT_SECRET", secret);
|
|
210
|
+
return secret;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const currentRaw = env[JWT_CURRENT_VERSION_VAR];
|
|
214
|
+
if (!currentRaw) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR} not set — explicit current-version required ` +
|
|
217
|
+
"so adding a new JWT_SECRET_V<n> doesn't auto-promote it to the sign key.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const signKid = `v${currentRaw}`;
|
|
221
|
+
if (!keys[signKid]) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR}="${currentRaw}" not present in the keyring ` +
|
|
224
|
+
`(have versions: ${Object.keys(keys).sort().join(", ")}). ` +
|
|
225
|
+
`Check JWT_SECRET_V${currentRaw} is set.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return { keys, signKid };
|
|
229
|
+
}
|
|
@@ -2,7 +2,10 @@ import type { MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
|
|
3
3
|
|
|
4
4
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
|
-
|
|
5
|
+
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
6
|
+
// older/decrypt-only format version still present in unmigrated rows.
|
|
7
|
+
const CIPHERTEXT_MARKER = "kumiko-pii:v";
|
|
8
|
+
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
6
9
|
|
|
7
10
|
// A PII subject ciphertext never belongs in an API response — its presence
|
|
8
11
|
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
@@ -19,7 +22,7 @@ export function piiCiphertextResponseGuard(): MiddlewareHandler {
|
|
|
19
22
|
if (!contentType.includes("application/json")) return;
|
|
20
23
|
const text = await c.res.clone().text();
|
|
21
24
|
// skip: clean response — the common case
|
|
22
|
-
if (!text.includes(
|
|
25
|
+
if (!text.includes(CIPHERTEXT_MARKER)) return;
|
|
23
26
|
|
|
24
27
|
const detail =
|
|
25
28
|
`[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
|
package/src/api/routes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
2
3
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
4
|
import type { SessionUser } from "../engine/types/handlers";
|
|
4
5
|
import {
|
|
@@ -16,6 +17,7 @@ import { Routes } from "./api-constants";
|
|
|
16
17
|
import { getUser } from "./auth-middleware";
|
|
17
18
|
import { patAllows } from "./pat-scope";
|
|
18
19
|
import { requestContext } from "./request-context";
|
|
20
|
+
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
|
|
19
21
|
|
|
20
22
|
export function createApiRoutes(dispatcher: Dispatcher) {
|
|
21
23
|
const api = new Hono();
|
|
@@ -121,6 +123,61 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
121
123
|
}
|
|
122
124
|
});
|
|
123
125
|
|
|
126
|
+
// Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
|
|
127
|
+
// broker-based /sse route). Frame contract for clients: "chunk" (one per
|
|
128
|
+
// yielded value, JSON-encoded), "ping" (heartbeat, empty data), "done"
|
|
129
|
+
// (terminal, empty data), "error" (terminal, JSON error envelope — the
|
|
130
|
+
// response status stays 200 since SSE headers are already flushed before
|
|
131
|
+
// dispatch gates run on the generator's first pull).
|
|
132
|
+
api.post(Routes.stream, async (c) => {
|
|
133
|
+
const user = getUser(c);
|
|
134
|
+
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
135
|
+
const requestId = requestContext.get()?.requestId;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
assertPatAllowed(user, body.type);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return streamSSE(c, async (stream) => {
|
|
144
|
+
const generator = dispatcher.stream(body.type, body.payload, user);
|
|
145
|
+
stream.onAbort(() => {
|
|
146
|
+
void generator.return(undefined);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let pending = generator.next();
|
|
151
|
+
while (true) {
|
|
152
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
154
|
+
heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
|
|
155
|
+
});
|
|
156
|
+
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
157
|
+
try {
|
|
158
|
+
outcome = await Promise.race([pending, heartbeat]);
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(heartbeatTimer);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (outcome === "heartbeat") {
|
|
164
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (outcome.done) break;
|
|
168
|
+
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
169
|
+
pending = generator.next();
|
|
170
|
+
}
|
|
171
|
+
await stream.writeSSE({ event: "done", data: "" });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
const err = toKumiko(e);
|
|
174
|
+
logServerFault(err, requestId, body.type);
|
|
175
|
+
const { error } = serializeError(err, requestId);
|
|
176
|
+
await stream.writeSSE({ event: "error", data: stringifyJson(error) });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
124
181
|
return api;
|
|
125
182
|
}
|
|
126
183
|
|
package/src/api/server.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { PUBLIC_API_PATHS } from "./api-constants";
|
|
|
50
50
|
import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
|
|
51
51
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
52
52
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
53
|
-
import { createJwtHelper, type JwtHelper } from "./jwt";
|
|
53
|
+
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
54
54
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
55
55
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
56
56
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
@@ -69,8 +69,13 @@ import { createSseRoute } from "./sse-route";
|
|
|
69
69
|
export type ServerOptions = {
|
|
70
70
|
registry: Registry;
|
|
71
71
|
context: AppContext;
|
|
72
|
-
jwtSecret: string;
|
|
72
|
+
jwtSecret: string | JwtKeyring;
|
|
73
73
|
jwtIssuer?: string;
|
|
74
|
+
// JWT lifetime in seconds. Explicit always wins. When omitted, the default
|
|
75
|
+
// depends on `auth.sessionChecker`: wired (revocation possible) keeps the
|
|
76
|
+
// long-lived 24h default; unwired (stateless JWTs, no revocation) drops to
|
|
77
|
+
// 1h so a leaked stateless token has a much smaller exposure window.
|
|
78
|
+
jwtTtl?: number;
|
|
74
79
|
dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
|
|
75
80
|
systemHooks?: SystemHooks;
|
|
76
81
|
eventDedup?: EventDedup;
|
|
@@ -94,6 +99,8 @@ export type ServerOptions = {
|
|
|
94
99
|
pollIntervalMs?: number;
|
|
95
100
|
batchSize?: number;
|
|
96
101
|
maxAttempts?: number;
|
|
102
|
+
rearmCooldownMs?: number;
|
|
103
|
+
maxRearmCount?: number;
|
|
97
104
|
// Opt out of building the dispatcher even if consumers exist — e.g. ops
|
|
98
105
|
// runs a dedicated dispatcher process, or a test needs to control the
|
|
99
106
|
// consumer lifecycle manually.
|
|
@@ -238,7 +245,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
238
245
|
);
|
|
239
246
|
}
|
|
240
247
|
|
|
241
|
-
|
|
248
|
+
// Stateless JWTs (no sessionChecker → no revocation) default to a shorter
|
|
249
|
+
// TTL than session-backed ones, since a leaked stateless token can't be
|
|
250
|
+
// revoked and stays valid until it expires. Explicit jwtTtl always wins.
|
|
251
|
+
const defaultJwtTtl = options.auth?.sessionChecker ? 24 * 60 * 60 : 60 * 60;
|
|
252
|
+
const jwt = createJwtHelper(
|
|
253
|
+
options.jwtSecret,
|
|
254
|
+
options.jwtIssuer,
|
|
255
|
+
options.jwtTtl ?? defaultJwtTtl,
|
|
256
|
+
);
|
|
242
257
|
const sseBroker = options.sseBroker ?? createSseBroker();
|
|
243
258
|
|
|
244
259
|
// Resolve the per-process instance identifier. Prefer explicit
|
|
@@ -566,8 +581,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
566
581
|
// middleware can reject revoked sids on every request.
|
|
567
582
|
const jwtGuard = authMiddleware(jwt, {
|
|
568
583
|
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
569
|
-
...(options.auth?.
|
|
570
|
-
...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
|
|
584
|
+
...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
|
|
571
585
|
...(options.auth?.resolveTenantLifecycleStatus
|
|
572
586
|
? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
|
|
573
587
|
: {}),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// #1163: Bun.SQL can hand out a closed connection under load (AbortError
|
|
2
|
+
// "The connection was closed."). Pure reads retry exactly once on a fresh
|
|
3
|
+
// pool checkout; tx handles, non-matching errors, and genuine user aborts
|
|
4
|
+
// must NOT retry.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
8
|
+
import { selectMany } from "../query";
|
|
9
|
+
|
|
10
|
+
function closedConnectionError(): Error {
|
|
11
|
+
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type FakeClient = {
|
|
15
|
+
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
16
|
+
begin?: () => never;
|
|
17
|
+
calls: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeClient(failures: Error[], opts: { tx?: boolean } = {}): FakeClient {
|
|
21
|
+
const remaining = [...failures];
|
|
22
|
+
const client: FakeClient = {
|
|
23
|
+
calls: 0,
|
|
24
|
+
unsafe: async () => {
|
|
25
|
+
client.calls++;
|
|
26
|
+
const err = remaining.shift();
|
|
27
|
+
if (err) throw err;
|
|
28
|
+
return [{ id: "r1", title: "ok", tenant_id: "t1", inserted_at: null, updated_at: null }];
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
// A top-level pool client has begin(); a transaction handle does not.
|
|
32
|
+
if (!opts.tx)
|
|
33
|
+
client.begin = () => {
|
|
34
|
+
throw new Error("not used in test");
|
|
35
|
+
};
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const table = buildEntityTable("note", {
|
|
40
|
+
fields: { title: { type: "text", required: true } },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("selectMany — closed-connection retry (#1163)", () => {
|
|
44
|
+
test("retries once on AbortError 'connection was closed' and returns rows", async () => {
|
|
45
|
+
const db = fakeClient([closedConnectionError()]);
|
|
46
|
+
const rows = await selectMany(db, table);
|
|
47
|
+
expect(rows).toHaveLength(1);
|
|
48
|
+
expect(rows[0]?.title).toBe("ok");
|
|
49
|
+
expect(db.calls).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("gives up after the single retry when the connection stays closed", async () => {
|
|
53
|
+
const db = fakeClient([closedConnectionError(), closedConnectionError()]);
|
|
54
|
+
await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
|
|
55
|
+
expect(db.calls).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("never retries on a transaction handle (no begin)", async () => {
|
|
59
|
+
const db = fakeClient([closedConnectionError()], { tx: true });
|
|
60
|
+
await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
|
|
61
|
+
expect(db.calls).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not retry a genuine user abort (different message)", async () => {
|
|
65
|
+
const userAbort = Object.assign(new Error("The operation was aborted."), {
|
|
66
|
+
name: "AbortError",
|
|
67
|
+
});
|
|
68
|
+
const db = fakeClient([userAbort]);
|
|
69
|
+
await expect(selectMany(db, table)).rejects.toThrow("operation was aborted");
|
|
70
|
+
expect(db.calls).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not retry generic query errors", async () => {
|
|
74
|
+
const syntax = Object.assign(new Error("syntax error at or near"), { name: "PostgresError" });
|
|
75
|
+
const db = fakeClient([syntax]);
|
|
76
|
+
await expect(selectMany(db, table)).rejects.toThrow("syntax error");
|
|
77
|
+
expect(db.calls).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
// drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
|
|
20
20
|
// reference, NICHT als runtime-API-call)
|
|
21
21
|
|
|
22
|
+
import type {
|
|
23
|
+
SelectOptions,
|
|
24
|
+
WhereObject,
|
|
25
|
+
WhereOperator,
|
|
26
|
+
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
22
27
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
23
28
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
24
29
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
@@ -183,19 +188,13 @@ function assertNotTenantScoped(db: unknown, fnName: string): void {
|
|
|
183
188
|
|
|
184
189
|
export type AnyDb = BunDbRunner | unknown;
|
|
185
190
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
readonly ne?: unknown;
|
|
194
|
-
readonly in?: readonly unknown[];
|
|
195
|
-
readonly like?: string;
|
|
196
|
-
};
|
|
197
|
-
export type WhereValue = unknown | WhereOperator;
|
|
198
|
-
export type WhereObject = Record<string, WhereValue>;
|
|
191
|
+
export type {
|
|
192
|
+
OrderByClause,
|
|
193
|
+
SelectOptions,
|
|
194
|
+
WhereObject,
|
|
195
|
+
WhereOperator,
|
|
196
|
+
WhereValue,
|
|
197
|
+
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
199
198
|
|
|
200
199
|
function isWhereOperator(v: unknown): v is WhereOperator {
|
|
201
200
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
@@ -206,18 +205,6 @@ function isWhereOperator(v: unknown): v is WhereOperator {
|
|
|
206
205
|
const opKeys = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
|
|
207
206
|
return keys.every((k) => opKeys.includes(k));
|
|
208
207
|
}
|
|
209
|
-
export type OrderByClause = {
|
|
210
|
-
readonly col: string;
|
|
211
|
-
readonly direction?: "asc" | "desc";
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
export type SelectOptions = {
|
|
215
|
-
readonly limit?: number;
|
|
216
|
-
// Single column or array for multi-column tie-breaks (e.g.
|
|
217
|
-
// [{col: "createdAt"}, {col: "id"}] for chronological-with-stable-id).
|
|
218
|
-
readonly orderBy?: OrderByClause | readonly OrderByClause[];
|
|
219
|
-
};
|
|
220
|
-
|
|
221
208
|
// Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
|
|
222
209
|
// biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
|
|
223
210
|
type TableLike = EntityTableMeta | any;
|
|
@@ -562,6 +549,38 @@ function buildWhereClause(
|
|
|
562
549
|
return { sqlText: conditions.join(" AND "), values };
|
|
563
550
|
}
|
|
564
551
|
|
|
552
|
+
// #1163: under load the Bun.SQL pool can hand out a connection the server
|
|
553
|
+
// already closed ("The connection was closed.", AbortError code 20) — no
|
|
554
|
+
// validate-on-checkout exists. One retry re-checks out a fresh connection;
|
|
555
|
+
// safe for pure reads. Tx handles are never retried: their transaction is
|
|
556
|
+
// dead once the connection dropped, and a retry would run on the same dead
|
|
557
|
+
// handle. Detection is name+message (not name alone) so a genuine user
|
|
558
|
+
// abort (AbortSignal cancel) is NOT retried.
|
|
559
|
+
function isClosedConnectionError(err: unknown): boolean {
|
|
560
|
+
if (err === null || typeof err !== "object") return false;
|
|
561
|
+
const e = err as { name?: unknown; message?: unknown };
|
|
562
|
+
return (
|
|
563
|
+
e.name === "AbortError" &&
|
|
564
|
+
typeof e.message === "string" &&
|
|
565
|
+
/connection was closed/i.test(e.message)
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function unsafeRead<TRow>(
|
|
570
|
+
db: AnyDb,
|
|
571
|
+
sqlText: string,
|
|
572
|
+
params: readonly unknown[],
|
|
573
|
+
): Promise<readonly TRow[]> {
|
|
574
|
+
const raw = asRawClient(db);
|
|
575
|
+
try {
|
|
576
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
577
|
+
} catch (err) {
|
|
578
|
+
// TransactionSql has savepoint(), only a top-level pool client has begin().
|
|
579
|
+
if (typeof raw.begin !== "function" || !isClosedConnectionError(err)) throw err;
|
|
580
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
565
584
|
// biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
|
|
566
585
|
export async function selectMany<TRow = any>(
|
|
567
586
|
db: AnyDb,
|
|
@@ -598,7 +617,7 @@ export async function selectMany<TRow = any>(
|
|
|
598
617
|
}
|
|
599
618
|
sqlText += ` LIMIT ${options.limit}`;
|
|
600
619
|
}
|
|
601
|
-
const raw = (await
|
|
620
|
+
const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
|
|
602
621
|
return coerceRows(raw, info) as readonly TRow[];
|
|
603
622
|
}
|
|
604
623
|
|
|
@@ -832,7 +851,7 @@ export async function countWhere(
|
|
|
832
851
|
sqlText += ` WHERE ${w.sqlText}`;
|
|
833
852
|
values = w.values;
|
|
834
853
|
}
|
|
835
|
-
const rows = (await
|
|
854
|
+
const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
|
|
836
855
|
return rows[0]?.count ?? 0;
|
|
837
856
|
}
|
|
838
857
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared core for the standalone consumer-ops CLI (status | restart).
|
|
2
|
+
//
|
|
3
|
+
// A dead event consumer (halt-on-poison after maxAttempts) previously had no
|
|
4
|
+
// recovery surface in the standalone prod bundle — only raw SQL against
|
|
5
|
+
// kumiko_event_consumers. Mirrors schema-cli.ts's shape (single runXCli(argv,
|
|
6
|
+
// out) entry point, own DB connection) so `kumiko-consumer` ships the same
|
|
7
|
+
// way `kumiko-schema` does.
|
|
8
|
+
|
|
9
|
+
import { createDbConnection } from "./db";
|
|
10
|
+
import { getConsumerState, restartConsumer } from "./pipeline";
|
|
11
|
+
import { ensureTemporalPolyfill } from "./time";
|
|
12
|
+
|
|
13
|
+
export type ConsumerCliOut = {
|
|
14
|
+
readonly log: (line: string) => void;
|
|
15
|
+
readonly err: (line: string) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseInstanceIdFlag(argv: readonly string[]): string | undefined {
|
|
19
|
+
const i = argv.indexOf("--instance-id");
|
|
20
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runConsumerCli(
|
|
24
|
+
argv: readonly string[],
|
|
25
|
+
out: ConsumerCliOut,
|
|
26
|
+
): Promise<number> {
|
|
27
|
+
// The standalone bundle never runs runProdApp/runDevApp's boot, which is
|
|
28
|
+
// where Temporal normally gets installed — ConsumerRecoveryState.updatedAt
|
|
29
|
+
// is a Temporal.Instant, so without this every subcommand throws "Temporal
|
|
30
|
+
// is not defined" (same failure mode as schema-cli, see its polyfill test).
|
|
31
|
+
await ensureTemporalPolyfill();
|
|
32
|
+
const sub = argv[0];
|
|
33
|
+
|
|
34
|
+
if (sub !== "status" && sub !== "restart") {
|
|
35
|
+
out.log("");
|
|
36
|
+
out.log(" Subcommands:");
|
|
37
|
+
out.log(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
|
|
38
|
+
out.log(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
|
|
39
|
+
out.log("");
|
|
40
|
+
return sub === undefined ? 0 : 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const name = argv[1];
|
|
44
|
+
if (!name) {
|
|
45
|
+
out.err(` Usage: consumer ${sub} <name> [--instance-id <id>]`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
const instanceId = parseInstanceIdFlag(argv);
|
|
49
|
+
|
|
50
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
51
|
+
if (!dbUrl) {
|
|
52
|
+
out.err(" DATABASE_URL not set.");
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
56
|
+
try {
|
|
57
|
+
if (sub === "status") {
|
|
58
|
+
const state = await getConsumerState(db, name, instanceId);
|
|
59
|
+
if (!state) {
|
|
60
|
+
out.err(` Consumer "${name}" (instance_id="${instanceId ?? "__shared__"}") not found.`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
out.log("");
|
|
64
|
+
out.log(` ${state.name} (instance_id="${state.instanceId}")`);
|
|
65
|
+
out.log(` status: ${state.status}`);
|
|
66
|
+
out.log(` cursor: ${state.lastProcessedEventId}`);
|
|
67
|
+
out.log(` attempts: ${state.attempts}`);
|
|
68
|
+
out.log(` rearmCount: ${state.rearmCount}`);
|
|
69
|
+
out.log(` lastError: ${state.lastError ?? "-"}`);
|
|
70
|
+
out.log(` updatedAt: ${state.updatedAt.toString()}`);
|
|
71
|
+
out.log("");
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// restart
|
|
76
|
+
const result = await restartConsumer(db, name, instanceId);
|
|
77
|
+
out.log("");
|
|
78
|
+
out.log(` ✓ ${result.name} (instance_id="${result.instanceId}") → ${result.status}`);
|
|
79
|
+
out.log("");
|
|
80
|
+
return 0;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
83
|
+
return 1;
|
|
84
|
+
} finally {
|
|
85
|
+
await close();
|
|
86
|
+
}
|
|
87
|
+
}
|