@cosmicdrift/kumiko-bundled-features 0.156.2 → 0.157.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-mfa/__tests__/verify.integration.test.ts +17 -19
- package/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/src/cap-counter/__tests__/entity-ddl.test.ts +15 -0
- package/src/cap-counter/entity.ts +2 -2
- package/src/delivery/feature.ts +2 -2
- package/src/delivery/tables.ts +2 -2
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/global-feature-state-table.ts +1 -1
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/inbound-mail-foundation/entities.ts +8 -4
- package/src/inbound-mail-foundation/feature.ts +3 -3
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +2 -2
- package/src/inbound-mail-foundation/retention-sweep.ts +1 -1
- package/src/inbound-mail-foundation/types.ts +1 -1
- package/src/inbound-mail-foundation/watch-supervisor.ts +1 -1
- package/src/jobs/__tests__/entity-ddl.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +107 -0
- package/src/jobs/feature.ts +13 -2
- package/src/jobs/handlers/reindex-entity.job.ts +49 -0
- package/src/jobs/job-run-table.ts +3 -3
- package/src/personal-access-tokens/feature.ts +3 -3
- package/src/personal-access-tokens/schema/api-token.ts +6 -4
- package/src/secrets/__tests__/rotate.integration.test.ts +23 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +4 -4
- package/src/sessions/db/queries/cleanup.ts +2 -2
- package/src/sessions/feature.ts +5 -5
- package/src/sessions/schema/user-session.ts +4 -2
- package/src/tenant-lifecycle/stages.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.157.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -116,11 +116,11 @@
|
|
|
116
116
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
117
117
|
},
|
|
118
118
|
"dependencies": {
|
|
119
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
120
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
119
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.157.0",
|
|
120
|
+
"@cosmicdrift/kumiko-framework": "0.157.0",
|
|
121
|
+
"@cosmicdrift/kumiko-headless": "0.157.0",
|
|
122
|
+
"@cosmicdrift/kumiko-renderer": "0.157.0",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer-web": "0.157.0",
|
|
124
124
|
"@mollie/api-client": "^4.5.0",
|
|
125
125
|
"imapflow": "^1.3.3",
|
|
126
126
|
"mailparser": "^3.9.8",
|
|
@@ -90,15 +90,26 @@ async function enableMfaFor(idSeed: number): Promise<{
|
|
|
90
90
|
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
91
91
|
user,
|
|
92
92
|
);
|
|
93
|
-
// verify.write.ts re-checks tenant membership the
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
93
|
+
// verify.write.ts re-checks tenant membership AND the user's own row
|
|
94
|
+
// (status gate) the way login.write.ts does — auth-mfa never writes a
|
|
95
|
+
// read_users row itself (enableStart/enableConfirm only touch user-mfa),
|
|
96
|
+
// so both need seeding here, not just per-test where a scenario cares.
|
|
97
97
|
await seedRow(stack.db, tenantMembershipsTable, {
|
|
98
98
|
userId: user.id,
|
|
99
99
|
tenantId: user.tenantId,
|
|
100
100
|
roles: JSON.stringify(["User"]),
|
|
101
101
|
});
|
|
102
|
+
await seedRow(stack.db, userTable, {
|
|
103
|
+
id: user.id,
|
|
104
|
+
tenantId: user.tenantId,
|
|
105
|
+
email: `user-${user.id}@example.com`,
|
|
106
|
+
passwordHash: "h",
|
|
107
|
+
displayName: `Test User ${idSeed}`,
|
|
108
|
+
locale: "de",
|
|
109
|
+
emailVerified: true,
|
|
110
|
+
roles: "[]",
|
|
111
|
+
status: USER_STATUS.Active,
|
|
112
|
+
});
|
|
102
113
|
return { user, secret, recoveryCodes: start.recoveryCodes };
|
|
103
114
|
}
|
|
104
115
|
|
|
@@ -247,21 +258,8 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
|
|
|
247
258
|
const { user, secret } = await enableMfaFor(6);
|
|
248
259
|
const challengeToken = challengeFor(user.id, user.tenantId);
|
|
249
260
|
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// a row to actually flip, then flip it to simulate a restriction landing
|
|
253
|
-
// between login and verify.
|
|
254
|
-
await seedRow(stack.db, userTable, {
|
|
255
|
-
id: user.id,
|
|
256
|
-
tenantId: user.tenantId,
|
|
257
|
-
email: `user-${user.id}@example.com`,
|
|
258
|
-
passwordHash: "h",
|
|
259
|
-
displayName: "Restricted User",
|
|
260
|
-
locale: "de",
|
|
261
|
-
emailVerified: true,
|
|
262
|
-
roles: "[]",
|
|
263
|
-
status: USER_STATUS.Active,
|
|
264
|
-
});
|
|
261
|
+
// enableMfaFor already seeded the user row Active — flip it to simulate
|
|
262
|
+
// a restriction landing between login and verify.
|
|
265
263
|
await asRawClient(stack.db).unsafe(
|
|
266
264
|
`UPDATE "${userTable.tableName}" SET status = $1 WHERE id = $2`,
|
|
267
265
|
[USER_STATUS.Restricted, user.id],
|
|
@@ -139,6 +139,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
139
139
|
// now. Deliberately NOT re-checking strictEmailVerification here:
|
|
140
140
|
// MfaVerifyOptions has no such flag, and email verification can't
|
|
141
141
|
// regress between login and verify within the 10-minute window.
|
|
142
|
+
if (!userRow) return invalidChallengeToken();
|
|
142
143
|
if (
|
|
143
144
|
userRow?.status === USER_STATUS.Restricted ||
|
|
144
145
|
userRow?.status === USER_STATUS.DeletionRequested ||
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { capCounterEntity } from "../entity";
|
|
4
|
+
|
|
5
|
+
function pgTypeOf(table: unknown, dbName: string): string | undefined {
|
|
6
|
+
const cols = (table as { columns?: ReadonlyArray<{ name: string; pgType?: string }> }).columns;
|
|
7
|
+
return cols?.find((c) => c.name === dbName)?.pgType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("capCounterEntity — DDL (#1205 regression)", () => {
|
|
11
|
+
test("value column is bigint, not double precision", () => {
|
|
12
|
+
const table = buildEntityTable("cap-counter", capCounterEntity);
|
|
13
|
+
expect(pgTypeOf(table, "value")).toBe("bigint");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createBigIntField,
|
|
2
3
|
createEntity,
|
|
3
|
-
createNumberField,
|
|
4
4
|
createTextField,
|
|
5
5
|
createTimestampField,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -41,7 +41,7 @@ export const capCounterEntity = createEntity({
|
|
|
41
41
|
table: "read_cap_counters",
|
|
42
42
|
fields: {
|
|
43
43
|
capName: createTextField({ required: true, maxLength: 100, sortable: true, searchable: true }),
|
|
44
|
-
value:
|
|
44
|
+
value: createBigIntField({ required: true, default: 0 }),
|
|
45
45
|
periodStart: createTimestampField({ required: true }),
|
|
46
46
|
lastSoftWarnedAt: createTimestampField(),
|
|
47
47
|
},
|
package/src/delivery/feature.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
export function createDeliveryFeature(): FeatureDefinition {
|
|
23
23
|
return defineFeature("delivery", (r) => {
|
|
24
24
|
r.describe(
|
|
25
|
-
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `
|
|
25
|
+
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
26
26
|
);
|
|
27
27
|
r.uiHints({
|
|
28
28
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
@@ -36,7 +36,7 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
36
36
|
r.entity("notification-preference", notificationPreferenceEntity, {
|
|
37
37
|
table: notificationPreferencesTable,
|
|
38
38
|
});
|
|
39
|
-
r.
|
|
39
|
+
r.storeTable(deliveryAttemptsTableMeta, {
|
|
40
40
|
reason: "read_side.delivery_attempt_log",
|
|
41
41
|
});
|
|
42
42
|
|
package/src/delivery/tables.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
// PK = event aggregate-id (uuid). Keeps the projection row linked back to
|
|
29
29
|
// its event stream 1:1 — same convention as jobRunsTable + tenantSecretsTable.
|
|
30
30
|
// Event replays stay idempotent (primary-key conflict instead of duplicate rows).
|
|
31
|
-
export const deliveryAttemptsTable = pgTable("
|
|
31
|
+
export const deliveryAttemptsTable = pgTable("store_delivery_attempts", {
|
|
32
32
|
id: uuid("id").primaryKey(),
|
|
33
33
|
tenantId: uuid("tenant_id").notNull(),
|
|
34
34
|
notificationType: text("notification_type").notNull(),
|
|
@@ -53,7 +53,7 @@ export const deliveryAttemptsTable = pgTable("read_delivery_attempts", {
|
|
|
53
53
|
// pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
|
|
54
54
|
// aus dieser Meta ab.
|
|
55
55
|
export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
56
|
-
tableName: "
|
|
56
|
+
tableName: "store_delivery_attempts",
|
|
57
57
|
columns: [
|
|
58
58
|
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
59
59
|
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
@@ -39,7 +39,7 @@ export function createFeatureTogglesFeature(
|
|
|
39
39
|
): FeatureDefinition {
|
|
40
40
|
return defineFeature("feature-toggles", (r) => {
|
|
41
41
|
r.describe(
|
|
42
|
-
'Persists per-feature enabled/disabled state in the `
|
|
42
|
+
'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
|
|
43
43
|
);
|
|
44
44
|
r.uiHints({
|
|
45
45
|
displayLabel: "Feature Toggles · Operator Switches",
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
// name IS the identity here. No tenantId: this is a global override that
|
|
16
16
|
// applies across every tenant (per-tenant toggles are intentionally out of
|
|
17
17
|
// scope, see core-feature-toggles.md).
|
|
18
|
-
export const globalFeatureStateTable = pgTable("
|
|
18
|
+
export const globalFeatureStateTable = pgTable("store_global_feature_state", {
|
|
19
19
|
featureName: text("feature_name").primaryKey(),
|
|
20
20
|
enabled: boolean("enabled").notNull(),
|
|
21
21
|
// Optimistic-lock column. The set-handler reads the existing row, then
|
|
@@ -44,7 +44,7 @@ describe("inboundMailFoundationFeature — shape", () => {
|
|
|
44
44
|
test("SyncCursor + SeenMessage sind unmanaged (NICHT event-sourced, Plan §3.4)", () => {
|
|
45
45
|
// Drift-Pin: als r.entity würden die Tick-State-Tabellen (a) das
|
|
46
46
|
// Event-Log fluten und (b) beim Projection-Rebuild gewischt.
|
|
47
|
-
const unmanaged = Object.keys(inboundMailFoundationFeature.
|
|
47
|
+
const unmanaged = Object.keys(inboundMailFoundationFeature.storeTables);
|
|
48
48
|
expect(unmanaged.length).toBe(2);
|
|
49
49
|
});
|
|
50
50
|
});
|
|
@@ -111,7 +111,7 @@ export const MAIL_THREAD_PII_FIELDS = collectPiiSubjectFields(mailThreadEntity);
|
|
|
111
111
|
* Write-locked auf privileged — nur Foundation-Handler/Supervisor
|
|
112
112
|
* schreiben, kein Tenant-Request. */
|
|
113
113
|
export const syncCursorEntity = createEntity({
|
|
114
|
-
table: "
|
|
114
|
+
table: "store_mail_sync_cursors",
|
|
115
115
|
softDelete: false,
|
|
116
116
|
fields: {
|
|
117
117
|
accountId: createTextField({
|
|
@@ -141,7 +141,7 @@ export const syncCursorEntity = createEntity({
|
|
|
141
141
|
* ingest-handler prüft zuerst hier (cheap), der Stream-Scan bleibt
|
|
142
142
|
* Defense-in-depth. */
|
|
143
143
|
export const seenMessageEntity = createEntity({
|
|
144
|
-
table: "
|
|
144
|
+
table: "store_mail_seen_messages",
|
|
145
145
|
softDelete: false,
|
|
146
146
|
fields: {
|
|
147
147
|
accountId: createTextField({
|
|
@@ -164,5 +164,9 @@ export const seenMessageEntity = createEntity({
|
|
|
164
164
|
// Plain EntityTableMeta (kein branded EntityTable) — unmanaged Direct-
|
|
165
165
|
// Write-Stores, Handler schreiben via ctx.db (siehe user-session.ts-
|
|
166
166
|
// Rationale).
|
|
167
|
-
export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity
|
|
168
|
-
|
|
167
|
+
export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity, {
|
|
168
|
+
source: "unmanaged",
|
|
169
|
+
});
|
|
170
|
+
export const seenMessageTable = buildEntityTableMeta("mail-seen-message", seenMessageEntity, {
|
|
171
|
+
source: "unmanaged",
|
|
172
|
+
});
|
|
@@ -156,12 +156,12 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
// Sync-Maschinerie: hochfrequenter Tick-State, bewusst NICHT
|
|
159
|
-
// event-sourced — r.
|
|
159
|
+
// event-sourced — r.storeTable hält die Migration-DDL, nimmt die
|
|
160
160
|
// Tabellen aber aus dem Projection-Rebuild (#494/#498-Klasse).
|
|
161
|
-
r.
|
|
161
|
+
r.storeTable(syncCursorTable, {
|
|
162
162
|
reason: "read_side.mail_sync_cursors_direct_write",
|
|
163
163
|
});
|
|
164
|
-
r.
|
|
164
|
+
r.storeTable(seenMessageTable, {
|
|
165
165
|
reason: "read_side.mail_seen_messages_direct_write",
|
|
166
166
|
});
|
|
167
167
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (watch-callback + fetchSince-Sync). Nimmt eine normalisierte
|
|
3
3
|
// RawInboundMessage und macht atomic in EINER TX:
|
|
4
4
|
//
|
|
5
|
-
// 1. Idempotency-Check (cheap:
|
|
5
|
+
// 1. Idempotency-Check (cheap: store_mail_seen_messages; Defense-in-
|
|
6
6
|
// depth: Stream-Existenz auf der deterministic aggregateId).
|
|
7
7
|
// 2. threadKey-Normalisierung (Provider-Thread-ID bevorzugt, sonst
|
|
8
8
|
// References-Chain-Root, sonst Message-ID = Single-Message-Thread).
|
|
@@ -115,7 +115,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
115
115
|
// ein früherer ingest NACH dem append aber VOR dem seen-insert
|
|
116
116
|
// gestorben ist (kann in einer TX nicht passieren — aber der
|
|
117
117
|
// Check ist billig und macht den Handler rebuild-robust falls
|
|
118
|
-
//
|
|
118
|
+
// store_mail_seen_messages je getruncated wird).
|
|
119
119
|
// ---------------------------------------------------------------
|
|
120
120
|
const seenRows = await selectMany(ctx.db.raw, seenMessageTable, {
|
|
121
121
|
accountId: payload.accountId,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// loadAggregate leer → received wird gar nicht erst repliziert. Gleiches
|
|
14
14
|
// Muster wie tenant-destroy-hook, nur per-Row-cutoff statt ganzer Tenant.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
16
|
+
// store_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
|
|
17
17
|
// Dedup-Anker braucht nur das Backfill-/Replay-Fenster → seenAt-cutoff
|
|
18
18
|
// via plain deleteMany, kein Stream/Archiv. Gescoped direkt über die
|
|
19
19
|
// tenant_id-Spalte der Tabelle (table-builder-Konvention), kein Account-Join.
|
|
@@ -92,7 +92,7 @@ export type RawInboundMessage = {
|
|
|
92
92
|
|
|
93
93
|
/** Provider-opaker Cursor: IMAP {uidValidity,lastUid} · Graph {deltaLink}
|
|
94
94
|
* · Gmail {historyId}. Foundation persisted ihn JSON-stringified in
|
|
95
|
-
*
|
|
95
|
+
* store_mail_sync_cursors, interpretiert ihn nie. */
|
|
96
96
|
export type SyncCursorPayload = Readonly<Record<string, unknown>>;
|
|
97
97
|
|
|
98
98
|
export type InboundFetchResult = {
|
|
@@ -63,7 +63,7 @@ export type InboundMailSupervisorDeps = {
|
|
|
63
63
|
* Credential-Reads der Provider). */
|
|
64
64
|
readonly providerCtx: InboundMailContext;
|
|
65
65
|
/** App-DB — direct reads auf read_mail_accounts (Account-Snapshot)
|
|
66
|
-
* + direct writes auf
|
|
66
|
+
* + direct writes auf store_mail_sync_cursors (unmanaged store). */
|
|
67
67
|
readonly db: DbConnection;
|
|
68
68
|
/** Standard-Dispatcher mit SystemUser — trägt ingest-message +
|
|
69
69
|
* update-account. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { jobRunEntity } from "../job-run-table";
|
|
4
|
+
|
|
5
|
+
function pgTypeOf(table: unknown, dbName: string): string | undefined {
|
|
6
|
+
const cols = (table as { columns?: ReadonlyArray<{ name: string; pgType?: string }> }).columns;
|
|
7
|
+
return cols?.find((c) => c.name === dbName)?.pgType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("jobRunEntity — DDL (#1205 regression)", () => {
|
|
11
|
+
test("duration column is integer, not double precision", () => {
|
|
12
|
+
const table = buildEntityTable("read_job_runs", jobRunEntity);
|
|
13
|
+
expect(pgTypeOf(table, "duration")).toBe("integer");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// #1215: the `jobs` feature registers `reindexEntity` as a manual + perTenant
|
|
2
|
+
// job. perTenant jobs fan out through a BullMQ wrapper that needs
|
|
3
|
+
// `getActiveTenantIds` (job-runner.ts) — setupTestStack's jobRunner doesn't
|
|
4
|
+
// wire that, so this calls the exported handler directly with a constructed
|
|
5
|
+
// context instead of going through jobRunner.dispatch(), same as
|
|
6
|
+
// inbound-mail-foundation's retention job test calls its sweep function
|
|
7
|
+
// directly rather than round-tripping through the queue.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
buildEntityTable,
|
|
12
|
+
createEventStoreExecutor,
|
|
13
|
+
createTenantDb,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { createJobsFeature } from "../feature";
|
|
24
|
+
import { reindexEntityJob } from "../handlers/reindex-entity.job";
|
|
25
|
+
|
|
26
|
+
// Resolved job name — operators trigger this via jobs:write:trigger with
|
|
27
|
+
// { jobName: "jobs:job:reindex-entity", payload: { entity: "..." } }.
|
|
28
|
+
const REINDEX_ENTITY_JOB = "jobs:job:reindex-entity";
|
|
29
|
+
|
|
30
|
+
const widgetEntity = createEntity({
|
|
31
|
+
table: "read_reindex_job_widgets",
|
|
32
|
+
fields: {
|
|
33
|
+
name: createTextField({ required: true, searchable: true }),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const widgetTable = buildEntityTable("widget", widgetEntity);
|
|
38
|
+
|
|
39
|
+
const widgetFeature = defineFeature("reindex-job-test", (r) => {
|
|
40
|
+
r.entity("widget", widgetEntity);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let stack: TestStack;
|
|
44
|
+
const admin = TestUsers.admin;
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
|
|
48
|
+
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
49
|
+
await createEventsTable(stack.db);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await stack.cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("jobs feature: reindexEntity registration", () => {
|
|
57
|
+
test("registers reindexEntity as jobs:job:reindex-entity — the name jobs:write:trigger dispatches by", () => {
|
|
58
|
+
const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
|
|
59
|
+
expect(job).toBeDefined();
|
|
60
|
+
expect(job?.handler).toBe(reindexEntityJob);
|
|
61
|
+
expect(job?.perTenant).toBe(true);
|
|
62
|
+
expect(job?.trigger).toEqual({ manual: true });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("reindexEntityJob", () => {
|
|
67
|
+
test("indexes rows for the tenant resolved from ctx.systemUser", async () => {
|
|
68
|
+
const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
|
|
69
|
+
const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
|
|
70
|
+
const created = await executor.create({ name: "Job Backfillable Widget" }, admin, tenantDb);
|
|
71
|
+
if (!created.isSuccess) throw new Error("seed failed");
|
|
72
|
+
|
|
73
|
+
// No stack.eventDispatcher.runOnce() — row was never indexed live.
|
|
74
|
+
const preResults = await stack.search.search(admin.tenantId, "backfillable", {
|
|
75
|
+
filterType: "widget",
|
|
76
|
+
});
|
|
77
|
+
expect(preResults).toHaveLength(0);
|
|
78
|
+
|
|
79
|
+
await reindexEntityJob(
|
|
80
|
+
{ entity: "widget" },
|
|
81
|
+
{ db: stack.db, registry: stack.registry, searchAdapter: stack.search, systemUser: admin },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const postResults = await stack.search.search(admin.tenantId, "backfillable", {
|
|
85
|
+
filterType: "widget",
|
|
86
|
+
});
|
|
87
|
+
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
reindexEntityJob(
|
|
93
|
+
{ entity: "widget" },
|
|
94
|
+
{ db: stack.db, registry: stack.registry, searchAdapter: stack.search },
|
|
95
|
+
),
|
|
96
|
+
).resolves.toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("throws when ctx.searchAdapter is missing", async () => {
|
|
100
|
+
await expect(
|
|
101
|
+
reindexEntityJob(
|
|
102
|
+
{ entity: "widget" },
|
|
103
|
+
{ db: stack.db, registry: stack.registry, systemUser: admin },
|
|
104
|
+
),
|
|
105
|
+
).rejects.toThrow(/searchAdapter/);
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
projectionRebuildJob,
|
|
18
18
|
projectionRebuildPayloadSchema,
|
|
19
19
|
} from "./handlers/projection-rebuild.job";
|
|
20
|
+
import { reindexEntityJob, reindexEntityPayloadSchema } from "./handlers/reindex-entity.job";
|
|
20
21
|
import { retryWrite } from "./handlers/retry.write";
|
|
21
22
|
import { triggerWrite } from "./handlers/trigger.write";
|
|
22
23
|
import { JOBS_I18N } from "./i18n";
|
|
@@ -30,7 +31,7 @@ import { jobRunLogsTable, jobRunLogsTableMeta, jobRunsTable } from "./job-run-ta
|
|
|
30
31
|
export function createJobsFeature(): FeatureDefinition {
|
|
31
32
|
return defineFeature("jobs", (r) => {
|
|
32
33
|
r.describe(
|
|
33
|
-
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `
|
|
34
|
+
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:details` for the operator UI.",
|
|
34
35
|
);
|
|
35
36
|
r.uiHints({
|
|
36
37
|
displayLabel: "Jobs · Audit & Operator UI",
|
|
@@ -38,7 +39,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
38
39
|
recommended: false,
|
|
39
40
|
});
|
|
40
41
|
r.systemScope();
|
|
41
|
-
r.
|
|
42
|
+
r.storeTable(jobRunLogsTableMeta, {
|
|
42
43
|
reason: "read_side.job_run_logs",
|
|
43
44
|
});
|
|
44
45
|
// Events-only aggregate: "jobRun" has no r.entity registration, because
|
|
@@ -174,6 +175,16 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
174
175
|
projectionRebuildJob,
|
|
175
176
|
);
|
|
176
177
|
|
|
178
|
+
// Retroactive search backfill (#1206/#1215) — manual + perTenant, so one
|
|
179
|
+
// `jobs:write:trigger` call with { entity } fans out to every active
|
|
180
|
+
// tenant (job-runner.ts perTenant dispatch applies to manual triggers
|
|
181
|
+
// too, not just cron).
|
|
182
|
+
r.job(
|
|
183
|
+
"reindexEntity",
|
|
184
|
+
{ trigger: { manual: true }, perTenant: true, schema: reindexEntityPayloadSchema },
|
|
185
|
+
reindexEntityJob,
|
|
186
|
+
);
|
|
187
|
+
|
|
177
188
|
const handlers = {
|
|
178
189
|
trigger: r.writeHandler(triggerWrite),
|
|
179
190
|
retry: r.writeHandler(retryWrite),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Manual, perTenant backfill job (#1206/#1215) — indexes existing rows for
|
|
2
|
+
// an entity that only got `searchable: true` after rows already existed.
|
|
3
|
+
// perTenant fan-out (job-runner.ts) also applies to a manual dispatch, not
|
|
4
|
+
// just cron: one `jobs:write:trigger` call with { entity } re-runs once per
|
|
5
|
+
// active tenant.
|
|
6
|
+
|
|
7
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
+
import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
export const reindexEntityPayloadSchema = z.object({
|
|
14
|
+
entity: z.string().min(1),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
18
|
+
const { entity } = reindexEntityPayloadSchema.parse(rawPayload);
|
|
19
|
+
if (!ctx.db) {
|
|
20
|
+
throw new InternalError({
|
|
21
|
+
message: "[jobs:reindex-entity] ctx.db missing — job context requires a database connection.",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (!ctx.registry) {
|
|
25
|
+
throw new InternalError({
|
|
26
|
+
message: "[jobs:reindex-entity] ctx.registry missing — job context requires the registry.",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (!ctx.searchAdapter) {
|
|
30
|
+
throw new InternalError({
|
|
31
|
+
message:
|
|
32
|
+
"[jobs:reindex-entity] ctx.searchAdapter missing — mount a search adapter to run this job.",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// perTenant fan-out — the job-runner enqueues one child run per active
|
|
36
|
+
// tenant with _tenantId set; a run without one (e.g. misconfigured
|
|
37
|
+
// manual dispatch outside the fan-out) has nothing scoped to reindex.
|
|
38
|
+
const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
|
|
39
|
+
if (tenantId === undefined) {
|
|
40
|
+
// skip: fired without a perTenant fan-out tenant — nothing scoped to reindex
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
44
|
+
const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
|
|
45
|
+
ctx.log?.info?.(
|
|
46
|
+
`[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +
|
|
47
|
+
`${result.failures.length > 0 ? ` (${result.failures.length} failed)` : ""}`,
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -48,7 +48,7 @@ export const jobRunEntity = createEntity({
|
|
|
48
48
|
attempt: createNumberField({ required: true, default: 1, integer: true }),
|
|
49
49
|
startedAt: createTimestampField({ required: true }),
|
|
50
50
|
finishedAt: createTimestampField(),
|
|
51
|
-
duration: createNumberField(),
|
|
51
|
+
duration: createNumberField({ integer: true }),
|
|
52
52
|
triggeredById: createTextField(),
|
|
53
53
|
},
|
|
54
54
|
});
|
|
@@ -60,7 +60,7 @@ export const jobRunsTable = buildEntityTable("job-run", jobRunEntity);
|
|
|
60
60
|
// uuid of the parent jobRun. Existing detail-query callers treat it as an
|
|
61
61
|
// opaque identifier, so the type-switch is backward-compatible at the
|
|
62
62
|
// query surface.
|
|
63
|
-
export const jobRunLogsTable = pgTable("
|
|
63
|
+
export const jobRunLogsTable = pgTable("store_job_run_logs", {
|
|
64
64
|
id: serial("id").primaryKey(),
|
|
65
65
|
runId: text("run_id").notNull(),
|
|
66
66
|
level: text("level").notNull().$type<JobLogLevel>(),
|
|
@@ -76,7 +76,7 @@ export const jobRunLogsTable = pgTable("read_job_run_logs", {
|
|
|
76
76
|
// pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
|
|
77
77
|
// aus dieser Meta ab.
|
|
78
78
|
export const jobRunLogsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
79
|
-
tableName: "
|
|
79
|
+
tableName: "store_job_run_logs",
|
|
80
80
|
columns: [
|
|
81
81
|
{ name: "id", pgType: "serial", notNull: true, primaryKey: true },
|
|
82
82
|
{ name: "run_id", pgType: "text", notNull: true },
|
|
@@ -40,7 +40,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
40
40
|
const { scopes } = options;
|
|
41
41
|
return defineFeature(PAT_FEATURE, (r) => {
|
|
42
42
|
r.describe(
|
|
43
|
-
"Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `
|
|
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) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
|
|
44
44
|
);
|
|
45
45
|
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
46
46
|
// Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
|
|
@@ -50,10 +50,10 @@ export function createPersonalAccessTokensFeature(
|
|
|
50
50
|
// Resolver reads memberships + users on every PAT request to build live
|
|
51
51
|
// roles — make both boot-time deps so a mis-wiring fails validateBoot.
|
|
52
52
|
r.requires("user", "tenant");
|
|
53
|
-
// Direct-write store like
|
|
53
|
+
// Direct-write store like store_user_sessions: create/revoke write it, the
|
|
54
54
|
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
55
55
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
56
|
-
r.
|
|
56
|
+
r.storeTable(buildEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
|
|
57
57
|
reason: "read_side.api_tokens_direct_write",
|
|
58
58
|
// create.write encrypts `name` via encryptForDirectWrite (#820).
|
|
59
59
|
piiEncryptedOnWrite: true,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
createTimestampField,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
|
|
9
|
-
// One row per Personal Access Token. Like
|
|
9
|
+
// One row per Personal Access Token. Like store_user_sessions this is a
|
|
10
10
|
// direct-write store (r.unmanagedTable): the create/revoke handlers write it and
|
|
11
11
|
// the resolver point-reads it on the hot auth path. `tokenHash` is the SHA-256
|
|
12
12
|
// of the plaintext (never the plaintext); the unique index on it makes the
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
// request can forge ownership/scope/hash by poking a field directly — the
|
|
15
15
|
// handlers mutate them via ctx.db inside the pipeline.
|
|
16
16
|
export const apiTokenEntity = createEntity({
|
|
17
|
-
table: "
|
|
17
|
+
table: "store_api_tokens",
|
|
18
18
|
// No softDelete: revocation is its own lifecycle (revokedAt timestamp), and
|
|
19
19
|
// we keep revoked rows for the "your tokens" audit list.
|
|
20
20
|
softDelete: false,
|
|
@@ -52,10 +52,12 @@ export const apiTokenEntity = createEntity({
|
|
|
52
52
|
expiresAt: createTimestampField({ access: { write: access.privileged } }),
|
|
53
53
|
revokedAt: createTimestampField({ access: { write: access.privileged } }),
|
|
54
54
|
},
|
|
55
|
-
indexes: [{ unique: true, columns: ["tokenHash"], name: "
|
|
55
|
+
indexes: [{ unique: true, columns: ["tokenHash"], name: "store_api_tokens_hash_unique" }],
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
|
|
59
59
|
// the table must be a WritableTable (post ES-write-brand #742) — same as
|
|
60
60
|
// sessions' userSessionTable. buildEntityTable is branded executor-only.
|
|
61
|
-
export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity
|
|
61
|
+
export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity, {
|
|
62
|
+
source: "unmanaged",
|
|
63
|
+
});
|
|
@@ -175,8 +175,16 @@ describe("rotate-job circuit-breaker", () => {
|
|
|
175
175
|
// Happy-path counterpart to the failure tests above: a working provider
|
|
176
176
|
// that actually rewraps. Run last — it's the only test that mutates the
|
|
177
177
|
// shared 20-row fixture, so the earlier "stays at 20" assertions must
|
|
178
|
-
// see it before this runs.
|
|
178
|
+
// see it before this runs. Enforced below, not just documented: a
|
|
179
|
+
// precondition check fails loud (not silently-wrong) if a test inserted
|
|
180
|
+
// between this and the circuit-breaker tests already touched the
|
|
181
|
+
// fixture — rotateJob's batch scan has no per-row exclusion/ordering
|
|
182
|
+
// to fall back on, so isolating this test onto a second tenant doesn't
|
|
183
|
+
// help (rotator would just exhaust maxFailures on the other tenant's
|
|
184
|
+
// still-V1 rows before ever reaching its own).
|
|
179
185
|
test("successful rotation rewraps the DEK, bumps kekVersion, and preserves plaintext", async () => {
|
|
186
|
+
expect(await countV1Rows()).toBe(20);
|
|
187
|
+
|
|
180
188
|
const rotator = createEnvMasterKeyProvider({
|
|
181
189
|
env: {
|
|
182
190
|
KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
|
|
@@ -185,6 +193,17 @@ describe("rotate-job circuit-breaker", () => {
|
|
|
185
193
|
},
|
|
186
194
|
});
|
|
187
195
|
|
|
196
|
+
// Snapshot pre-rotation ciphertext — rotate.job.ts's header comment
|
|
197
|
+
// claims "the ciphertext itself never changes, only the DEK wrapper".
|
|
198
|
+
// Assert that invariant below instead of only checking kekVersion/
|
|
199
|
+
// plaintext, which a rewrap-that-re-encrypts would also satisfy.
|
|
200
|
+
const preRotation = await selectMany<{ key: string; envelope: StoredEnvelope }>(
|
|
201
|
+
stack.db,
|
|
202
|
+
tenantSecretsTable,
|
|
203
|
+
{ tenantId: admin.tenantId },
|
|
204
|
+
);
|
|
205
|
+
const ciphertextByKey = new Map(preRotation.map((row) => [row.key, row.envelope.ciphertext]));
|
|
206
|
+
|
|
188
207
|
await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator));
|
|
189
208
|
|
|
190
209
|
expect(await countV1Rows()).toBe(0);
|
|
@@ -209,6 +228,9 @@ describe("rotate-job circuit-breaker", () => {
|
|
|
209
228
|
expect(rows).toHaveLength(20);
|
|
210
229
|
for (const row of rows) {
|
|
211
230
|
expect(row.kekVersion).toBe(2);
|
|
231
|
+
const expectedCiphertext = ciphertextByKey.get(row.key);
|
|
232
|
+
expect(expectedCiphertext).toBeDefined();
|
|
233
|
+
expect(row.envelope.ciphertext).toBe(expectedCiphertext as string);
|
|
212
234
|
const expectedIndex = row.key.split("-").at(-1);
|
|
213
235
|
const plaintext = await decryptValue(decodeStoredEnvelope(row.envelope), verifier);
|
|
214
236
|
expect(plaintext).toBe(`secret-${expectedIndex}`);
|
|
@@ -19,7 +19,7 @@ import { createUserFeature } from "../../user/feature";
|
|
|
19
19
|
import { createSessionsFeature } from "../feature";
|
|
20
20
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// store_user_sessions is a hot-path direct-write store: sessionCreator inserts
|
|
23
23
|
// rows and the revoke handlers update them WITHOUT emitting lifecycle events.
|
|
24
24
|
// If the table is registered as an r.entity, the framework makes it a
|
|
25
25
|
// rebuildable implicit projection whose replay finds zero matching events and
|
|
@@ -27,7 +27,7 @@ import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
|
27
27
|
// session on the next projection rebuild (deploy / `schema apply`). #498/#494.
|
|
28
28
|
//
|
|
29
29
|
// Pre-fix both tests are RED: the implicit projection "sessions:projection:
|
|
30
|
-
// user-session-entity" exists and rebuilding it empties
|
|
30
|
+
// user-session-entity" exists and rebuilding it empties store_user_sessions.
|
|
31
31
|
// Post-fix (r.unmanagedTable) the table is no longer a rebuild target.
|
|
32
32
|
|
|
33
33
|
const IMPLICIT_PROJECTION = "sessions:projection:user-session-entity";
|
|
@@ -48,7 +48,7 @@ afterAll(async () => {
|
|
|
48
48
|
|
|
49
49
|
beforeEach(async () => {
|
|
50
50
|
await asRawClient(testDb.db).unsafe(
|
|
51
|
-
"TRUNCATE
|
|
51
|
+
"TRUNCATE store_user_sessions, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE",
|
|
52
52
|
);
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -70,7 +70,7 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
|
|
|
70
70
|
await updateRows(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
describe("sessions /
|
|
73
|
+
describe("sessions / store_user_sessions survives projection rebuild", () => {
|
|
74
74
|
test("is NOT registered as a rebuildable implicit projection", () => {
|
|
75
75
|
const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
|
|
76
76
|
expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
|
|
@@ -7,9 +7,9 @@ export async function deleteStaleSessionsBatch(
|
|
|
7
7
|
batchSize: number,
|
|
8
8
|
): Promise<number> {
|
|
9
9
|
const rows = (await asRawClient(db).unsafe(
|
|
10
|
-
`DELETE FROM "
|
|
10
|
+
`DELETE FROM "store_user_sessions"
|
|
11
11
|
WHERE "id" IN (
|
|
12
|
-
SELECT "id" FROM "
|
|
12
|
+
SELECT "id" FROM "store_user_sessions"
|
|
13
13
|
WHERE "expires_at" < now() - ($1::int * interval '1 day')
|
|
14
14
|
OR "revoked_at" < now() - ($1::int * interval '1 day')
|
|
15
15
|
LIMIT $2
|
package/src/sessions/feature.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function bindAutoRevokeFromFeature(
|
|
|
58
58
|
return undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// The sessions feature registers the
|
|
61
|
+
// The sessions feature registers the store_user_sessions table (as an
|
|
62
62
|
// unmanaged direct-write store, NOT an r.entity — see below) and the three
|
|
63
63
|
// user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
|
|
64
64
|
// export a sessionCreator/sessionRevoker here — those are produced by
|
|
@@ -76,7 +76,7 @@ export function bindAutoRevokeFromFeature(
|
|
|
76
76
|
export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
|
|
77
77
|
return defineFeature("sessions", (r) => {
|
|
78
78
|
r.describe(
|
|
79
|
-
"Tracks signed-in clients in the `
|
|
79
|
+
"Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
80
80
|
);
|
|
81
81
|
r.uiHints({
|
|
82
82
|
displayLabel: "Sessions · Server-side Logout",
|
|
@@ -87,16 +87,16 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
87
87
|
// gate for locked accounts) — make that a boot-time dependency so a
|
|
88
88
|
// sessions-without-user wiring fails validateBoot instead of 500ing live.
|
|
89
89
|
r.requires("user");
|
|
90
|
-
//
|
|
90
|
+
// store_user_sessions is a hot-path direct-write store: sessionCreator
|
|
91
91
|
// inserts and the revoke handlers update rows WITHOUT emitting lifecycle
|
|
92
92
|
// events (the row columns ARE the audit trail). Registering it as
|
|
93
93
|
// r.entity would make it a rebuildable implicit projection whose replay
|
|
94
94
|
// finds zero session events and swaps an empty shadow over the live
|
|
95
95
|
// table — wiping every active session on the next projection rebuild
|
|
96
|
-
// (#498/#494). r.
|
|
96
|
+
// (#498/#494). r.storeTable keeps the migration DDL but opts the
|
|
97
97
|
// table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
|
|
98
98
|
// which are direct-write stores too.
|
|
99
|
-
r.
|
|
99
|
+
r.storeTable(buildEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
|
|
100
100
|
reason: "read_side.user_sessions_direct_write",
|
|
101
101
|
// sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
|
|
102
102
|
piiEncryptedOnWrite: true,
|
|
@@ -20,7 +20,7 @@ export const userSessionEntity = createEntity({
|
|
|
20
20
|
// Entity-Key ist "user-session" (mit Dash), toTableName's snake-case-
|
|
21
21
|
// Transform käme auf "read_user-sessions" → kein valides SQL ohne Quoting.
|
|
22
22
|
// Deshalb expliziter Override auf den read_-konformen Namen.
|
|
23
|
-
table: "
|
|
23
|
+
table: "store_user_sessions",
|
|
24
24
|
// sid-as-PK: the sessionCreator callback generates the UUID, returns it to
|
|
25
25
|
// the framework; the framework stamps it as `jti`; here the same value is
|
|
26
26
|
// the row primary key. Single source of truth for the identifier.
|
|
@@ -74,4 +74,6 @@ export const userSessionEntity = createEntity({
|
|
|
74
74
|
// handlers write it directly via ctx.db; the meta carries no executor-only
|
|
75
75
|
// brand so those writes stay legal. See feature.ts for the rebuild-exclusion
|
|
76
76
|
// rationale (#494/#498).
|
|
77
|
-
export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity
|
|
77
|
+
export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity, {
|
|
78
|
+
source: "unmanaged",
|
|
79
|
+
});
|
|
@@ -109,7 +109,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
|
|
|
109
109
|
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
110
110
|
// Per-row forget() through the executor, not a bulk deleteMany: memberships
|
|
111
111
|
// are an ES-managed projection (add/remove/update-roles all go through
|
|
112
|
-
// tenantMembershipCrud), so a
|
|
112
|
+
// tenantMembershipCrud), so a store table write here is eventless — a future
|
|
113
113
|
// projection rebuild would replay the historical add-member events and
|
|
114
114
|
// resurrect rows this stage removed. forget() (Art.17 hard-purge) keeps the
|
|
115
115
|
// erasure rebuild-safe and gives each membership its own audit event.
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
CHANGED
|
@@ -146,7 +146,7 @@ async function seedUser(id: string): Promise<void> {
|
|
|
146
146
|
describe("user-session userData-hooks", () => {
|
|
147
147
|
async function seedSession(id: string, userId: string, tenantId: string): Promise<void> {
|
|
148
148
|
await asRawClient(full.db).unsafe(
|
|
149
|
-
`INSERT INTO
|
|
149
|
+
`INSERT INTO store_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
|
|
150
150
|
VALUES ($1, $2, $3, now(), now() + interval '1 day', '203.0.113.7', 'TestAgent/1.0')`,
|
|
151
151
|
[id, userId, tenantId],
|
|
152
152
|
);
|
|
@@ -170,11 +170,11 @@ describe("user-session userData-hooks", () => {
|
|
|
170
170
|
await userSessionDeleteHook(ctx("sess-user-2"), "delete");
|
|
171
171
|
|
|
172
172
|
const a = await rawSelect(
|
|
173
|
-
"SELECT * FROM
|
|
173
|
+
"SELECT * FROM store_user_sessions WHERE user_id = $1 AND tenant_id = $2",
|
|
174
174
|
["sess-user-2", TENANT_A],
|
|
175
175
|
);
|
|
176
176
|
const b = await rawSelect(
|
|
177
|
-
"SELECT * FROM
|
|
177
|
+
"SELECT * FROM store_user_sessions WHERE user_id = $1 AND tenant_id = $2",
|
|
178
178
|
["sess-user-2", TENANT_B],
|
|
179
179
|
);
|
|
180
180
|
expect(a).toHaveLength(0);
|
|
@@ -185,7 +185,7 @@ describe("user-session userData-hooks", () => {
|
|
|
185
185
|
describe("api-token userData-hooks", () => {
|
|
186
186
|
async function seedToken(id: string, userId: string): Promise<void> {
|
|
187
187
|
await asRawClient(full.db).unsafe(
|
|
188
|
-
`INSERT INTO
|
|
188
|
+
`INSERT INTO store_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
|
|
189
189
|
VALUES ($1, $2, $3, 'My MacBook', $4, 'kum_ab12', '["read"]', now())`,
|
|
190
190
|
[id, userId, TENANT_A, `hash-${id}`],
|
|
191
191
|
);
|
|
@@ -207,7 +207,7 @@ describe("api-token userData-hooks", () => {
|
|
|
207
207
|
|
|
208
208
|
await apiTokenDeleteHook(ctx("pat-user-2"), "delete");
|
|
209
209
|
|
|
210
|
-
const rows = await rawSelect("SELECT * FROM
|
|
210
|
+
const rows = await rawSelect("SELECT * FROM store_api_tokens WHERE user_id = $1", [
|
|
211
211
|
"pat-user-2",
|
|
212
212
|
]);
|
|
213
213
|
expect(rows).toHaveLength(0);
|
|
@@ -371,7 +371,7 @@ describe("config-value userData-hooks", () => {
|
|
|
371
371
|
describe("delivery-attempt userData-hooks (#799)", () => {
|
|
372
372
|
async function seedAttempt(id: string, recipientId: string, tenantId: string): Promise<void> {
|
|
373
373
|
await asRawClient(full.db).unsafe(
|
|
374
|
-
`INSERT INTO
|
|
374
|
+
`INSERT INTO store_delivery_attempts
|
|
375
375
|
(id, tenant_id, notification_type, channel, recipient_id, recipient_address, status, priority)
|
|
376
376
|
VALUES ($1, $2, 'app:notify:ping', 'email', $3, $4, 'sent', 'normal')`,
|
|
377
377
|
[id, tenantId, recipientId, `user-${recipientId}@example.com`],
|