@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1
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 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +22 -7
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
encryptPiiFieldValues,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
28
28
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
29
30
|
import { Temporal } from "temporal-polyfill";
|
|
30
31
|
import { z } from "zod";
|
|
31
32
|
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
|
|
@@ -99,6 +100,7 @@ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: strin
|
|
|
99
100
|
return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
|
|
102
104
|
export const ingestMessageHandler: WriteHandlerDef = {
|
|
103
105
|
name: "ingest-message",
|
|
104
106
|
schema: ingestMessageSchema,
|
|
@@ -221,46 +223,33 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// ---------------------------------------------------------------
|
|
224
|
-
// 5. Thread-Rollup.
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
226
|
+
// 5. Thread-Rollup. New snapshot (live COUNT + max(lastMessageAt)) via
|
|
227
|
+
// ctx.tryAppendEvent in a bounded retry loop — the apply stays a
|
|
228
|
+
// dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
|
|
229
|
+
// timestamp), so it stays plaintext-comparable; subject is
|
|
230
|
+
// encrypted once up front (constant across retries, doesn't
|
|
231
|
+
// depend on the thread's prior state).
|
|
230
232
|
//
|
|
231
|
-
// messageCount
|
|
232
|
-
// threadEvents
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
233
|
+
// messageCount deliberately does NOT come from a previously loaded
|
|
234
|
+
// threadEvents snapshot (previousCount+1): two concurrent ingests
|
|
235
|
+
// on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
|
|
236
|
+
// or two different messages of the same thread arriving at once)
|
|
237
|
+
// can both read the same stream version before either commits.
|
|
238
|
+
// tryAppendEvent's fresh version-read at append time then makes
|
|
239
|
+
// the loser's append fail with a VersionConflictError instead of
|
|
240
|
+
// silently succeeding — so we retry: reload the thread stream,
|
|
241
|
+
// recompute the live row-count and lastMessageAt, and append
|
|
242
|
+
// again. Unlike step 4, a lost race here must NOT be treated as a
|
|
243
|
+
// no-op duplicate — skipping would leave this message's
|
|
244
|
+
// contribution to messageCount/lastMessageAt out of the thread
|
|
245
|
+
// forever. Exhausting all attempts throws, rolling back the whole
|
|
246
|
+
// TX (including the step-4 message append) for a clean re-ingest
|
|
247
|
+
// on the next poll — never return success with a stale snapshot.
|
|
240
248
|
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// Drift theoretisch weiterhin auftreten). Was der COUNT aendert: er
|
|
245
|
-
// macht den Fehler SELBSTKORRIGIEREND statt kumulativ — jeder
|
|
246
|
-
// nachfolgende ingest liest den echten Row-Count neu, statt einen
|
|
247
|
-
// frueher falsch geschriebenen previousCount fortzuschreiben. Eine
|
|
248
|
-
// echte Race-freie Loesung braucht ein atomares Read+Append (z.B.
|
|
249
|
-
// SELECT ... FOR UPDATE oder eine Advisory-Lock auf threadAggId) —
|
|
250
|
-
// das ist bewusst nicht Teil dieses Fixes (siehe PR-Beschreibung).
|
|
249
|
+
// A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
|
|
250
|
+
// advisory lock on threadAggId) is deliberately not part of this
|
|
251
|
+
// fix — two writers (watch + poll) make bounded retry sufficient.
|
|
251
252
|
// ---------------------------------------------------------------
|
|
252
|
-
const threadEvents = await ctx.loadAggregate(threadAggId);
|
|
253
|
-
const lastThreadEvent = threadEvents[threadEvents.length - 1];
|
|
254
|
-
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
255
|
-
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
256
|
-
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
257
|
-
const newLastAt =
|
|
258
|
-
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
259
|
-
const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
|
|
260
|
-
tenantId,
|
|
261
|
-
threadKey,
|
|
262
|
-
});
|
|
263
|
-
|
|
264
253
|
const threadPlainPii = { tenantId, subject: payload.subject };
|
|
265
254
|
const encryptedThreadFields = piiKms
|
|
266
255
|
? await encryptPiiFieldValues(
|
|
@@ -275,19 +264,40 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
275
264
|
)
|
|
276
265
|
: threadPlainPii;
|
|
277
266
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
267
|
+
let threadAppendOk = false;
|
|
268
|
+
for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
|
|
269
|
+
const threadEvents = await ctx.loadAggregate(threadAggId);
|
|
270
|
+
const lastThreadEvent = threadEvents[threadEvents.length - 1];
|
|
271
|
+
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
272
|
+
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
273
|
+
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
274
|
+
const newLastAt =
|
|
275
|
+
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
276
|
+
const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
|
|
277
|
+
tenantId,
|
|
278
|
+
threadKey,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const threadEventPayload: MailThreadEventPayload = {
|
|
282
|
+
threadKey,
|
|
283
|
+
subject: encryptedThreadFields["subject"] as string,
|
|
284
|
+
lastMessageAtIso: newLastAt,
|
|
285
|
+
messageCount,
|
|
286
|
+
};
|
|
287
|
+
const threadAppend = await ctx.tryAppendEvent({
|
|
288
|
+
aggregateId: threadAggId,
|
|
289
|
+
aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
|
|
290
|
+
type: MAIL_THREAD_UPDATED_EVENT_QN,
|
|
291
|
+
payload: threadEventPayload,
|
|
292
|
+
headers: messageHeaders,
|
|
293
|
+
});
|
|
294
|
+
threadAppendOk = threadAppend.ok;
|
|
295
|
+
}
|
|
296
|
+
if (!threadAppendOk) {
|
|
297
|
+
throw new InternalError({
|
|
298
|
+
message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
291
301
|
|
|
292
302
|
// ---------------------------------------------------------------
|
|
293
303
|
// 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type MailAccountRecord,
|
|
26
26
|
type RawInboundMessage,
|
|
27
27
|
} from "../../inbound-mail-foundation";
|
|
28
|
+
import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
|
|
28
29
|
import { imapInboundMailPlugin } from "../feature";
|
|
29
30
|
|
|
30
31
|
const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
|
|
@@ -192,3 +193,14 @@ describe("imap-live — echter Server (greenmail)", () => {
|
|
|
192
193
|
20_000,
|
|
193
194
|
);
|
|
194
195
|
});
|
|
196
|
+
|
|
197
|
+
describeInboundMailProviderContract(
|
|
198
|
+
"imap (greenmail)",
|
|
199
|
+
() => ({
|
|
200
|
+
plugin: imapInboundMailPlugin,
|
|
201
|
+
ctx: ctxWithCredentials(credentialDoc(PASSWORD)),
|
|
202
|
+
account,
|
|
203
|
+
seed: (subject) => sendTestMail(subject, "contract seed"),
|
|
204
|
+
}),
|
|
205
|
+
{ skip: !available },
|
|
206
|
+
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
|
|
3
|
+
import {
|
|
4
|
+
inboundProviderInMemoryFeature,
|
|
5
|
+
inMemoryInboundMailPlugin,
|
|
6
|
+
seedInboundMessage,
|
|
7
|
+
} from "../feature";
|
|
8
|
+
|
|
9
|
+
describe("inboundProviderInMemoryFeature — shape", () => {
|
|
10
|
+
test("has the expected name + requirement", () => {
|
|
11
|
+
expect(inboundProviderInMemoryFeature.name).toBe("inbound-provider-inmemory");
|
|
12
|
+
expect(inboundProviderInMemoryFeature.requires).toContain("inbound-mail-foundation");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describeInboundMailProviderContract("inmemory", () => {
|
|
17
|
+
const accountId = crypto.randomUUID();
|
|
18
|
+
return {
|
|
19
|
+
plugin: inMemoryInboundMailPlugin,
|
|
20
|
+
ctx: {},
|
|
21
|
+
account: {
|
|
22
|
+
id: accountId,
|
|
23
|
+
tenantId: "00000000-0000-4000-8000-000000004242",
|
|
24
|
+
provider: "inmemory",
|
|
25
|
+
authMethod: "password",
|
|
26
|
+
ownerUserId: null,
|
|
27
|
+
address: "contract@example.com",
|
|
28
|
+
displayName: "Contract-Test",
|
|
29
|
+
status: "active",
|
|
30
|
+
watchState: "idle",
|
|
31
|
+
},
|
|
32
|
+
seed: (subject) =>
|
|
33
|
+
seedInboundMessage(accountId, {
|
|
34
|
+
providerMessageId: crypto.randomUUID(),
|
|
35
|
+
messageIdHeader: null,
|
|
36
|
+
providerThreadId: null,
|
|
37
|
+
references: [],
|
|
38
|
+
from: "sender@example.com",
|
|
39
|
+
to: ["contract@example.com"],
|
|
40
|
+
cc: [],
|
|
41
|
+
subject,
|
|
42
|
+
snippet: subject,
|
|
43
|
+
receivedAtIso: "2026-07-01T10:00:00Z",
|
|
44
|
+
rawMime: null,
|
|
45
|
+
scope: "inbox",
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
@@ -102,7 +102,7 @@ function offsetOf(cursor: SyncCursorPayload | null): number {
|
|
|
102
102
|
return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : 0;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const
|
|
105
|
+
export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
|
|
106
106
|
verify: async (_ctx, account: MailAccountRecord) => {
|
|
107
107
|
const state = stateFor(account.id);
|
|
108
108
|
if (state.nextVerifyError) {
|
|
@@ -150,5 +150,5 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
|
|
|
150
150
|
recommended: false,
|
|
151
151
|
});
|
|
152
152
|
r.requires("inbound-mail-foundation");
|
|
153
|
-
r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY,
|
|
153
|
+
r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
|
|
154
154
|
});
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
createEventStoreExecutor,
|
|
13
13
|
createTenantDb,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createEntity,
|
|
17
|
+
createTextField,
|
|
18
|
+
defineFeature,
|
|
19
|
+
type JobContext,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
21
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
22
|
import {
|
|
18
23
|
setupTestStack,
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
TestUsers,
|
|
21
26
|
unsafeCreateEntityTable,
|
|
22
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
29
|
import { createJobsFeature } from "../feature";
|
|
24
30
|
import { reindexEntityJob } from "../handlers/reindex-entity.job";
|
|
25
31
|
|
|
@@ -43,6 +49,16 @@ const widgetFeature = defineFeature("reindex-job-test", (r) => {
|
|
|
43
49
|
let stack: TestStack;
|
|
44
50
|
const admin = TestUsers.admin;
|
|
45
51
|
|
|
52
|
+
const noopLogger: JobContext["log"] = {
|
|
53
|
+
info() {},
|
|
54
|
+
warn() {},
|
|
55
|
+
error() {},
|
|
56
|
+
debug() {},
|
|
57
|
+
child() {
|
|
58
|
+
return noopLogger;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
46
62
|
beforeAll(async () => {
|
|
47
63
|
stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
|
|
48
64
|
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
@@ -87,6 +103,42 @@ describe("reindexEntityJob", () => {
|
|
|
87
103
|
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
88
104
|
});
|
|
89
105
|
|
|
106
|
+
test("the registered job handler resolves a systemUser-scoped fan-out run and actually indexes (not reindexEntityJob called directly)", async () => {
|
|
107
|
+
// The two direct-call tests above bypass jobRunner.dispatch()'s
|
|
108
|
+
// ctx-resolution/guard chain entirely — a bug there (e.g. a systemUser
|
|
109
|
+
// that isn't wired through) would be invisible to them. Invoke the
|
|
110
|
+
// ACTUAL registered handler, same as retention-cleanup's handler test.
|
|
111
|
+
const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
|
|
112
|
+
const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
|
|
113
|
+
const created = await executor.create({ name: "Registry-Resolved Widget" }, admin, tenantDb);
|
|
114
|
+
if (!created.isSuccess) throw new Error("seed failed");
|
|
115
|
+
|
|
116
|
+
const preResults = await stack.search.search(admin.tenantId, "registry-resolved", {
|
|
117
|
+
filterType: "widget",
|
|
118
|
+
});
|
|
119
|
+
expect(preResults).toHaveLength(0);
|
|
120
|
+
|
|
121
|
+
const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
|
|
122
|
+
expect(job).toBeDefined();
|
|
123
|
+
if (!job) return;
|
|
124
|
+
|
|
125
|
+
const ctx: JobContext = {
|
|
126
|
+
db: stack.db,
|
|
127
|
+
registry: stack.registry,
|
|
128
|
+
searchAdapter: stack.search,
|
|
129
|
+
systemUser: admin,
|
|
130
|
+
log: noopLogger,
|
|
131
|
+
triggeredBy: null,
|
|
132
|
+
...bridgeStub(),
|
|
133
|
+
};
|
|
134
|
+
await job.handler({ entity: "widget" }, ctx);
|
|
135
|
+
|
|
136
|
+
const postResults = await stack.search.search(admin.tenantId, "registry-resolved", {
|
|
137
|
+
filterType: "widget",
|
|
138
|
+
});
|
|
139
|
+
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
90
142
|
test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
|
|
91
143
|
await expect(
|
|
92
144
|
reindexEntityJob(
|
|
@@ -21,7 +21,7 @@ export const detailQuery = defineQueryHandler({
|
|
|
21
21
|
|
|
22
22
|
// payload is stored encrypted under the triggering user's DEK (#799).
|
|
23
23
|
if (typeof row["payload"] === "string") {
|
|
24
|
-
row["payload"] = await decryptStoredPii(row["payload"], "job-run-detail");
|
|
24
|
+
row["payload"] = await decryptStoredPii(row["payload"], "payload", "job-run-detail");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const logs = await selectMany(
|
|
@@ -6,7 +6,7 @@ import { jobRunsTable } from "../job-run-table";
|
|
|
6
6
|
|
|
7
7
|
async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
|
|
8
8
|
if (typeof row["payload"] !== "string") return row;
|
|
9
|
-
return { ...row, payload: await decryptStoredPii(row["payload"], "job-runs") };
|
|
9
|
+
return { ...row, payload: await decryptStoredPii(row["payload"], "payload", "job-runs") };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const listQuery = defineQueryHandler({
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EmailMessage } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
3
|
+
import type { MailTransportContext, MailTransportPlugin } from "../feature";
|
|
4
|
+
|
|
5
|
+
export type MailTransportContractFixture = {
|
|
6
|
+
readonly plugin: MailTransportPlugin;
|
|
7
|
+
readonly ctx: MailTransportContext;
|
|
8
|
+
readonly tenantId: string;
|
|
9
|
+
// Local providers (inmemory) expose readBack to assert real delivery;
|
|
10
|
+
// remote providers (smtp) omit it — send() needs a live server.
|
|
11
|
+
readonly readBack?: (tenantId: string) => readonly EmailMessage[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function describeMailTransportContract(
|
|
15
|
+
name: string,
|
|
16
|
+
factory: () => MailTransportContractFixture | Promise<MailTransportContractFixture>,
|
|
17
|
+
): void {
|
|
18
|
+
describe(`${name} — MailTransportPlugin contract`, () => {
|
|
19
|
+
let fixture: MailTransportContractFixture;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
fixture = await factory();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("build resolves to an EmailTransport with a send function", async () => {
|
|
26
|
+
const transport = await fixture.plugin.build(fixture.ctx, fixture.tenantId);
|
|
27
|
+
expect(typeof transport.send).toBe("function");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("send delivers the message — verified via readBack", async () => {
|
|
31
|
+
if (!fixture.readBack) return;
|
|
32
|
+
const message: EmailMessage = {
|
|
33
|
+
to: "contract-recipient@example.test",
|
|
34
|
+
subject: "contract-test-subject",
|
|
35
|
+
html: "<p>contract-test-body</p>",
|
|
36
|
+
};
|
|
37
|
+
const transport = await fixture.plugin.build(fixture.ctx, fixture.tenantId);
|
|
38
|
+
await transport.send(message);
|
|
39
|
+
expect(fixture.readBack(fixture.tenantId)).toContainEqual(message);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
// feature.ts contract tests for mail-transport-inmemory.
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { isMailTransportPlugin, type MailTransportPlugin } from "../../mail-foundation";
|
|
5
|
+
import { describeMailTransportContract } from "../../mail-foundation/__tests__/mail-transport-contract";
|
|
4
6
|
import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../feature";
|
|
5
7
|
|
|
8
|
+
function registeredPlugin(): MailTransportPlugin {
|
|
9
|
+
const usage = mailTransportInMemoryFeature.extensionUsages.find(
|
|
10
|
+
(u) => u.extensionName === "mailTransport" && u.entityName === "inmemory",
|
|
11
|
+
);
|
|
12
|
+
if (!usage || !isMailTransportPlugin(usage.options)) {
|
|
13
|
+
throw new Error("mail-transport-inmemory: plugin not registered under 'inmemory'");
|
|
14
|
+
}
|
|
15
|
+
return usage.options;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describeMailTransportContract("mail-transport-inmemory", () => ({
|
|
19
|
+
plugin: registeredPlugin(),
|
|
20
|
+
ctx: {},
|
|
21
|
+
tenantId: "contract-test-tenant",
|
|
22
|
+
readBack: getInbox,
|
|
23
|
+
}));
|
|
24
|
+
|
|
6
25
|
describe("mailTransportInMemoryFeature — shape", () => {
|
|
7
26
|
test("has the expected name", () => {
|
|
8
27
|
expect(mailTransportInMemoryFeature.name).toBe("mail-transport-inmemory");
|
|
@@ -4,8 +4,68 @@
|
|
|
4
4
|
// build-fn presence).
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { ConfigAccessor } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
9
|
+
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
10
|
+
import { isMailTransportPlugin, type MailTransportPlugin } from "../../mail-foundation";
|
|
11
|
+
import { describeMailTransportContract } from "../../mail-foundation/__tests__/mail-transport-contract";
|
|
7
12
|
import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../feature";
|
|
8
13
|
|
|
14
|
+
function registeredPlugin(): MailTransportPlugin {
|
|
15
|
+
const usage = mailTransportSmtpFeature.extensionUsages.find(
|
|
16
|
+
(u) => u.extensionName === "mailTransport" && u.entityName === "smtp",
|
|
17
|
+
);
|
|
18
|
+
if (!usage || !isMailTransportPlugin(usage.options)) {
|
|
19
|
+
throw new Error("mail-transport-smtp: plugin not registered under 'smtp'");
|
|
20
|
+
}
|
|
21
|
+
return usage.options;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fakeConfig(
|
|
25
|
+
overrides: Partial<
|
|
26
|
+
Record<"host" | "port" | "secure" | "from" | "authUser", string | number | boolean>
|
|
27
|
+
> = {},
|
|
28
|
+
): ConfigAccessor {
|
|
29
|
+
const keys = mailTransportSmtpFeature.exports.configKeys;
|
|
30
|
+
const values = new Map<string, string | number | boolean>([
|
|
31
|
+
[keys.host.name, overrides.host ?? "smtp.contract-test.invalid"],
|
|
32
|
+
[keys.port.name, overrides.port ?? 587],
|
|
33
|
+
[keys.secure.name, overrides.secure ?? false],
|
|
34
|
+
[keys.from.name, overrides.from ?? "noreply@contract-test.invalid"],
|
|
35
|
+
[keys.authUser.name, overrides.authUser ?? "contract-test-user"],
|
|
36
|
+
]);
|
|
37
|
+
return (async (handle: string | { readonly name: string }) => {
|
|
38
|
+
const key = typeof handle === "string" ? handle : handle.name;
|
|
39
|
+
return values.get(key);
|
|
40
|
+
}) as ConfigAccessor;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fakeSecrets(): SecretsContext {
|
|
44
|
+
return {
|
|
45
|
+
get: async () => createSecret("contract-test-password"),
|
|
46
|
+
has: async () => true,
|
|
47
|
+
set: async () => {},
|
|
48
|
+
delete: async () => true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describeMailTransportContract("mail-transport-smtp", () => ({
|
|
53
|
+
plugin: registeredPlugin(),
|
|
54
|
+
ctx: { config: fakeConfig(), secrets: fakeSecrets(), _userId: "contract-test-user" },
|
|
55
|
+
tenantId: "contract-test-tenant",
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
describe("mailTransportSmtpFeature — build error path", () => {
|
|
59
|
+
test("build rejects when a required config-key (host) is unset", async () => {
|
|
60
|
+
const ctx = {
|
|
61
|
+
config: fakeConfig({ host: "" }),
|
|
62
|
+
secrets: fakeSecrets(),
|
|
63
|
+
_userId: "contract-test-user",
|
|
64
|
+
};
|
|
65
|
+
await expect(registeredPlugin().build(ctx, "contract-test-tenant")).rejects.toThrow(/host/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
9
69
|
describe("mailTransportSmtpFeature — shape", () => {
|
|
10
70
|
test("has the expected name", () => {
|
|
11
71
|
expect(mailTransportSmtpFeature.name).toBe("mail-transport-smtp");
|
|
@@ -21,7 +21,11 @@ export function createBrandingQuery(opts: { readonly allowCustomCss: boolean })
|
|
|
21
21
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
22
22
|
handler: async (_query, ctx) => {
|
|
23
23
|
const base = await readBranding(ctx.config);
|
|
24
|
-
if (
|
|
24
|
+
if (
|
|
25
|
+
!opts.allowCustomCss ||
|
|
26
|
+
!ctx.config ||
|
|
27
|
+
!(await ctx.hasFeature(MANAGED_PAGES_CSS_FEATURE))
|
|
28
|
+
) {
|
|
25
29
|
return base;
|
|
26
30
|
}
|
|
27
31
|
return { ...base, customCss: await readCustomCss(ctx.config) };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import { EXT_TOKEN_VERIFIER } from "../../auth-foundation";
|
|
2
4
|
import { createPersonalAccessTokensFeature } from "../feature";
|
|
3
5
|
|
|
4
6
|
describe("createPersonalAccessTokensFeature toggleable-option (tier-gating)", () => {
|
|
@@ -22,3 +24,19 @@ describe("createPersonalAccessTokensFeature toggleable-option (tier-gating)", ()
|
|
|
22
24
|
expect(feature.toggleableDefault).toBe(true);
|
|
23
25
|
});
|
|
24
26
|
});
|
|
27
|
+
|
|
28
|
+
describe("createPersonalAccessTokensFeature — tokenVerifier registration (#1369)", () => {
|
|
29
|
+
test('registers via r.useExtension(EXT_TOKEN_VERIFIER, "pat", ...) instead of a patResolver field', () => {
|
|
30
|
+
const feature = createPersonalAccessTokensFeature({ scopes: {} });
|
|
31
|
+
expect(feature.extensionUsages).toHaveLength(1);
|
|
32
|
+
const [usage] = feature.extensionUsages;
|
|
33
|
+
expect(usage?.extensionName).toBe(EXT_TOKEN_VERIFIER);
|
|
34
|
+
expect(usage?.entityName).toBe("pat");
|
|
35
|
+
expect(usage?.options).toMatchObject({ shape: { kind: "prefix", prefix: PAT_TOKEN_PREFIX } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("requires auth-foundation (owner of EXT_TOKEN_VERIFIER)", () => {
|
|
39
|
+
const feature = createPersonalAccessTokensFeature({ scopes: {} });
|
|
40
|
+
expect(feature.requires).toContain("auth-foundation");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
createInMemoryLoginRateLimiter,
|
|
5
|
-
type PatResolver,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
7
4
|
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
5
|
import {
|
|
9
6
|
configureBlindIndexKey,
|
|
@@ -25,6 +22,7 @@ import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framew
|
|
|
25
22
|
import { Temporal } from "temporal-polyfill";
|
|
26
23
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
27
24
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
25
|
+
import { authFoundationFeature, resolveTokenVerifier } from "../../auth-foundation";
|
|
28
26
|
import { createConfigFeature } from "../../config";
|
|
29
27
|
import { createConfigResolver } from "../../config/resolver";
|
|
30
28
|
import { configValuesTable } from "../../config/table";
|
|
@@ -39,7 +37,6 @@ import { createUserFeature } from "../../user/feature";
|
|
|
39
37
|
import { userEntity } from "../../user/schema/user";
|
|
40
38
|
import { PatHandlers, PatQueries } from "../constants";
|
|
41
39
|
import { createPersonalAccessTokensFeature } from "../feature";
|
|
42
|
-
import { createPatResolver } from "../resolver";
|
|
43
40
|
import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
|
|
44
41
|
import type { PatScopeConfig } from "../scopes";
|
|
45
42
|
|
|
@@ -51,7 +48,6 @@ import type { PatScopeConfig } from "../scopes";
|
|
|
51
48
|
|
|
52
49
|
let stack: TestStack;
|
|
53
50
|
let h: ReturnType<typeof makeSessionHelpers>;
|
|
54
|
-
let patResolver: PatResolver | undefined;
|
|
55
51
|
|
|
56
52
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
57
53
|
const TENANT: TenantId = testTenantId(1);
|
|
@@ -91,21 +87,22 @@ beforeAll(async () => {
|
|
|
91
87
|
createAuthEmailPasswordFeature(),
|
|
92
88
|
createSessionsFeature(),
|
|
93
89
|
createPersonalAccessTokensFeature({ scopes: SCOPES }),
|
|
90
|
+
authFoundationFeature,
|
|
94
91
|
],
|
|
95
92
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
96
93
|
authConfig: {
|
|
97
94
|
membershipQuery: "tenant:query:memberships",
|
|
98
95
|
loginHandler: AuthHandlers.login,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
// PAT registers itself as a tokenVerifier provider (auth-foundation)
|
|
97
|
+
// instead of the app wiring a dedicated patResolver — resolved
|
|
98
|
+
// generically here, same as run-prod-app/run-dev-app do.
|
|
99
|
+
tokenVerifier: (raw: string) =>
|
|
100
|
+
resolveTokenVerifier({ db: stack.db, registry: stack.registry }, raw),
|
|
103
101
|
// Low per-token cap so the rate-limit test can exhaust it. Other tests do
|
|
104
102
|
// ≤2 requests per (distinct) token, so this ceiling never trips them.
|
|
105
103
|
patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
|
|
106
104
|
},
|
|
107
105
|
});
|
|
108
|
-
patResolver = createPatResolver({ db: stack.db, scopes: SCOPES });
|
|
109
106
|
h = makeSessionHelpers(stack, TENANT);
|
|
110
107
|
|
|
111
108
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthProviderPlugin,
|
|
3
|
+
EXT_TOKEN_VERIFIER,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
5
|
+
import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
1
6
|
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
7
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
8
|
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
|
|
@@ -6,6 +11,7 @@ import { createPatWrite } from "./handlers/create.write";
|
|
|
6
11
|
import { listPatQuery } from "./handlers/list.query";
|
|
7
12
|
import { revokePatWrite } from "./handlers/revoke.write";
|
|
8
13
|
import { PAT_FEATURE_I18N } from "./i18n";
|
|
14
|
+
import { createPatResolver } from "./resolver";
|
|
9
15
|
import { apiTokenEntity } from "./schema/api-token";
|
|
10
16
|
import type { PatScopeConfig } from "./scopes";
|
|
11
17
|
|
|
@@ -25,14 +31,13 @@ export type PersonalAccessTokensOptions = {
|
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
export type PatFeatureExports = {
|
|
28
|
-
readonly scopes: PatScopeConfig;
|
|
29
34
|
readonly rateLimit: PatRateLimit;
|
|
30
35
|
};
|
|
31
36
|
|
|
32
37
|
// Personal Access Tokens — long-lived, revocable bearer credentials for the
|
|
33
38
|
// HTTP API. Like `sessions`, the hot-path resolver is NOT a handler: it runs on
|
|
34
39
|
// every PAT-authenticated request and does a direct-DB point-read (see
|
|
35
|
-
// createPatResolver /
|
|
40
|
+
// createPatResolver / the tokenVerifier extension-point registration below). The dispatcher-side handlers here
|
|
36
41
|
// only mint/list/revoke tokens and expose the scope catalog.
|
|
37
42
|
export function createPersonalAccessTokensFeature(
|
|
38
43
|
options: PersonalAccessTokensOptions,
|
|
@@ -40,7 +45,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
40
45
|
const { scopes } = options;
|
|
41
46
|
return defineFeature(PAT_FEATURE, (r) => {
|
|
42
47
|
r.describe(
|
|
43
|
-
"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) —
|
|
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.",
|
|
44
49
|
);
|
|
45
50
|
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
46
51
|
// Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
|
|
@@ -49,7 +54,16 @@ export function createPersonalAccessTokensFeature(
|
|
|
49
54
|
if (options.toggleable !== undefined) r.toggleable(options.toggleable);
|
|
50
55
|
// Resolver reads memberships + users on every PAT request to build live
|
|
51
56
|
// roles — make both boot-time deps so a mis-wiring fails validateBoot.
|
|
52
|
-
|
|
57
|
+
// auth-foundation owns EXT_TOKEN_VERIFIER, which the useExtension below
|
|
58
|
+
// registers against.
|
|
59
|
+
r.requires("user", "tenant", "auth-foundation");
|
|
60
|
+
// Self-registers as a bearer-auth provider instead of the app wiring a
|
|
61
|
+
// dedicated patResolver callback — the middleware finds it generically
|
|
62
|
+
// via resolveTokenVerifier(), shape-matched by the PAT token prefix.
|
|
63
|
+
r.useExtension(EXT_TOKEN_VERIFIER, "pat", {
|
|
64
|
+
shape: { kind: "prefix", prefix: PAT_TOKEN_PREFIX },
|
|
65
|
+
build: (deps) => createPatResolver({ db: deps.db, scopes }),
|
|
66
|
+
} satisfies AuthProviderPlugin);
|
|
53
67
|
// Direct-write store like store_user_sessions: create/revoke write it, the
|
|
54
68
|
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
55
69
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
@@ -78,12 +92,13 @@ export function createPersonalAccessTokensFeature(
|
|
|
78
92
|
});
|
|
79
93
|
r.translations({ keys: PAT_FEATURE_I18N });
|
|
80
94
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
95
|
+
// rateLimit flows into feature.exports so run-prod-app builds the
|
|
96
|
+
// limiter from the same declaration — single source of truth. `scopes`
|
|
97
|
+
// is closed over directly by the useExtension build() above and by
|
|
98
|
+
// available-scopes, so it doesn't need to round-trip through exports.
|
|
83
99
|
return {
|
|
84
100
|
handlers,
|
|
85
101
|
queries,
|
|
86
|
-
scopes,
|
|
87
102
|
rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
|
|
88
103
|
} satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
|
|
89
104
|
});
|