@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -235,20 +235,30 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
235
235
|
// on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
|
|
236
236
|
// or two different messages of the same thread arriving at once)
|
|
237
237
|
// can both read the same stream version before either commits.
|
|
238
|
-
// tryAppendEvent's fresh version-read at append time
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
238
|
+
// tryAppendEvent's fresh version-read at append time makes the
|
|
239
|
+
// loser's append fail with a VersionConflictError ONLY when the
|
|
240
|
+
// other TX's commit lands between this reload and the append's own
|
|
241
|
+
// version check — so we retry: reload the thread stream, recompute
|
|
242
|
+
// the live row-count and lastMessageAt, and append again. If the
|
|
243
|
+
// other TX instead commits in the gap between the countWhere below
|
|
244
|
+
// and the append's version-read, the version check sees the
|
|
245
|
+
// already-bumped stream and appends without conflict — but with the
|
|
246
|
+
// stale row-count this countWhere captured before the concurrent
|
|
247
|
+
// insert. That write still lands (messageCount can undercount by
|
|
248
|
+
// one round), it is not eliminated by this retry loop; the next
|
|
249
|
+
// ingest on the thread re-counts and self-corrects. Unlike step 4,
|
|
250
|
+
// a lost version-conflict race here must NOT be treated as a no-op
|
|
251
|
+
// duplicate — skipping would leave this message's contribution to
|
|
252
|
+
// messageCount/lastMessageAt out of the thread forever. Exhausting
|
|
253
|
+
// all attempts throws, rolling back the whole TX (including the
|
|
254
|
+
// step-4 message append) for a clean re-ingest on the next poll —
|
|
255
|
+
// never return success with a stale snapshot.
|
|
248
256
|
//
|
|
249
257
|
// A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
|
|
250
|
-
// advisory lock on threadAggId)
|
|
251
|
-
//
|
|
258
|
+
// advisory lock on threadAggId) would close the countWhere→append
|
|
259
|
+
// gap above and is deliberately not part of this fix — two writers
|
|
260
|
+
// (watch + poll) make self-correcting drift an acceptable trade for
|
|
261
|
+
// now.
|
|
252
262
|
// ---------------------------------------------------------------
|
|
253
263
|
const threadPlainPii = { tenantId, subject: payload.subject };
|
|
254
264
|
const encryptedThreadFields = piiKms
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Greenmail suites skip in CI when the container is down — this file mocks
|
|
3
3
|
// imapflow so the plugin body stays on the coverage badge without Docker.
|
|
4
4
|
|
|
5
|
-
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
7
7
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
8
|
import {
|
|
@@ -110,17 +110,27 @@ class FakeImapFlow extends EventEmitter {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Restore hygiene: Bun runs a test-run's files in one process, so a
|
|
114
|
+
// top-level mock.module leaks into every file that runs after this one. The
|
|
115
|
+
// real module is captured before mocking so afterAll can put it back — a
|
|
116
|
+
// later file that needs the real imapflow (or a filtered/reordered run via
|
|
117
|
+
// `-t`) would otherwise silently see FakeImapFlow instead.
|
|
118
|
+
const realImapflow = await import("imapflow");
|
|
113
119
|
mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
|
|
114
120
|
|
|
115
121
|
const { imapInboundMailPlugin } = await import("../feature");
|
|
116
122
|
|
|
123
|
+
afterAll(() => {
|
|
124
|
+
mock.module("imapflow", () => realImapflow);
|
|
125
|
+
});
|
|
126
|
+
|
|
117
127
|
function mime(subject: string, text: string): Buffer {
|
|
118
128
|
return Buffer.from(
|
|
119
129
|
[
|
|
120
130
|
"From: Sender <sender@example.com>",
|
|
121
131
|
"To: inbox@example.com",
|
|
122
132
|
`Subject: ${subject}`,
|
|
123
|
-
|
|
133
|
+
`Message-ID: <${subject}-@example.com>`,
|
|
124
134
|
"Date: Mon, 01 Jan 2024 12:00:00 +0000",
|
|
125
135
|
"MIME-Version: 1.0",
|
|
126
136
|
"Content-Type: text/plain; charset=utf-8",
|
|
@@ -142,6 +152,13 @@ const account: MailAccountRecord = {
|
|
|
142
152
|
watchState: "idle",
|
|
143
153
|
};
|
|
144
154
|
|
|
155
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
156
|
+
const t0 = Date.now();
|
|
157
|
+
while (!predicate() && Date.now() - t0 < timeoutMs) {
|
|
158
|
+
await Bun.sleep(5);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
145
162
|
function ctxWithDoc(doc: string | null): InboundMailContext {
|
|
146
163
|
return {
|
|
147
164
|
_userId: "imap-plugin-mocked",
|
|
@@ -227,6 +244,11 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
227
244
|
expect(result.messages[0]?.subject).toBe("one");
|
|
228
245
|
expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 2 });
|
|
229
246
|
expect(result.hasMore).toBe(false);
|
|
247
|
+
// Fixtures must carry distinct Message-ID headers — colliding fixtures
|
|
248
|
+
// would mask a mapping bug where toRawInboundMessage produces a
|
|
249
|
+
// wrong/constant messageIdHeader per batch (used for the References/
|
|
250
|
+
// threading chain and the self-heal dedup fallback).
|
|
251
|
+
expect(new Set(result.messages.map((m) => m.messageIdHeader)).size).toBe(2);
|
|
230
252
|
});
|
|
231
253
|
|
|
232
254
|
test("fetch: incremental cursor filters uid <= lastUid + hasMore via maxMessages", async () => {
|
|
@@ -254,10 +276,11 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
254
276
|
{ uidValidity: "17", lastUid: 1 },
|
|
255
277
|
{ backfillWindowDays: 7, maxMessages: 2 },
|
|
256
278
|
);
|
|
257
|
-
// search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore
|
|
258
|
-
|
|
279
|
+
// search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore, uid 1
|
|
280
|
+
// filtered out and only the first 2 of the 3 remaining survive the cap.
|
|
281
|
+
expect(result.messages.map((m) => m.subject)).toEqual(["two", "three"]);
|
|
259
282
|
expect(result.hasMore).toBe(true);
|
|
260
|
-
expect(result.nextCursor
|
|
283
|
+
expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 3 });
|
|
261
284
|
});
|
|
262
285
|
|
|
263
286
|
test("fetch: UIDVALIDITY change → InboundCursorInvalidError", async () => {
|
|
@@ -307,7 +330,9 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
307
330
|
];
|
|
308
331
|
lastIdleClient?.emit("exists");
|
|
309
332
|
|
|
310
|
-
|
|
333
|
+
// drainNew() does MIME-parsing before onMessages fires — a fixed sleep
|
|
334
|
+
// flakes on a loaded CI runner; poll instead.
|
|
335
|
+
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")));
|
|
311
336
|
expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
|
|
312
337
|
|
|
313
338
|
await stop();
|
|
@@ -322,9 +347,11 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
322
347
|
},
|
|
323
348
|
});
|
|
324
349
|
lastIdleClient?.emit("error", new Error("socket hang up"));
|
|
325
|
-
await
|
|
350
|
+
await waitFor(() => errors >= 1);
|
|
326
351
|
lastIdleClient?.emit("error", new Error("second"));
|
|
327
|
-
|
|
352
|
+
// Confirms onError stays unsubscribed after the first error — polls the
|
|
353
|
+
// same bounded window rather than betting on a fixed sleep outlasting it.
|
|
354
|
+
await waitFor(() => errors >= 2, 100);
|
|
328
355
|
expect(errors).toBe(1);
|
|
329
356
|
await stop().catch(() => {});
|
|
330
357
|
});
|
|
@@ -13,36 +13,40 @@ describe("inboundProviderInMemoryFeature — shape", () => {
|
|
|
13
13
|
});
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
describeInboundMailProviderContract(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
})
|
|
16
|
+
describeInboundMailProviderContract(
|
|
17
|
+
"inmemory",
|
|
18
|
+
() => {
|
|
19
|
+
const accountId = crypto.randomUUID();
|
|
20
|
+
return {
|
|
21
|
+
plugin: inMemoryInboundMailPlugin,
|
|
22
|
+
ctx: {},
|
|
23
|
+
account: {
|
|
24
|
+
id: accountId,
|
|
25
|
+
tenantId: "00000000-0000-4000-8000-000000004242",
|
|
26
|
+
provider: "inmemory",
|
|
27
|
+
authMethod: "password",
|
|
28
|
+
ownerUserId: null,
|
|
29
|
+
address: "contract@example.com",
|
|
30
|
+
displayName: "Contract-Test",
|
|
31
|
+
status: "active",
|
|
32
|
+
watchState: "idle",
|
|
33
|
+
},
|
|
34
|
+
seed: (subject) =>
|
|
35
|
+
seedInboundMessage(accountId, {
|
|
36
|
+
providerMessageId: crypto.randomUUID(),
|
|
37
|
+
messageIdHeader: null,
|
|
38
|
+
providerThreadId: null,
|
|
39
|
+
references: [],
|
|
40
|
+
from: "sender@example.com",
|
|
41
|
+
to: ["contract@example.com"],
|
|
42
|
+
cc: [],
|
|
43
|
+
subject,
|
|
44
|
+
snippet: subject,
|
|
45
|
+
receivedAtIso: "2026-07-01T10:00:00Z",
|
|
46
|
+
rawMime: null,
|
|
47
|
+
scope: "inbox",
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
{ hasWatch: Boolean(inMemoryInboundMailPlugin.watch) },
|
|
52
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// kumiko-framework#1525: parseJobInstant is the extracted seam for the five
|
|
2
|
+
// value-position Temporal.Instant.from call sites inside feature.ts's
|
|
3
|
+
// defineApply callbacks (no exported seam there) — must not rely on
|
|
4
|
+
// globalThis.Temporal.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
|
|
8
|
+
import { parseJobInstant } from "../job-instant";
|
|
9
|
+
|
|
10
|
+
describe("parseJobInstant — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
11
|
+
test("parses an ISO instant without relying on globalThis.Temporal", async () => {
|
|
12
|
+
const instant = await withoutAmbientTemporal(() => parseJobInstant("2026-01-01T00:00:00Z"));
|
|
13
|
+
expect(instant.toString()).toBe("2026-01-01T00:00:00Z");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -92,10 +92,16 @@ describe("reindexEntityJob", () => {
|
|
|
92
92
|
});
|
|
93
93
|
expect(preResults).toHaveLength(0);
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
const ctx: JobContext = {
|
|
96
|
+
db: stack.db,
|
|
97
|
+
registry: stack.registry,
|
|
98
|
+
searchAdapter: stack.search,
|
|
99
|
+
systemUser: admin,
|
|
100
|
+
log: noopLogger,
|
|
101
|
+
triggeredBy: null,
|
|
102
|
+
...bridgeStub(),
|
|
103
|
+
};
|
|
104
|
+
await reindexEntityJob({ entity: "widget" }, ctx);
|
|
99
105
|
|
|
100
106
|
const postResults = await stack.search.search(admin.tenantId, "backfillable", {
|
|
101
107
|
filterType: "widget",
|
|
@@ -103,11 +109,15 @@ describe("reindexEntityJob", () => {
|
|
|
103
109
|
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
104
110
|
});
|
|
105
111
|
|
|
106
|
-
test("the
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
112
|
+
test("the job is registered under REINDEX_ENTITY_JOB and its handler indexes", async () => {
|
|
113
|
+
// Honest scope: this still hand-builds JobContext (same as
|
|
114
|
+
// retention-cleanup's handler test) rather than going through
|
|
115
|
+
// jobRunner.dispatch() — it does NOT cover the dispatch chain's own
|
|
116
|
+
// ctx-resolution/guard logic, only that registry.getJob(REINDEX_ENTITY_JOB)
|
|
117
|
+
// resolves to a handler and that handler indexes correctly when called
|
|
118
|
+
// with a well-formed ctx. A bug in dispatch()'s ctx-resolution itself
|
|
119
|
+
// (e.g. a systemUser that isn't wired through) would be invisible here,
|
|
120
|
+
// same as in the two direct-call tests above.
|
|
111
121
|
const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
|
|
112
122
|
const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
|
|
113
123
|
const created = await executor.create({ name: "Registry-Resolved Widget" }, admin, tenantDb);
|
package/src/jobs/feature.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { reindexEntityJob, reindexEntityPayloadSchema } from "./handlers/reindex
|
|
|
21
21
|
import { retryWrite } from "./handlers/retry.write";
|
|
22
22
|
import { triggerWrite } from "./handlers/trigger.write";
|
|
23
23
|
import { JOBS_I18N } from "./i18n";
|
|
24
|
+
import { parseJobInstant } from "./job-instant";
|
|
24
25
|
import {
|
|
25
26
|
JOB_RUN_COMPLETED_EVENT,
|
|
26
27
|
JOB_RUN_FAILED_EVENT,
|
|
@@ -78,7 +79,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
78
79
|
status: p.status,
|
|
79
80
|
payload: p.payload,
|
|
80
81
|
attempt: p.attempt,
|
|
81
|
-
startedAt:
|
|
82
|
+
startedAt: parseJobInstant(p.startedAt),
|
|
82
83
|
triggeredById: p.triggeredById,
|
|
83
84
|
});
|
|
84
85
|
},
|
|
@@ -92,7 +93,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
92
93
|
{
|
|
93
94
|
status: "completed",
|
|
94
95
|
duration: p.duration,
|
|
95
|
-
finishedAt:
|
|
96
|
+
finishedAt: parseJobInstant(p.finishedAt),
|
|
96
97
|
version: event.version,
|
|
97
98
|
modifiedAt: event.createdAt,
|
|
98
99
|
modifiedById: event.metadata?.userId ?? "system",
|
|
@@ -111,7 +112,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
111
112
|
status: "failed",
|
|
112
113
|
error: p.error,
|
|
113
114
|
duration: p.duration,
|
|
114
|
-
finishedAt:
|
|
115
|
+
finishedAt: parseJobInstant(p.finishedAt),
|
|
115
116
|
version: event.version,
|
|
116
117
|
modifiedAt: event.createdAt,
|
|
117
118
|
modifiedById: event.metadata?.userId ?? "system",
|
|
@@ -145,7 +146,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
145
146
|
runId: event.aggregateId,
|
|
146
147
|
level: log.level,
|
|
147
148
|
message: log.message,
|
|
148
|
-
timestamp:
|
|
149
|
+
timestamp: parseJobInstant(log.timestamp),
|
|
149
150
|
})),
|
|
150
151
|
);
|
|
151
152
|
},
|
|
@@ -161,7 +162,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
161
162
|
runId: event.aggregateId,
|
|
162
163
|
level: log.level,
|
|
163
164
|
message: log.message,
|
|
164
|
-
timestamp:
|
|
165
|
+
timestamp: parseJobInstant(log.timestamp),
|
|
165
166
|
})),
|
|
166
167
|
);
|
|
167
168
|
}),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Temporal } from "temporal-polyfill";
|
|
2
|
+
|
|
3
|
+
// kumiko-framework#1525: the five value-position Temporal.Instant.from
|
|
4
|
+
// call sites in feature.ts live inside defineApply callbacks with no
|
|
5
|
+
// exported seam, so this thin wrapper gives the ambient-global-independence
|
|
6
|
+
// regression test something to import directly.
|
|
7
|
+
export function parseJobInstant(iso: string): Temporal.Instant {
|
|
8
|
+
return Temporal.Instant.from(iso);
|
|
9
|
+
}
|
|
@@ -11,9 +11,18 @@ export type MailTransportContractFixture = {
|
|
|
11
11
|
readonly readBack?: (tenantId: string) => readonly EmailMessage[];
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
export type MailTransportContractOptions = {
|
|
15
|
+
// Local providers (inmemory) can assert real delivery via readBack —
|
|
16
|
+
// remote providers (smtp) can't (send() needs a live server), so their
|
|
17
|
+
// delivery test must show as an explicit `skip` in test output, not a
|
|
18
|
+
// silently-passing green with zero assertions (#1334).
|
|
19
|
+
readonly verifiesDelivery: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
14
22
|
export function describeMailTransportContract(
|
|
15
23
|
name: string,
|
|
16
24
|
factory: () => MailTransportContractFixture | Promise<MailTransportContractFixture>,
|
|
25
|
+
options: MailTransportContractOptions,
|
|
17
26
|
): void {
|
|
18
27
|
describe(`${name} — MailTransportPlugin contract`, () => {
|
|
19
28
|
let fixture: MailTransportContractFixture;
|
|
@@ -27,16 +36,23 @@ export function describeMailTransportContract(
|
|
|
27
36
|
expect(typeof transport.send).toBe("function");
|
|
28
37
|
});
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
(options.verifiesDelivery ? test : test.skip)(
|
|
40
|
+
"send delivers the message — verified via readBack",
|
|
41
|
+
async () => {
|
|
42
|
+
if (!fixture.readBack) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"describeMailTransportContract: verifiesDelivery:true but the fixture has no readBack",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const message: EmailMessage = {
|
|
48
|
+
to: "contract-recipient@example.test",
|
|
49
|
+
subject: "contract-test-subject",
|
|
50
|
+
html: "<p>contract-test-body</p>",
|
|
51
|
+
};
|
|
52
|
+
const transport = await fixture.plugin.build(fixture.ctx, fixture.tenantId);
|
|
53
|
+
await transport.send(message);
|
|
54
|
+
expect(fixture.readBack(fixture.tenantId)).toContainEqual(message);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
41
57
|
});
|
|
42
58
|
}
|
|
@@ -15,12 +15,16 @@ function registeredPlugin(): MailTransportPlugin {
|
|
|
15
15
|
return usage.options;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
describeMailTransportContract(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
describeMailTransportContract(
|
|
19
|
+
"mail-transport-inmemory",
|
|
20
|
+
() => ({
|
|
21
|
+
plugin: registeredPlugin(),
|
|
22
|
+
ctx: {},
|
|
23
|
+
tenantId: "contract-test-tenant",
|
|
24
|
+
readBack: getInbox,
|
|
25
|
+
}),
|
|
26
|
+
{ verifiesDelivery: true },
|
|
27
|
+
);
|
|
24
28
|
|
|
25
29
|
describe("mailTransportInMemoryFeature — shape", () => {
|
|
26
30
|
test("has the expected name", () => {
|
|
@@ -49,11 +49,16 @@ function fakeSecrets(): SecretsContext {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
describeMailTransportContract(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
describeMailTransportContract(
|
|
53
|
+
"mail-transport-smtp",
|
|
54
|
+
() => ({
|
|
55
|
+
plugin: registeredPlugin(),
|
|
56
|
+
ctx: { config: fakeConfig(), secrets: fakeSecrets(), _userId: "contract-test-user" },
|
|
57
|
+
tenantId: "contract-test-tenant",
|
|
58
|
+
}),
|
|
59
|
+
// send() needs a live SMTP server — can't assert real delivery here.
|
|
60
|
+
{ verifiesDelivery: false },
|
|
61
|
+
);
|
|
57
62
|
|
|
58
63
|
describe("mailTransportSmtpFeature — build error path", () => {
|
|
59
64
|
test("build rejects when a required config-key (host) is unset", async () => {
|
|
@@ -20,6 +20,35 @@ describe("renderSafeMarkdown — XSS-Härtung", () => {
|
|
|
20
20
|
expect(html).toContain('href="#"');
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
// kumiko-framework#1522/#1523: CommonMark allows a tab inside a
|
|
24
|
+
// <...>-bracketed link destination; `.trim()` only strips leading/
|
|
25
|
+
// trailing whitespace, so "java\tscript:" survived isSafeHref's scheme
|
|
26
|
+
// regex (the tab breaks the character class before ":", the check falls
|
|
27
|
+
// through to `return true`) and the browser normalizes it back to
|
|
28
|
+
// "javascript:" on click. Mirrors xss-safety.test.tsx's UNSAFE_HREFS.
|
|
29
|
+
test("java\\tscript:-Link-href (tab-obfuscated scheme via <...> destination) wird neutralisiert", () => {
|
|
30
|
+
const html = renderSafeMarkdown("[klick](<java\tscript:alert(1)>)");
|
|
31
|
+
expect(html.toLowerCase()).not.toContain("javascript:");
|
|
32
|
+
expect(html).toContain('href="#"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// kumiko-framework#1545: HTML character references decode client-side
|
|
36
|
+
// (the browser parses them before the URL parser ever sees the value),
|
|
37
|
+
// so "javascript:", "java	script:" and "java	script:" all
|
|
38
|
+
// normalize to an executable javascript: URL on click even though none
|
|
39
|
+
// contains a literal ":" for isSafeHref's pre-decode scheme check.
|
|
40
|
+
test("HTML-entity-obfuscated javascript: hrefs are neutralized", () => {
|
|
41
|
+
for (const href of [
|
|
42
|
+
"javascript:alert(1)",
|
|
43
|
+
"java	script:alert(1)",
|
|
44
|
+
"java	script:alert(1)",
|
|
45
|
+
]) {
|
|
46
|
+
const html = renderSafeMarkdown(`[klick](<${href}>)`);
|
|
47
|
+
expect(html.toLowerCase()).not.toContain("javascript:");
|
|
48
|
+
expect(html).toContain('href="#"');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
23
52
|
test("data:-Image-src wird neutralisiert", () => {
|
|
24
53
|
const html = renderSafeMarkdown("");
|
|
25
54
|
expect(html.toLowerCase()).not.toContain("data:");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import { escapeHtml, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import { Marked } from "marked";
|
|
3
3
|
|
|
4
4
|
// Geteilter, gehärteter Markdown→HTML-Kern für server-gerenderte Public-
|
|
@@ -24,14 +24,6 @@ safeRenderer.use({
|
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
// http(s)/mailto oder schema-los (relativ/anchor) erlaubt; javascript:, data:,
|
|
28
|
-
// vbscript: u.a. abgelehnt. Ein relativer href hat kein `scheme:`-Präfix.
|
|
29
|
-
function isSafeHref(href: string): boolean {
|
|
30
|
-
const trimmed = href.trim().toLowerCase();
|
|
31
|
-
if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
|
|
32
|
-
return /^(?:https?|mailto):/.test(trimmed);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
27
|
export function renderSafeMarkdown(markdown: string): string {
|
|
36
28
|
// @cast-boundary marked.parse return-type ist `string | Promise<string>`;
|
|
37
29
|
// `{ async: false }` garantiert sync (string) — Cast nur API-Vertragsfix.
|
|
@@ -47,7 +47,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
47
47
|
r.describe(
|
|
48
48
|
"Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — registered as an auth-foundation tokenVerifier provider, resolved generically by the middleware. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
|
|
49
49
|
);
|
|
50
|
-
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended:
|
|
50
|
+
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
51
51
|
// Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
|
|
52
52
|
// itself r.toggleable so the dispatcher gate + tier-engine can switch PAT
|
|
53
53
|
// on/off per tenant. { default: false } = fail-closed until a tier grants it.
|
|
@@ -57,7 +57,9 @@ beforeEach(async () => {
|
|
|
57
57
|
// written directly on the hot path, then revoked — no events anywhere.
|
|
58
58
|
const SID = "00000000-0000-0000-0000-000000000001";
|
|
59
59
|
|
|
60
|
-
async function insertRevokedSession(
|
|
60
|
+
async function insertRevokedSession(
|
|
61
|
+
db: DbConnection | ReturnType<typeof createTenantDb>,
|
|
62
|
+
): Promise<void> {
|
|
61
63
|
const now = Temporal.Now.instant();
|
|
62
64
|
await seedRow(db, userSessionTable, {
|
|
63
65
|
id: SID,
|
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
-
import {
|
|
4
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
5
5
|
import { createConfigFeature } from "../../config/feature";
|
|
6
|
+
import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
|
|
6
7
|
import { createTenantFeature } from "../../tenant";
|
|
7
8
|
import { createUserFeature } from "../../user/feature";
|
|
8
9
|
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID } from "../constants";
|
|
9
10
|
import { createSessionsFeature } from "../feature";
|
|
10
11
|
|
|
11
|
-
// Stub instead of the real auth-foundation — this test is about screen/query
|
|
12
|
-
// wiring, not auth; the real feature also mandates a tokenVerifier provider
|
|
13
|
-
// which is out of scope here.
|
|
14
|
-
const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
|
|
15
|
-
r.extendsRegistrar(EXT_SESSION_STORE, {});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
12
|
describe("sessions screens + query access alignment (kumiko-framework#255)", () => {
|
|
19
13
|
const features = [
|
|
20
14
|
createConfigFeature(),
|
|
21
15
|
createUserFeature(),
|
|
22
16
|
createTenantFeature(),
|
|
23
|
-
|
|
17
|
+
authFoundationFeature,
|
|
18
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
24
19
|
createSessionsFeature(),
|
|
25
20
|
];
|
|
26
21
|
|