@cosmicdrift/kumiko-bundled-features 0.159.1 → 0.161.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 +6 -6
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +529 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +134 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/login-screen.tsx +5 -2
- package/src/auth-foundation/__tests__/feature.test.ts +16 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
- package/src/auth-foundation/boot-checks.ts +54 -0
- package/src/auth-foundation/feature.ts +77 -23
- package/src/auth-foundation/index.ts +14 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
- package/src/auth-foundation/types.ts +63 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
- package/src/auth-mfa/constants.ts +1 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/feature.ts +8 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
|
@@ -203,6 +203,24 @@ describe("config backing=secrets — fail-loud when secrets unwired", () => {
|
|
|
203
203
|
await unwired.cleanup();
|
|
204
204
|
}
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
test("cascade read on backing=secrets without secretsReader throws", async () => {
|
|
208
|
+
// Write succeeds on the wired stack; then resolve via a bare resolver
|
|
209
|
+
// (no secretsReader) so readBackingSecret fails loud on the cascade path.
|
|
210
|
+
await stack.http.writeOk(
|
|
211
|
+
ConfigHandlers.set,
|
|
212
|
+
{ key: API_KEY, value: "sk-live-planted", scope: "system" },
|
|
213
|
+
systemAdmin,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const bare = createConfigResolver();
|
|
217
|
+
const keyDef = stack.registry.getConfigKey(API_KEY);
|
|
218
|
+
expect(keyDef).toBeDefined();
|
|
219
|
+
|
|
220
|
+
await expect(
|
|
221
|
+
bare.getCascade(API_KEY, keyDef!, systemAdmin.tenantId, systemAdmin.id, stack.db),
|
|
222
|
+
).rejects.toThrow(/backing="secrets".*without a secrets/);
|
|
223
|
+
});
|
|
206
224
|
});
|
|
207
225
|
|
|
208
226
|
describe("config backing=secrets — reset dispatch", () => {
|
|
@@ -527,3 +527,42 @@ describe("config:query:cascade handler", () => {
|
|
|
527
527
|
expect(data[TENANT_KEY]).toBeDefined();
|
|
528
528
|
});
|
|
529
529
|
});
|
|
530
|
+
|
|
531
|
+
// getAll / getAllWithSource are bulk readers used by admin UIs — never
|
|
532
|
+
// reached via the cascade query handlers above, so they stay uncovered
|
|
533
|
+
// unless called directly on the resolver.
|
|
534
|
+
describe("getAll / getAllWithSource", () => {
|
|
535
|
+
test("getAll picks the most specific row (user > tenant > system)", async () => {
|
|
536
|
+
await stack.http.writeOk(
|
|
537
|
+
ConfigHandlers.set,
|
|
538
|
+
{ key: TENANT_KEY, value: "TENANT_BULK" },
|
|
539
|
+
tenantAdmin,
|
|
540
|
+
);
|
|
541
|
+
await stack.http.writeOk(
|
|
542
|
+
ConfigHandlers.set,
|
|
543
|
+
{ key: USER_KEY, value: "USER_BULK" },
|
|
544
|
+
tenantAdmin,
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const all = await resolver.getAll(tenantAdmin.tenantId, tenantAdmin.id, db);
|
|
548
|
+
// getAll returns raw stored strings (JSON-serialized); deserialize for asserts.
|
|
549
|
+
expect(JSON.parse(all.get(TENANT_KEY)?.value ?? "null")).toBe("TENANT_BULK");
|
|
550
|
+
expect(JSON.parse(all.get(USER_KEY)?.value ?? "null")).toBe("USER_BULK");
|
|
551
|
+
// System seed still present for keys without a more-specific row.
|
|
552
|
+
expect(JSON.parse(all.get(SYSTEM_KEY)?.value ?? "null")).toBe("SEED_SYSTEM");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("getAllWithSource tags the winning row's source", async () => {
|
|
556
|
+
const withSource = await resolver.getAllWithSource(tenantAdmin.tenantId, tenantAdmin.id, db);
|
|
557
|
+
|
|
558
|
+
const userRow = withSource.get(USER_KEY);
|
|
559
|
+
expect(userRow?.source).toBe("user-row");
|
|
560
|
+
expect(JSON.parse(userRow?.value ?? "null")).toBe("USER_BULK");
|
|
561
|
+
|
|
562
|
+
const tenantRow = withSource.get(TENANT_KEY);
|
|
563
|
+
expect(tenantRow?.source).toBe("tenant-row");
|
|
564
|
+
|
|
565
|
+
const systemRow = withSource.get(SYSTEM_KEY);
|
|
566
|
+
expect(systemRow?.source).toBe("system-row");
|
|
567
|
+
});
|
|
568
|
+
});
|
|
@@ -156,4 +156,24 @@ describe("piiEncrypted config keys", () => {
|
|
|
156
156
|
configurePiiSubjectKms(kms);
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
test("read of stored piiEncrypted ciphertext without KMS fails loud", async () => {
|
|
161
|
+
// Plant ciphertext while KMS is wired, then strip it so the READ path
|
|
162
|
+
// (decryptPiiEncrypted) — not just the write path — fails loud.
|
|
163
|
+
await stack.http.writeOk(
|
|
164
|
+
ConfigHandlers.set,
|
|
165
|
+
{ key: BILLING_ADDRESS_KEY, value: "planted-for-read" },
|
|
166
|
+
tenantAdmin,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
resetPiiSubjectKmsForTests();
|
|
170
|
+
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const res = await stack.http.query(ConfigQueries.values, {}, tenantAdmin);
|
|
174
|
+
expect(res.status).toBe(500);
|
|
175
|
+
} finally {
|
|
176
|
+
configurePiiSubjectKms(kms);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
159
179
|
});
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
InboundCursorInvalidError,
|
|
44
44
|
InboundMailAccountStatuses,
|
|
45
45
|
InboundMailFoundationHandlers,
|
|
46
|
+
InboundRateLimitError,
|
|
46
47
|
inboundMailFoundationFeature,
|
|
47
48
|
inboundMessagesProjectionTable,
|
|
48
49
|
mailAccountsProjectionTable,
|
|
@@ -268,4 +269,157 @@ describe("watch-supervisor — error semantics", () => {
|
|
|
268
269
|
"cursor-ok-2@example.com",
|
|
269
270
|
]);
|
|
270
271
|
});
|
|
272
|
+
|
|
273
|
+
test("InboundRateLimitError on fetch keeps the account active for the next tick", async () => {
|
|
274
|
+
const admin = adminFor(4107);
|
|
275
|
+
const accountId = await connectSharedAccount(admin);
|
|
276
|
+
failNextFetchWith(accountId, new InboundRateLimitError("slow down", 30_000));
|
|
277
|
+
|
|
278
|
+
const supervisor = createSupervisor();
|
|
279
|
+
await supervisor.pollOnce();
|
|
280
|
+
|
|
281
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
282
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("watch-supervisor — watch restart + ingest resilience", () => {
|
|
287
|
+
test("transient watch error schedules backoff restart and re-arms watch", async () => {
|
|
288
|
+
const admin = adminFor(4108);
|
|
289
|
+
const accountId = await connectSharedAccount(admin);
|
|
290
|
+
const supervisor = createSupervisor();
|
|
291
|
+
try {
|
|
292
|
+
await supervisor.start();
|
|
293
|
+
await waitFor(() => {
|
|
294
|
+
expect(isWatching(accountId)).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
emitWatchError(accountId, new Error("socket dropped"));
|
|
298
|
+
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(isWatching(accountId)).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
304
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
305
|
+
expect(String(accounts[0]?.["watchState"])).toMatch(/^watching|backoff:/);
|
|
306
|
+
} finally {
|
|
307
|
+
await supervisor.stop();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("watch-ingest failure is logged; poll reconciliation still ingests", async () => {
|
|
312
|
+
const admin = adminFor(4109);
|
|
313
|
+
const accountId = await connectSharedAccount(admin);
|
|
314
|
+
let ingestCalls = 0;
|
|
315
|
+
const supervisor = createInboundMailSupervisor({
|
|
316
|
+
providerCtx: { registry: stack.registry },
|
|
317
|
+
db,
|
|
318
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) => {
|
|
319
|
+
if (handlerQn === InboundMailFoundationHandlers.ingestMessage) {
|
|
320
|
+
ingestCalls += 1;
|
|
321
|
+
if (ingestCalls === 1) {
|
|
322
|
+
return Promise.resolve({ isSuccess: false, error: { code: "simulated" } });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return stack.dispatcher.write(
|
|
326
|
+
handlerQn,
|
|
327
|
+
payload,
|
|
328
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
pollIntervalMs: 60_000,
|
|
332
|
+
watchBackoffInitialMs: 50,
|
|
333
|
+
watchBackoffMaxMs: 200,
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
await supervisor.start();
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(isWatching(accountId)).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await seedInboundMessage(
|
|
342
|
+
accountId,
|
|
343
|
+
rawMsg({ providerMessageId: "watch-ingest-fail-1", subject: "via watch" }),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
await supervisor.pollOnce();
|
|
347
|
+
|
|
348
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
349
|
+
expect(rows.some((r) => r["messageIdHeader"] === "watch-ingest-fail-1@example.com")).toBe(
|
|
350
|
+
true,
|
|
351
|
+
);
|
|
352
|
+
} finally {
|
|
353
|
+
await supervisor.stop();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("storeBody hook persists raw MIME references on poll ingest", async () => {
|
|
358
|
+
const admin = adminFor(4110);
|
|
359
|
+
const accountId = await connectSharedAccount(admin);
|
|
360
|
+
const stored: string[] = [];
|
|
361
|
+
const supervisor = createInboundMailSupervisor({
|
|
362
|
+
providerCtx: { registry: stack.registry },
|
|
363
|
+
db,
|
|
364
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
365
|
+
stack.dispatcher.write(
|
|
366
|
+
handlerQn,
|
|
367
|
+
payload,
|
|
368
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
369
|
+
),
|
|
370
|
+
storeBody: async (_account, msg) => {
|
|
371
|
+
stored.push(msg.providerMessageId);
|
|
372
|
+
return `body-ref:${msg.providerMessageId}`;
|
|
373
|
+
},
|
|
374
|
+
pollIntervalMs: 60_000,
|
|
375
|
+
});
|
|
376
|
+
await seedInboundMessage(
|
|
377
|
+
accountId,
|
|
378
|
+
rawMsg({
|
|
379
|
+
providerMessageId: "body-store-1",
|
|
380
|
+
rawMime: new TextEncoder().encode("raw-mime-bytes"),
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
await supervisor.pollOnce();
|
|
384
|
+
expect(stored).toEqual(["body-store-1"]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("disconnecting an account tears down its watcher on the next pollOnce", async () => {
|
|
388
|
+
const admin = adminFor(4111);
|
|
389
|
+
const accountId = await connectSharedAccount(admin);
|
|
390
|
+
const supervisor = createSupervisor();
|
|
391
|
+
try {
|
|
392
|
+
await supervisor.start();
|
|
393
|
+
await waitFor(() => {
|
|
394
|
+
expect(isWatching(accountId)).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await stack.http.writeOk(
|
|
398
|
+
InboundMailFoundationHandlers.disconnectAccount,
|
|
399
|
+
{ accountId, reason: "test" },
|
|
400
|
+
admin,
|
|
401
|
+
);
|
|
402
|
+
await supervisor.pollOnce();
|
|
403
|
+
|
|
404
|
+
expect(isWatching(accountId)).toBe(false);
|
|
405
|
+
} finally {
|
|
406
|
+
await supervisor.stop();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("start() is idempotent — second start keeps the watcher armed", async () => {
|
|
411
|
+
const admin = adminFor(4112);
|
|
412
|
+
const accountId = await connectSharedAccount(admin);
|
|
413
|
+
const supervisor = createSupervisor();
|
|
414
|
+
try {
|
|
415
|
+
await supervisor.start();
|
|
416
|
+
await waitFor(() => {
|
|
417
|
+
expect(isWatching(accountId)).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
await supervisor.start();
|
|
420
|
+
expect(isWatching(accountId)).toBe(true);
|
|
421
|
+
} finally {
|
|
422
|
+
await supervisor.stop();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
271
425
|
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Always-on unit coverage for imapInboundMailPlugin.verify/fetch/watch.
|
|
2
|
+
// Greenmail suites skip in CI when the container is down — this file mocks
|
|
3
|
+
// imapflow so the plugin body stays on the coverage badge without Docker.
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import {
|
|
9
|
+
type InboundMailContext,
|
|
10
|
+
isInboundAuthError,
|
|
11
|
+
isInboundCursorInvalidError,
|
|
12
|
+
isInboundTransientError,
|
|
13
|
+
type MailAccountRecord,
|
|
14
|
+
type RawInboundMessage,
|
|
15
|
+
} from "../../inbound-mail-foundation";
|
|
16
|
+
|
|
17
|
+
type FakeMsg = {
|
|
18
|
+
readonly uid: number;
|
|
19
|
+
readonly source: Buffer;
|
|
20
|
+
readonly internalDate: Date;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FakeState = {
|
|
24
|
+
connectError?: Error;
|
|
25
|
+
authFailed?: boolean;
|
|
26
|
+
uidValidity: string;
|
|
27
|
+
uidNext: number;
|
|
28
|
+
messages: FakeMsg[];
|
|
29
|
+
/** When set, search() returns this list instead of all message uids. */
|
|
30
|
+
searchUids?: number[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let state: FakeState;
|
|
34
|
+
let lastIdleClient: FakeImapFlow | undefined;
|
|
35
|
+
|
|
36
|
+
class FakeImapFlow extends EventEmitter {
|
|
37
|
+
mailbox: { uidValidity: bigint; uidNext: number } | false = false;
|
|
38
|
+
private idleReject: ((err: Error) => void) | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(_opts: unknown) {
|
|
41
|
+
super();
|
|
42
|
+
lastIdleClient = this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async connect(): Promise<void> {
|
|
46
|
+
if (state.connectError) {
|
|
47
|
+
const err = state.connectError as Error & { authenticationFailed?: boolean };
|
|
48
|
+
if (state.authFailed) err.authenticationFailed = true;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async logout(): Promise<void> {
|
|
54
|
+
this.idleReject?.(new Error("connection closed"));
|
|
55
|
+
this.idleReject = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getMailboxLock(_box: string): Promise<{ release: () => void }> {
|
|
59
|
+
this.mailbox = {
|
|
60
|
+
uidValidity: BigInt(state.uidValidity),
|
|
61
|
+
uidNext: state.uidNext,
|
|
62
|
+
};
|
|
63
|
+
return { release: () => {} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async mailboxOpen(_box: string): Promise<{ uidValidity: bigint; uidNext: number }> {
|
|
67
|
+
this.mailbox = {
|
|
68
|
+
uidValidity: BigInt(state.uidValidity),
|
|
69
|
+
uidNext: state.uidNext,
|
|
70
|
+
};
|
|
71
|
+
return this.mailbox;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async search(_query: unknown, _opts?: unknown): Promise<number[]> {
|
|
75
|
+
return state.searchUids ?? state.messages.map((m) => m.uid);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async *fetch(
|
|
79
|
+
range: string,
|
|
80
|
+
_opts?: unknown,
|
|
81
|
+
_uidOpts?: unknown,
|
|
82
|
+
): AsyncGenerator<{
|
|
83
|
+
uid: number;
|
|
84
|
+
source: Buffer;
|
|
85
|
+
internalDate: Date;
|
|
86
|
+
}> {
|
|
87
|
+
// Real imapflow is called with either "N,M,…" (explicit uid list) or "N:*".
|
|
88
|
+
const wanted = new Set<number>();
|
|
89
|
+
if (range.includes("*")) {
|
|
90
|
+
const start = Number(range.split(":")[0]);
|
|
91
|
+
for (const m of state.messages) {
|
|
92
|
+
if (m.uid >= start) wanted.add(m.uid);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
for (const part of range.split(",")) {
|
|
96
|
+
const n = Number(part.trim());
|
|
97
|
+
if (Number.isFinite(n)) wanted.add(n);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const m of state.messages) {
|
|
101
|
+
if (wanted.size > 0 && !wanted.has(m.uid)) continue;
|
|
102
|
+
yield { uid: m.uid, source: m.source, internalDate: m.internalDate };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async idle(): Promise<void> {
|
|
107
|
+
await new Promise<void>((_resolve, reject) => {
|
|
108
|
+
this.idleReject = reject;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
|
|
114
|
+
|
|
115
|
+
const { imapInboundMailPlugin } = await import("../feature");
|
|
116
|
+
|
|
117
|
+
function mime(subject: string, text: string): Buffer {
|
|
118
|
+
return Buffer.from(
|
|
119
|
+
[
|
|
120
|
+
"From: Sender <sender@example.com>",
|
|
121
|
+
"To: inbox@example.com",
|
|
122
|
+
`Subject: ${subject}`,
|
|
123
|
+
"Message-ID: <msg-1@example.com>",
|
|
124
|
+
"Date: Mon, 01 Jan 2024 12:00:00 +0000",
|
|
125
|
+
"MIME-Version: 1.0",
|
|
126
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
127
|
+
"",
|
|
128
|
+
text,
|
|
129
|
+
].join("\r\n"),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const account: MailAccountRecord = {
|
|
134
|
+
id: "00000000-0000-4000-8000-000000001a01",
|
|
135
|
+
tenantId: "00000000-0000-4000-8000-000000004242",
|
|
136
|
+
provider: "imap",
|
|
137
|
+
authMethod: "password",
|
|
138
|
+
ownerUserId: null,
|
|
139
|
+
address: "inbox@example.com",
|
|
140
|
+
displayName: "Mock",
|
|
141
|
+
status: "active",
|
|
142
|
+
watchState: "idle",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
function ctxWithDoc(doc: string | null): InboundMailContext {
|
|
146
|
+
return {
|
|
147
|
+
_userId: "imap-plugin-mocked",
|
|
148
|
+
secrets: {
|
|
149
|
+
get: async () => (doc === null ? null : createSecret(doc)),
|
|
150
|
+
has: async () => doc !== null,
|
|
151
|
+
set: async () => {},
|
|
152
|
+
delete: async () => {},
|
|
153
|
+
} as unknown as import("@cosmicdrift/kumiko-framework/secrets").SecretsContext, // @cast-boundary test-double
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const goodDoc = JSON.stringify({
|
|
158
|
+
host: "imap.example.com",
|
|
159
|
+
port: 993,
|
|
160
|
+
secure: true,
|
|
161
|
+
user: "u@example.com",
|
|
162
|
+
password: "pw",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
lastIdleClient = undefined;
|
|
167
|
+
state = {
|
|
168
|
+
uidValidity: "17",
|
|
169
|
+
uidNext: 3,
|
|
170
|
+
messages: [
|
|
171
|
+
{
|
|
172
|
+
uid: 1,
|
|
173
|
+
source: mime("one", "hello one"),
|
|
174
|
+
internalDate: new Date("2024-01-01T12:00:00Z"),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
uid: 2,
|
|
178
|
+
source: mime("two", "hello two"),
|
|
179
|
+
internalDate: new Date("2024-01-02T12:00:00Z"),
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
186
|
+
test("verify: connect+logout happy path", async () => {
|
|
187
|
+
await expect(
|
|
188
|
+
imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account),
|
|
189
|
+
).resolves.toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("verify: auth failure → InboundAuthError", async () => {
|
|
193
|
+
state.connectError = new Error("Authentication failed");
|
|
194
|
+
state.authFailed = true;
|
|
195
|
+
try {
|
|
196
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account);
|
|
197
|
+
expect.unreachable("expected verify to throw");
|
|
198
|
+
} catch (e) {
|
|
199
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("verify: missing credential secret → InboundAuthError", async () => {
|
|
204
|
+
try {
|
|
205
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(null), account);
|
|
206
|
+
expect.unreachable("expected verify to throw");
|
|
207
|
+
} catch (e) {
|
|
208
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("verify: malformed credential JSON → InboundAuthError", async () => {
|
|
213
|
+
try {
|
|
214
|
+
await imapInboundMailPlugin.verify(ctxWithDoc("not-json"), account);
|
|
215
|
+
expect.unreachable("expected verify to throw");
|
|
216
|
+
} catch (e) {
|
|
217
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("fetch: backfill (null cursor) returns messages + nextCursor", async () => {
|
|
222
|
+
const result = await imapInboundMailPlugin.fetch(ctxWithDoc(goodDoc), account, null, {
|
|
223
|
+
backfillWindowDays: 7,
|
|
224
|
+
maxMessages: 50,
|
|
225
|
+
});
|
|
226
|
+
expect(result.messages).toHaveLength(2);
|
|
227
|
+
expect(result.messages[0]?.subject).toBe("one");
|
|
228
|
+
expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 2 });
|
|
229
|
+
expect(result.hasMore).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("fetch: incremental cursor filters uid <= lastUid + hasMore via maxMessages", async () => {
|
|
233
|
+
state.searchUids = [1, 2, 3, 4];
|
|
234
|
+
state.messages = [
|
|
235
|
+
{
|
|
236
|
+
uid: 2,
|
|
237
|
+
source: mime("two", "x"),
|
|
238
|
+
internalDate: new Date("2024-01-02T12:00:00Z"),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
uid: 3,
|
|
242
|
+
source: mime("three", "y"),
|
|
243
|
+
internalDate: new Date("2024-01-03T12:00:00Z"),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
uid: 4,
|
|
247
|
+
source: mime("four", "z"),
|
|
248
|
+
internalDate: new Date("2024-01-04T12:00:00Z"),
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
const result = await imapInboundMailPlugin.fetch(
|
|
252
|
+
ctxWithDoc(goodDoc),
|
|
253
|
+
account,
|
|
254
|
+
{ uidValidity: "17", lastUid: 1 },
|
|
255
|
+
{ backfillWindowDays: 7, maxMessages: 2 },
|
|
256
|
+
);
|
|
257
|
+
// search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore
|
|
258
|
+
expect(result.messages.length).toBeLessThanOrEqual(2);
|
|
259
|
+
expect(result.hasMore).toBe(true);
|
|
260
|
+
expect(result.nextCursor["uidValidity"]).toBe("17");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("fetch: UIDVALIDITY change → InboundCursorInvalidError", async () => {
|
|
264
|
+
try {
|
|
265
|
+
await imapInboundMailPlugin.fetch(
|
|
266
|
+
ctxWithDoc(goodDoc),
|
|
267
|
+
account,
|
|
268
|
+
{ uidValidity: "16", lastUid: 1 },
|
|
269
|
+
{ backfillWindowDays: 7, maxMessages: 50 },
|
|
270
|
+
);
|
|
271
|
+
expect.unreachable("expected fetch to throw");
|
|
272
|
+
} catch (e) {
|
|
273
|
+
expect(isInboundCursorInvalidError(e)).toBe(true);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("fetch: network error → InboundTransientError", async () => {
|
|
278
|
+
state.connectError = new Error("ECONNREFUSED");
|
|
279
|
+
try {
|
|
280
|
+
await imapInboundMailPlugin.fetch(ctxWithDoc(goodDoc), account, null, {
|
|
281
|
+
backfillWindowDays: 7,
|
|
282
|
+
maxMessages: 50,
|
|
283
|
+
});
|
|
284
|
+
expect.unreachable("expected fetch to throw");
|
|
285
|
+
} catch (e) {
|
|
286
|
+
expect(isInboundTransientError(e)).toBe(true);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("watch: exists → onMessages, stop() tears down", async () => {
|
|
291
|
+
expect(imapInboundMailPlugin.watch).toBeDefined();
|
|
292
|
+
const received: RawInboundMessage[][] = [];
|
|
293
|
+
const stop = await imapInboundMailPlugin.watch!(ctxWithDoc(goodDoc), account, {
|
|
294
|
+
onMessages: async (msgs) => {
|
|
295
|
+
received.push([...msgs]);
|
|
296
|
+
},
|
|
297
|
+
onError: () => {},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// New mail above uidNext-1 (uidNext=3 → nextUid starts at 3)
|
|
301
|
+
state.messages = [
|
|
302
|
+
{
|
|
303
|
+
uid: 3,
|
|
304
|
+
source: mime("pushed", "idle body"),
|
|
305
|
+
internalDate: new Date("2024-01-05T12:00:00Z"),
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
lastIdleClient?.emit("exists");
|
|
309
|
+
|
|
310
|
+
await Bun.sleep(50);
|
|
311
|
+
expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
|
|
312
|
+
|
|
313
|
+
await stop();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("watch: client error → onError once", async () => {
|
|
317
|
+
let errors = 0;
|
|
318
|
+
const stop = await imapInboundMailPlugin.watch!(ctxWithDoc(goodDoc), account, {
|
|
319
|
+
onMessages: async () => {},
|
|
320
|
+
onError: () => {
|
|
321
|
+
errors += 1;
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
lastIdleClient?.emit("error", new Error("socket hang up"));
|
|
325
|
+
await Bun.sleep(20);
|
|
326
|
+
lastIdleClient?.emit("error", new Error("second"));
|
|
327
|
+
await Bun.sleep(20);
|
|
328
|
+
expect(errors).toBe(1);
|
|
329
|
+
await stop().catch(() => {});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -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: true });
|
|
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.
|
package/src/sessions/feature.ts
CHANGED
|
@@ -42,6 +42,9 @@ export type SessionsFeatureOptions = {
|
|
|
42
42
|
// only to supply a custom revoker — an explicit value wins over the
|
|
43
43
|
// runtime binding.
|
|
44
44
|
readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
|
|
45
|
+
// Session JWT / store_user_sessions TTL. Was previously auth.sessions.expiresInMs
|
|
46
|
+
// (#1372) — lives on the sessions feature now that sessionStore is the wiring.
|
|
47
|
+
readonly expiresInMs?: number;
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
@@ -120,12 +123,16 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
120
123
|
// via resolveSessionStore().
|
|
121
124
|
r.useExtension(EXT_SESSION_STORE, "default", {
|
|
122
125
|
build: (deps): SessionStore => {
|
|
123
|
-
const callbacks = createSessionCallbacks({
|
|
126
|
+
const callbacks = createSessionCallbacks({
|
|
127
|
+
db: deps.db,
|
|
128
|
+
...(options?.expiresInMs !== undefined && { expiresInMs: options.expiresInMs }),
|
|
129
|
+
});
|
|
124
130
|
return {
|
|
125
131
|
creator: callbacks.sessionCreator,
|
|
126
132
|
revoker: callbacks.sessionRevoker,
|
|
127
133
|
checker: callbacks.sessionChecker,
|
|
128
134
|
massRevoker: callbacks.sessionMassRevoker,
|
|
135
|
+
revokeAllOthers: callbacks.sessionRevokeAllOthers,
|
|
129
136
|
};
|
|
130
137
|
},
|
|
131
138
|
} satisfies SessionStoreProvider);
|