@cosmicdrift/kumiko-samples 0.353.0 → 0.354.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 +1 -1
- package/packages/bundled-features/package.json +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +13 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/seed-migration/README.md +0 -2
|
@@ -4,18 +4,26 @@ import {
|
|
|
4
4
|
type SessionStoreProvider,
|
|
5
5
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
6
6
|
import { type DbConnection, deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
access,
|
|
9
|
+
defineFeature,
|
|
10
|
+
type FeatureDefinition,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
12
|
import { USER_SESSION_ENTITY_NAME } from "./constants.js";
|
|
9
13
|
import { cleanupJob } from "./handlers/cleanup.job.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
14
|
+
import { createDetailQuery } from "./handlers/detail.query.js";
|
|
15
|
+
import { createListQuery } from "./handlers/list.query.js";
|
|
12
16
|
import { mineQuery } from "./handlers/mine.query.js";
|
|
13
17
|
import { revokeWrite } from "./handlers/revoke.write.js";
|
|
14
18
|
import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write.js";
|
|
15
19
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write.js";
|
|
16
20
|
import { SESSIONS_I18N } from "./i18n.js";
|
|
17
21
|
import { userSessionEntity } from "./schema/user-session.js";
|
|
18
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createSessionDetailScreen,
|
|
24
|
+
createSessionListScreen,
|
|
25
|
+
sessionMineScreen,
|
|
26
|
+
} from "./screens.js";
|
|
19
27
|
import {
|
|
20
28
|
createSessionCallbacks,
|
|
21
29
|
type SessionAllOthersRevoker,
|
|
@@ -45,6 +53,10 @@ export type SessionsFeatureOptions = {
|
|
|
45
53
|
// Session JWT / store_user_sessions TTL. Was previously auth.sessions.expiresInMs
|
|
46
54
|
// (#1372) — lives on the sessions feature now that sessionStore is the wiring.
|
|
47
55
|
readonly expiresInMs?: number;
|
|
56
|
+
// Access preset for the admin list/detail queries and screens; they expose decrypted
|
|
57
|
+
// IP/user-agent of every session in the tenant. A preset name, not a role array, because
|
|
58
|
+
// dedupeOptions must stay primitive.
|
|
59
|
+
readonly adminAccess?: "admin" | "systemAdmin";
|
|
48
60
|
};
|
|
49
61
|
|
|
50
62
|
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
@@ -89,6 +101,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
89
101
|
return defineFeature(
|
|
90
102
|
"sessions",
|
|
91
103
|
(r) => {
|
|
104
|
+
const adminRoles = access[options?.adminAccess ?? "admin"];
|
|
92
105
|
r.describe(
|
|
93
106
|
"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 same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. 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.",
|
|
94
107
|
);
|
|
@@ -162,8 +175,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
162
175
|
|
|
163
176
|
const queries = {
|
|
164
177
|
mine: r.queryHandler(mineQuery),
|
|
165
|
-
list: r.queryHandler(
|
|
166
|
-
detail: r.queryHandler(
|
|
178
|
+
list: r.queryHandler(createListQuery(adminRoles)),
|
|
179
|
+
detail: r.queryHandler(createDetailQuery(adminRoles)),
|
|
167
180
|
};
|
|
168
181
|
|
|
169
182
|
// Retention: chunked DELETE of expired/revoked rows. Manual trigger
|
|
@@ -222,8 +235,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
222
235
|
|
|
223
236
|
r.translations({ keys: SESSIONS_I18N });
|
|
224
237
|
|
|
225
|
-
r.screen(
|
|
226
|
-
r.screen(
|
|
238
|
+
r.screen(createSessionListScreen(adminRoles));
|
|
239
|
+
r.screen(createSessionDetailScreen(adminRoles));
|
|
227
240
|
r.screen(sessionMineScreen);
|
|
228
241
|
r.nav({
|
|
229
242
|
id: "session-list",
|
|
@@ -16,36 +16,37 @@ const sessionRowSchema = z.object({
|
|
|
16
16
|
userAgent: z.string().nullable(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
export const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
19
|
+
export const createDetailQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
20
|
+
defineQueryHandler({
|
|
21
|
+
name: "user-session:detail",
|
|
22
|
+
schema: z.object({ id: z.uuid() }),
|
|
23
|
+
access: { roles: adminRoles },
|
|
24
|
+
description:
|
|
25
|
+
"Loads one session of the active tenant by its id with owner, timestamps and decrypted IP/user-agent; use it when an admin investigates a specific session found through the session list.",
|
|
26
|
+
// Decrypted IP and user-agent.
|
|
27
|
+
agent: { risk: "high" },
|
|
28
|
+
outputSchema: sessionRowSchema.nullable(),
|
|
29
|
+
handler: async (query, ctx) => {
|
|
30
|
+
const row = await fetchOne<{
|
|
31
|
+
id: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
createdAt: unknown;
|
|
34
|
+
expiresAt: unknown;
|
|
35
|
+
revokedAt: unknown;
|
|
36
|
+
ip: string | null;
|
|
37
|
+
userAgent: string | null;
|
|
38
|
+
}>(ctx.db, userSessionTable, { id: query.payload.id });
|
|
39
|
+
if (!row) return null;
|
|
40
|
+
return {
|
|
41
|
+
id: row.id,
|
|
42
|
+
userId: row.userId,
|
|
43
|
+
createdAt: row.createdAt,
|
|
44
|
+
expiresAt: row.expiresAt,
|
|
45
|
+
revokedAt: row.revokedAt,
|
|
46
|
+
ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
|
|
47
|
+
userAgent: row.userAgent
|
|
48
|
+
? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
|
|
49
|
+
: row.userAgent,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -25,68 +25,71 @@ function isSortableColumn(value: string): value is SortableColumn {
|
|
|
25
25
|
// tenantId column. Includes revoked rows; UI shows revokedAt distinct.
|
|
26
26
|
// No cursor — nextCursor is always null; `limit` only caps the page size,
|
|
27
27
|
// it doesn't offset into a further one.
|
|
28
|
-
export const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
z.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
: r.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
28
|
+
export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
29
|
+
definePagedQueryHandler({
|
|
30
|
+
name: "user-session:list",
|
|
31
|
+
schema: z.object({
|
|
32
|
+
limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
|
|
33
|
+
sort: z.string().optional(),
|
|
34
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
35
|
+
}),
|
|
36
|
+
access: { roles: adminRoles },
|
|
37
|
+
description:
|
|
38
|
+
"Lists every session in the active tenant, live and revoked, sortable by id, user, creation, expiry or revocation, for an admin auditing who is signed in.",
|
|
39
|
+
outputSchema: z.object({
|
|
40
|
+
rows: z.array(
|
|
41
|
+
z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
userId: z.string(),
|
|
44
|
+
createdAt: z.unknown(),
|
|
45
|
+
expiresAt: z.unknown(),
|
|
46
|
+
revokedAt: z.unknown(),
|
|
47
|
+
ip: z.string().nullable(),
|
|
48
|
+
userAgent: z.string().nullable(),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
nextCursor: z.string().nullable(),
|
|
52
|
+
}),
|
|
53
|
+
handler: async (query, ctx) => {
|
|
54
|
+
const requestedSort = query.payload.sort;
|
|
55
|
+
const sortColumn: SortableColumn =
|
|
56
|
+
requestedSort !== undefined && isSortableColumn(requestedSort)
|
|
57
|
+
? requestedSort
|
|
58
|
+
: "createdAt";
|
|
59
|
+
const rows = await selectMany<{
|
|
60
|
+
id: string;
|
|
61
|
+
userId: string;
|
|
62
|
+
createdAt: unknown;
|
|
63
|
+
expiresAt: unknown;
|
|
64
|
+
revokedAt: unknown;
|
|
65
|
+
ip: string | null;
|
|
66
|
+
userAgent: string | null;
|
|
67
|
+
}>(ctx.db, userSessionTable, undefined, {
|
|
68
|
+
// `id` as a tie-breaker keeps row order (and, with `limit` set, row
|
|
69
|
+
// selection) deterministic across identical requests — sortColumn
|
|
70
|
+
// alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
|
|
71
|
+
orderBy:
|
|
72
|
+
sortColumn === "id"
|
|
73
|
+
? { col: "id", direction: query.payload.sortDirection ?? "desc" }
|
|
74
|
+
: [
|
|
75
|
+
{ col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
|
|
76
|
+
{ col: "id", direction: "asc" },
|
|
77
|
+
],
|
|
78
|
+
...(query.payload.limit !== undefined && { limit: query.payload.limit }),
|
|
79
|
+
});
|
|
80
|
+
const decryptedRows = await Promise.all(
|
|
81
|
+
rows.map(async (r) => ({
|
|
82
|
+
id: r.id,
|
|
83
|
+
userId: r.userId,
|
|
84
|
+
createdAt: r.createdAt,
|
|
85
|
+
expiresAt: r.expiresAt,
|
|
86
|
+
revokedAt: r.revokedAt,
|
|
87
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
|
|
88
|
+
userAgent: r.userAgent
|
|
89
|
+
? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
|
|
90
|
+
: r.userAgent,
|
|
91
|
+
})),
|
|
92
|
+
);
|
|
93
|
+
return { rows: decryptedRows, nextCursor: null };
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
SessionQueries,
|
|
14
14
|
} from "./constants.js";
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export const createSessionListScreen = (
|
|
17
|
+
adminRoles: readonly string[] = access.admin,
|
|
18
|
+
): ProjectionListScreenDefinition => ({
|
|
19
19
|
id: SESSION_LIST_SCREEN_ID,
|
|
20
20
|
type: "projectionList",
|
|
21
21
|
query: SessionQueries.list,
|
|
@@ -61,10 +61,12 @@ export const sessionListScreen: ProjectionListScreenDefinition = {
|
|
|
61
61
|
rowClick: true,
|
|
62
62
|
},
|
|
63
63
|
],
|
|
64
|
-
access:
|
|
65
|
-
};
|
|
64
|
+
access: { roles: adminRoles },
|
|
65
|
+
});
|
|
66
66
|
|
|
67
|
-
export const
|
|
67
|
+
export const createSessionDetailScreen = (
|
|
68
|
+
adminRoles: readonly string[] = access.admin,
|
|
69
|
+
): ProjectionDetailScreenDefinition => ({
|
|
68
70
|
id: SESSION_DETAIL_SCREEN_ID,
|
|
69
71
|
type: "projectionDetail",
|
|
70
72
|
query: SessionQueries.detail,
|
|
@@ -97,8 +99,8 @@ export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
|
|
|
97
99
|
ip: i18nKey("sessions.detail.field.ip"),
|
|
98
100
|
userAgent: i18nKey("sessions.detail.field.userAgent"),
|
|
99
101
|
},
|
|
100
|
-
access:
|
|
101
|
-
};
|
|
102
|
+
access: { roles: adminRoles },
|
|
103
|
+
});
|
|
102
104
|
|
|
103
105
|
// Revoke is hidden on the current session — it would sign the user out mid-click; logout covers it.
|
|
104
106
|
export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
collectPiiSubjectFields,
|
|
4
4
|
configuredPiiSubjectKms,
|
|
5
5
|
encryptPiiFieldValues,
|
|
6
|
+
KeyErasedError,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
8
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { subjectErasedConflict } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
10
|
|
|
9
11
|
// Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
|
|
10
12
|
// PII encryption never runs — every insert of subject-annotated fields must
|
|
@@ -18,12 +20,17 @@ export async function encryptForDirectWrite(
|
|
|
18
20
|
): Promise<Record<string, unknown>> {
|
|
19
21
|
const kms = configuredPiiSubjectKms();
|
|
20
22
|
if (!kms) return row;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
try {
|
|
24
|
+
return await encryptPiiFieldValues(
|
|
25
|
+
row,
|
|
26
|
+
entity,
|
|
27
|
+
collectPiiSubjectFields(entity),
|
|
28
|
+
kms,
|
|
29
|
+
{ requestId: requestContext.get()?.requestId ?? fallbackRequestId },
|
|
30
|
+
{ entityName },
|
|
31
|
+
);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (e instanceof KeyErasedError) throw subjectErasedConflict(e);
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
29
36
|
}
|
|
@@ -21,6 +21,7 @@ export { createLockoutCounter, type LockoutCounterState } from "./lockout-counte
|
|
|
21
21
|
export { mapWithConcurrency } from "./map-with-concurrency.js";
|
|
22
22
|
export {
|
|
23
23
|
denyUnlessJoinRowParentVisible,
|
|
24
|
+
hostRowIsGone,
|
|
24
25
|
joinRowParentIsVisible,
|
|
25
26
|
parentRowIsVisible,
|
|
26
27
|
} from "./parent-visibility.js";
|
|
@@ -38,7 +39,7 @@ export {
|
|
|
38
39
|
signRowBoundGrant,
|
|
39
40
|
} from "./row-bound-grant.js";
|
|
40
41
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
|
41
|
-
export { sessionLocaleField } from "./session-locale-field.js";
|
|
42
|
+
export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
|
|
42
43
|
export { sessionTimezoneField } from "./session-timezone-field.js";
|
|
43
44
|
export {
|
|
44
45
|
peekTokenSubject,
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
buildEntityTable,
|
|
3
|
+
type EventStoreExecutor,
|
|
4
|
+
type TenantDb,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
2
6
|
import {
|
|
3
7
|
createEntityExecutor,
|
|
4
8
|
type EntityDefinition,
|
|
@@ -61,6 +65,32 @@ export async function parentRowIsVisible(
|
|
|
61
65
|
return (await executor.detail({ id: entityId }, user, db)) !== null;
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
const hostTablesByEntity = new WeakMap<EntityDefinition, ReturnType<typeof buildEntityTable>>();
|
|
69
|
+
|
|
70
|
+
// True when the host row can never be read again by anyone: its entityType no
|
|
71
|
+
// longer names a registered entity, or the row is hard-deleted / soft-deleted.
|
|
72
|
+
// Deliberately skips the caller's `access.read` ownership (unlike
|
|
73
|
+
// parentRowIsVisible): a row hidden from this caller but alive for another team
|
|
74
|
+
// is NOT gone, and its join row must stay.
|
|
75
|
+
export async function hostRowIsGone(
|
|
76
|
+
registry: Registry,
|
|
77
|
+
entityType: string,
|
|
78
|
+
entityId: string,
|
|
79
|
+
db: TenantDb,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
const entity = registry.getEntity(entityType);
|
|
82
|
+
if (!entity) return true;
|
|
83
|
+
if (!idShapeMatchesEntity(entity, entityId)) return true;
|
|
84
|
+
|
|
85
|
+
let table = hostTablesByEntity.get(entity);
|
|
86
|
+
if (!table) {
|
|
87
|
+
table = buildEntityTable(entityType, entity, { relations: registry.getRelations(entityType) });
|
|
88
|
+
hostTablesByEntity.set(entity, table);
|
|
89
|
+
}
|
|
90
|
+
const row = await db.fetchOne(table, { id: entityId });
|
|
91
|
+
return row === undefined || row["isDeleted"] === true;
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
// Write-path half of the parentRef gate (fw#2766). Resolves the join entity's
|
|
65
95
|
// own `parentRef` declaration from the registry — the same object the read
|
|
66
96
|
// path's SQL gate reads — so the field names and the allowedTypes allowlist
|
|
@@ -54,6 +54,19 @@ function anchoredPurpose(purpose: string, anchor: string): string {
|
|
|
54
54
|
return `${purpose}:${anchor}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// The HMAC input is `${purpose}:${anchor}:${subject}.${expiresAtMs}` with no
|
|
58
|
+
// length prefixes. Colon-free anchor and subject make the split unambiguous
|
|
59
|
+
// from the right, so two different (purpose, anchor, subject) triples can
|
|
60
|
+
// never share a signature. The purpose may keep colons (tenant-handover
|
|
61
|
+
// namespaces its purpose by entity type).
|
|
62
|
+
function hasDelimiter(value: string): boolean {
|
|
63
|
+
return value.includes(":");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isUnambiguousPart(value: string | null | undefined): value is string {
|
|
67
|
+
return !!value && !hasDelimiter(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
export function signRowBoundGrant(args: {
|
|
58
71
|
readonly subject: string;
|
|
59
72
|
readonly purpose: string;
|
|
@@ -62,6 +75,9 @@ export function signRowBoundGrant(args: {
|
|
|
62
75
|
readonly secret: string;
|
|
63
76
|
readonly now?: Temporal.Instant;
|
|
64
77
|
}): { readonly token: string; readonly expiresAt: Temporal.Instant } {
|
|
78
|
+
if (hasDelimiter(args.anchor) || hasDelimiter(args.subject)) {
|
|
79
|
+
throw new Error("row-bound grant: subject and anchor must not contain ':'");
|
|
80
|
+
}
|
|
65
81
|
return signToken(
|
|
66
82
|
args.subject,
|
|
67
83
|
anchoredPurpose(args.purpose, args.anchor),
|
|
@@ -90,7 +106,7 @@ export async function redeemRowBoundGrant(args: {
|
|
|
90
106
|
if (!args.secret) return FAILED;
|
|
91
107
|
|
|
92
108
|
const subject = peekTokenSubject(args.token);
|
|
93
|
-
if (!subject) return FAILED;
|
|
109
|
+
if (!isUnambiguousPart(subject)) return FAILED;
|
|
94
110
|
|
|
95
111
|
// The subject is unverified attacker input at this point, so a lookup that
|
|
96
112
|
// throws on it (e.g. a non-uuid value against a uuid column) must not
|
|
@@ -107,7 +123,7 @@ export async function redeemRowBoundGrant(args: {
|
|
|
107
123
|
});
|
|
108
124
|
return FAILED;
|
|
109
125
|
}
|
|
110
|
-
if (!anchor) return FAILED;
|
|
126
|
+
if (!isUnambiguousPart(anchor)) return FAILED;
|
|
111
127
|
|
|
112
128
|
const verified = verifyToken(
|
|
113
129
|
args.token,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
1
2
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
4
|
|
|
@@ -7,3 +8,9 @@ export function sessionLocaleField(
|
|
|
7
8
|
if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
|
|
8
9
|
return { locale: canonicalizeLocaleTag(locale) };
|
|
9
10
|
}
|
|
11
|
+
|
|
12
|
+
// Only an explicit request signal (X-Locale) counts: the boot default is not a
|
|
13
|
+
// choice the new user made.
|
|
14
|
+
export function registrationLocaleFromRequest(): ReturnType<typeof sessionLocaleField> {
|
|
15
|
+
return sessionLocaleField(requestContext.get()?.locale);
|
|
16
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ctx.templateResolver at runtime.
|
|
4
4
|
|
|
5
5
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
-
import type {
|
|
6
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -76,7 +76,7 @@ export type TemplateResolverApi = {
|
|
|
76
76
|
readonly resolveTemplate: (args: ResolveRequest) => Promise<TemplateResource>;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
export function createTemplateResolverApi(db:
|
|
79
|
+
export function createTemplateResolverApi(db: DbRunner): TemplateResolverApi {
|
|
80
80
|
return {
|
|
81
81
|
findExact: async ({ tenantId, slug, kind, locale, scope }) => {
|
|
82
82
|
const effectiveTenantId = scope === "system" ? SYSTEM_TENANT_ID : tenantId;
|
|
@@ -106,7 +106,7 @@ export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
async function fetchTemplate(
|
|
109
|
-
db:
|
|
109
|
+
db: DbRunner,
|
|
110
110
|
tenantId: string,
|
|
111
111
|
slug: string,
|
|
112
112
|
kind: TemplateKind,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
|
|
5
5
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
6
6
|
|
|
7
7
|
// List für Admin-UI: filterbar nach kind / locale / status. Liefert
|
|
@@ -10,7 +10,7 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
|
10
10
|
export const listQuery = defineQueryHandler({
|
|
11
11
|
name: "list",
|
|
12
12
|
schema: z.object({
|
|
13
|
-
kind: z.enum(
|
|
13
|
+
kind: z.enum(TEMPLATE_KINDS).optional(),
|
|
14
14
|
locale: z.string().min(2).max(8).optional(),
|
|
15
15
|
status: z.enum(TEMPLATE_STATUSES).optional(),
|
|
16
16
|
includeSystem: z.boolean().default(true),
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "createTenantFeature fails the boot when a declared assignable app role is missing from its assignableAppRoles option",
|
|
6
|
+
"detail": "Final review batch E1: bundled features\n`createTenantFeature` now fails the boot when an `assignableRole` declaration is missing from its `assignableAppRoles` option, because the members and invite screens would otherwise omit the role and a save of the member-roles form would silently strip it. `composeFeatures(includeBundled)` already passes the option. Agent-callable handlers that change access or books now resolve risk `high`: `tenant:write:disable`, `tenant:write:updateMemberRoles`, `user:write:user:update`, `tier-engine:write:set-tenant-tier`, `ledger:write:create-transaction` and `ledger:write:reverse-transaction`. `in_app_messages` gets a `(tenant_id, user_id, created_at)` index. `GET /files/:id/download-url` no longer puts a personal `fileName` into the signed URL; the hint carries a neutral name derived from the MIME type, while `GET /files/:id` still sends the real name in its response header. The forget-subject denial audit event goes through the shared `appendDomainEventCore`, which the pipeline barrel now exports.",
|
|
7
|
+
"migration": "Pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature when you mount it by hand; composeFeatures(includeBundled) already does."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.354.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "tenant:write:disable and tenant:write:updateMemberRoles resolve agent risk high"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.353.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -81,8 +81,15 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
|
|
|
81
81
|
r.systemScope();
|
|
82
82
|
r.requires("config");
|
|
83
83
|
r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
|
|
84
|
+
// The elevation guards read the registry, the screens only these options: a role the
|
|
85
|
+
// server accepts but the screen omits is silently stripped on the next member-roles save.
|
|
84
86
|
r.bootCheck(({ features }) => {
|
|
85
|
-
collectAssignableAppRoles(features)
|
|
87
|
+
for (const [role, assignableFrom] of collectAssignableAppRoles(features)) {
|
|
88
|
+
if (options?.assignableAppRoles?.get(role) === assignableFrom) continue;
|
|
89
|
+
throw new Error(
|
|
90
|
+
`[tenant] assignable app role "${role}" (assignableFrom "${assignableFrom}") is declared but missing from the tenant screens' options; pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
86
93
|
});
|
|
87
94
|
r.entity("tenant", tenantEntity);
|
|
88
95
|
r.entity("tenant-membership", tenantMembershipEntity);
|
|
@@ -14,6 +14,8 @@ function createToggleTenantHandler(enable: boolean) {
|
|
|
14
14
|
name: verbName,
|
|
15
15
|
schema: z.object({ id: z.uuid() }),
|
|
16
16
|
access: { roles: ["SystemAdmin"] },
|
|
17
|
+
// Disabling locks every member out; enabling is its undo.
|
|
18
|
+
...(enable ? {} : { agent: { risk: "high" as const } }),
|
|
17
19
|
description: enable
|
|
18
20
|
? "Re-enables a disabled tenant so its members can sign in and switch to it again; this is the undo for tenant:write:disable."
|
|
19
21
|
: "Marks a tenant disabled so it stops counting as a membership — its members can no longer sign in to it or switch to it — while leaving all of its data in place.",
|
|
@@ -72,6 +72,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
72
72
|
access: { roles: ["system", ...access.admin] },
|
|
73
73
|
description:
|
|
74
74
|
"Sets the roles a user holds in a tenant, rejecting reserved role names and any grant that would raise the target above the caller; roles the caller could not grant (platform roles such as DataProtectionOfficer or TenantOwner, undeclared or higher-tier app roles) are kept, only the system user replaces the full list; admins act on their own tenant, SystemAdmins on any.",
|
|
75
|
+
agent: { risk: "high" },
|
|
75
76
|
handler: async (event, ctx) => {
|
|
76
77
|
if (!ctx.systemDb) {
|
|
77
78
|
throw new InternalError({
|
|
@@ -26,6 +26,7 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
26
26
|
import {
|
|
27
27
|
createEntity,
|
|
28
28
|
createMultiSelectField,
|
|
29
|
+
createNumberField,
|
|
29
30
|
createSelectField,
|
|
30
31
|
createTextField,
|
|
31
32
|
createTimestampField,
|
|
@@ -94,6 +95,11 @@ export const tenantInvitationEntity = createEntity({
|
|
|
94
95
|
personal: { of: "invitedBy" },
|
|
95
96
|
find: "exact",
|
|
96
97
|
}),
|
|
98
|
+
// Version of the invitee's membership row when the invitation was issued
|
|
99
|
+
// (0 = no membership yet). Accept compares it against the current version
|
|
100
|
+
// to detect a membership decision made after the invite. NULL only on rows
|
|
101
|
+
// issued before this field existed.
|
|
102
|
+
membershipVersion: createNumberField({ integer: true, min: 0 }),
|
|
97
103
|
// UI-Anzeige — Wahrheit liegt in Redis-TTL.
|
|
98
104
|
expiresAt: createTimestampField({ required: true }),
|
|
99
105
|
},
|