@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.335.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.
Files changed (242) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  30. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  31. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  32. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  33. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  34. package/dist/auth-mfa/web/mfa-client.js +4 -10
  35. package/dist/billing-foundation/billing-info-query.js +1 -1
  36. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  37. package/dist/billing-foundation/checkout-core.js +10 -6
  38. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  39. package/dist/cap-counter/book-cap-usage.js +15 -5
  40. package/dist/cap-counter/enforce-cap.js +9 -3
  41. package/dist/cap-counter/feature.js +3 -2
  42. package/dist/cap-counter/i18n.d.ts +1 -0
  43. package/dist/cap-counter/i18n.js +6 -5
  44. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  45. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  46. package/dist/cap-overview/i18n.js +5 -0
  47. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  48. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  49. package/dist/channel-email/email-channel.js +12 -1
  50. package/dist/channel-in-app/tables.js +2 -6
  51. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  52. package/dist/config/db/queries/resolver.js +3 -1
  53. package/dist/config/handlers/set.write.js +8 -4
  54. package/dist/config/web/i18n.js +1 -0
  55. package/dist/config/write-helpers.d.ts +1 -1
  56. package/dist/config/write-helpers.js +11 -5
  57. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  58. package/dist/custom-fields/constants.js +1 -4
  59. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  60. package/dist/data-retention/run-retention-cleanup.js +100 -44
  61. package/dist/delivery/types.d.ts +2 -1
  62. package/dist/delivery/types.js +7 -0
  63. package/dist/delivery/unsubscribe.js +5 -11
  64. package/dist/derivatives-sharp/render.js +27 -9
  65. package/dist/document-ingest-foundation/feature.js +5 -5
  66. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  67. package/dist/document-ingest-foundation/providers.js +6 -0
  68. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  69. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  70. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  71. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  72. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  73. package/dist/files-tenant-data/index.js +3 -2
  74. package/dist/folders/handlers/clear-folder.write.js +4 -11
  75. package/dist/folders/handlers/set-folder.write.js +4 -10
  76. package/dist/form-draft/feature.js +3 -2
  77. package/dist/form-draft/handlers/discard.write.js +7 -8
  78. package/dist/form-draft/handlers/get.query.js +7 -1
  79. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  80. package/dist/foundation-shared/host-allowlist.js +14 -0
  81. package/dist/foundation-shared/index.d.ts +1 -0
  82. package/dist/foundation-shared/index.js +1 -0
  83. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  84. package/dist/foundation-shared/mail-host-policy.js +3 -13
  85. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  86. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  87. package/dist/jobs/handlers/list.query.d.ts +7 -2
  88. package/dist/jobs/handlers/list.query.js +9 -2
  89. package/dist/jobs/handlers/retry.write.js +3 -2
  90. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  91. package/dist/jobs/job-run-logger.js +18 -2
  92. package/dist/jobs/tenant-job-failure-table.js +2 -6
  93. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  94. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  95. package/dist/ledger/subject-reference.d.ts +4 -0
  96. package/dist/ledger/subject-reference.js +7 -0
  97. package/dist/legal-pages/feature.js +8 -1
  98. package/dist/managed-pages/handlers/branding.query.js +1 -1
  99. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  100. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  101. package/dist/metrics/compute.js +7 -1
  102. package/dist/metrics/window.js +5 -0
  103. package/dist/notes-history/handlers/add-note.write.js +4 -9
  104. package/dist/notes-history-user-data/hooks.js +8 -1
  105. package/dist/personal-access-tokens/constants.d.ts +1 -0
  106. package/dist/personal-access-tokens/constants.js +1 -0
  107. package/dist/personal-access-tokens/feature.js +2 -2
  108. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  109. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  110. package/dist/personal-access-tokens/i18n.js +12 -13
  111. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  112. package/dist/personal-access-tokens/screens.js +5 -1
  113. package/dist/rate-limiting/feature.js +2 -0
  114. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  115. package/dist/rate-limiting/i18n.d.ts +10 -0
  116. package/dist/rate-limiting/i18n.js +10 -0
  117. package/dist/secrets/feature.js +3 -2
  118. package/dist/seo/handlers/seo-config.query.js +1 -1
  119. package/dist/sessions/constants.d.ts +1 -0
  120. package/dist/sessions/constants.js +1 -0
  121. package/dist/sessions/feature.js +5 -3
  122. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  123. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  124. package/dist/sessions/handlers/revoke.write.js +2 -2
  125. package/dist/sessions/schema/user-session.js +2 -1
  126. package/dist/sessions/screens.js +5 -1
  127. package/dist/sessions/session-callbacks.js +2 -2
  128. package/dist/shared/index.d.ts +1 -1
  129. package/dist/shared/index.js +1 -1
  130. package/dist/shared/lockout-counter.js +1 -1
  131. package/dist/shared/parent-visibility.d.ts +5 -0
  132. package/dist/shared/parent-visibility.js +17 -1
  133. package/dist/shared/row-bound-grant.js +20 -9
  134. package/dist/shared/single-use-token-store.js +5 -13
  135. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  136. package/dist/step-dispatcher/webhook-runner.js +18 -17
  137. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  138. package/dist/subscription-mollie/plugin-methods.js +7 -1
  139. package/dist/subscription-mollie/verify-webhook.js +6 -0
  140. package/dist/subscription-stripe/plugin-methods.js +25 -6
  141. package/dist/subscription-stripe/verify-webhook.js +13 -6
  142. package/dist/tags/handlers/assign-tag.write.js +4 -10
  143. package/dist/tags/handlers/remove-tag.write.js +4 -11
  144. package/dist/template-resolver/api.d.ts +2 -1
  145. package/dist/template-resolver/api.js +1 -1
  146. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  147. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  148. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  149. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  150. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  151. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  152. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  153. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  154. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  155. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  156. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  157. package/dist/template-resolver/handlers/list.query.js +1 -1
  158. package/dist/template-resolver/seeding.js +41 -39
  159. package/dist/template-resolver/table.d.ts +8 -5
  160. package/dist/template-resolver/testing.js +2 -2
  161. package/dist/template-resolver/user-content-table.d.ts +2 -5
  162. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  163. package/dist/template-resolver-user-data/hooks.js +1 -1
  164. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  165. package/dist/tenant/cancel-pending-invitation.js +22 -0
  166. package/dist/tenant/feature.js +4 -2
  167. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +17 -4
  170. package/dist/tenant/handlers/remove-member.write.js +9 -14
  171. package/dist/tenant/handlers/team-list.query.js +2 -2
  172. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  173. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  174. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  175. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  176. package/dist/tenant/i18n.js +4 -0
  177. package/dist/tenant/index.d.ts +1 -1
  178. package/dist/tenant/index.js +1 -1
  179. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  180. package/dist/tenant/last-tenant-admin.js +5 -3
  181. package/dist/tenant/seeding.d.ts +4 -0
  182. package/dist/tenant/seeding.js +9 -2
  183. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  184. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  185. package/dist/tenant-lifecycle/feature.js +2 -1
  186. package/dist/tenant-lifecycle/stages.js +2 -2
  187. package/dist/tier-engine/feature.js +3 -2
  188. package/dist/user/command-schemas.d.ts +2 -2
  189. package/dist/user/handlers/create.write.d.ts +1 -1
  190. package/dist/user/handlers/create.write.js +8 -2
  191. package/dist/user/handlers/update.write.d.ts +1 -1
  192. package/dist/user/handlers/update.write.js +8 -2
  193. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  194. package/dist/user-data-rights/feature.js +22 -9
  195. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  196. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  197. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  198. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  199. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  200. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  201. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  202. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  203. package/dist/user-data-rights/run-export-jobs.js +74 -41
  204. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  205. package/dist/user-data-rights/run-user-export.js +2 -2
  206. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  207. package/dist/user-profile/feature.js +6 -1
  208. package/dist/user-profile/handlers/change-email.write.js +2 -0
  209. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  210. package/dist/workflow-runner/event-subscriber.js +29 -9
  211. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  212. package/dist/workflow-runner/pending-projection.js +8 -1
  213. package/package.json +11 -9
  214. package/src/agent-tools/changes.json +12 -0
  215. package/src/auth-email-password/changes.json +18 -0
  216. package/src/auth-mfa/changes.json +6 -0
  217. package/src/billing-foundation/changes.json +7 -0
  218. package/src/cap-counter/changes.json +7 -0
  219. package/src/cap-overview/changes.json +12 -0
  220. package/src/channel-email/changes.json +6 -0
  221. package/src/config/changes.json +6 -0
  222. package/src/crypto-shredding/changes.json +6 -0
  223. package/src/data-retention/changes.json +12 -0
  224. package/src/delivery/changes.json +7 -0
  225. package/src/derivatives-sharp/changes.json +11 -0
  226. package/src/document-ingest-foundation/changes.json +12 -0
  227. package/src/inbound-mail-foundation/changes.json +12 -0
  228. package/src/jobs/changes.json +13 -0
  229. package/src/legal-pages/changes.json +8 -1
  230. package/src/managed-pages/changes.json +5 -0
  231. package/src/metrics/changes.json +6 -0
  232. package/src/rate-limiting/changes.json +5 -0
  233. package/src/secrets/changes.json +7 -0
  234. package/src/sessions/changes.json +5 -0
  235. package/src/shared/changes.json +6 -0
  236. package/src/step-dispatcher/changes.json +5 -0
  237. package/src/subscription-mollie/changes.json +7 -0
  238. package/src/subscription-stripe/changes.json +11 -0
  239. package/src/template-resolver/changes.json +7 -0
  240. package/src/tenant/changes.json +29 -0
  241. package/src/user-data-rights/changes.json +22 -0
  242. package/src/workflow-runner/changes.json +6 -0
@@ -15,7 +15,7 @@ export const bySlugQuery = defineQueryHandler({
15
15
  lang: z.string().min(2).max(8),
16
16
  }),
17
17
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
18
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
18
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
19
19
  description: "Reads the body and metadata of one published managed page of the caller's tenant by slug and language, returning null for drafts and bodyless pages; use it to render a public page, not to load one for editing.",
20
20
  handler: async (query, ctx) => {
21
21
  const row = await fetchOne(ctx.db, pagesTable, {
@@ -11,7 +11,7 @@ export const byTenantPublishedQuery = defineQueryHandler({
11
11
  tenantIdOverride: z.string().min(1).optional(),
12
12
  }),
13
13
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
14
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
14
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
15
15
  description: "Lists every published page of a tenant with slug, language, title and last-change time but no body; use it to enumerate the public pages for sitemap.xml or llms.txt, rather than by-slug which fetches one page's content.",
16
16
  handler: async (query, ctx) => {
17
17
  const override = query.payload.tenantIdOverride;
@@ -111,7 +111,13 @@ export async function runMetric(db, metric, params) {
111
111
  ? bucketStartsMs(window, bucket, params.timeZone)
112
112
  : [];
113
113
  const fillBuckets = (byBucketMs) => bucketStarts.map((atMs) => ({ atMs, value: byBucketMs.get(atMs) ?? gapValue }));
114
- const points = fillBuckets(new Map(aggregates.points.map((row) => [Number(row.keys[0]), row.value])));
114
+ // Grouped metrics carry their time series in `series`; fetchAggregates skips the total series, so filling gaps here would fake a flat zero line.
115
+ const points = metric.groupBy === undefined
116
+ ? fillBuckets(new Map(aggregates.points.map((row) => [
117
+ Number(row.keys[0]),
118
+ row.value,
119
+ ])))
120
+ : [];
115
121
  const rows = buildRows(metric, aggregates, value, labelOf);
116
122
  return {
117
123
  value,
@@ -12,7 +12,12 @@ const RANGE_DAYS = {
12
12
  "14d": 14,
13
13
  "30d": 30,
14
14
  };
15
+ // Offset strings ("+01:00") pass Intl/Temporal as ISO offsets but Postgres
16
+ // date_trunc reads them as POSIX zones with the opposite sign, so only named
17
+ // zones are accepted.
15
18
  export function isValidTimeZone(timeZone) {
19
+ if (/^[+-]/.test(timeZone))
20
+ return false;
16
21
  try {
17
22
  new Intl.DateTimeFormat(undefined, { timeZone });
18
23
  return true;
@@ -1,6 +1,5 @@
1
1
  import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
- import { decryptStoredPii, joinRowParentIsVisible } from "../../shared/index.js";
2
+ import { decryptStoredPii, denyUnlessJoinRowParentVisible } from "../../shared/index.js";
4
3
  import { userTable } from "../../user/index.js";
5
4
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants.js";
6
5
  import { noteEntryExecutor, noteMentionExecutor } from "../executor.js";
@@ -29,13 +28,9 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
29
28
  },
30
29
  handler: async (event, ctx) => {
31
30
  const payload = event.payload; // @cast-boundary engine-payload
32
- // NotFoundError, not an access-denied error, so the response doesn't
33
- // double as an existence oracle — same policy as executor.detail,
34
- // which never distinguishes "no access" from "doesn't exist".
35
- const visible = await joinRowParentIsVisible(ctx.registry, "note-entry", payload, event.user, ctx.db);
36
- if (!visible) {
37
- return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
38
- }
31
+ const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "note-entry", payload, event.user, ctx.db);
32
+ if (denied)
33
+ return denied;
39
34
  let authorName = null;
40
35
  try {
41
36
  // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
@@ -4,7 +4,7 @@
4
4
  // dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
5
5
  // export-only, erasure via crypto-shredding.
6
6
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
7
- import { resolveRetentionPolicyForTenant } from "../data-retention/index.js";
7
+ import { resolveRetentionPolicyForTenant, resolveTenantRetentionPreset, } from "../data-retention/index.js";
8
8
  import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
9
9
  import { policyToStrategy } from "../user-data-rights/index.js";
10
10
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
@@ -58,6 +58,12 @@ export const noteEntryDeleteHook = async (ctx) => {
58
58
  if (mentions.length === 0)
59
59
  return;
60
60
  const noteIds = new Set(mentions.map((m) => m.noteId));
61
+ // Once per hook run: the preset depends on the tenant only, not the note.
62
+ const tenantPreset = await resolveTenantRetentionPreset({
63
+ db: ctx.db,
64
+ registry: ctx.registry,
65
+ tenantId: ctx.tenantId,
66
+ });
61
67
  for (const noteId of noteIds) {
62
68
  const note = await ctx.db.fetchOne(noteEntryTable, { id: noteId });
63
69
  // Defensive: append-only rows are never hard-deleted, so this shouldn't
@@ -69,6 +75,7 @@ export const noteEntryDeleteHook = async (ctx) => {
69
75
  registry: ctx.registry,
70
76
  tenantId: ctx.tenantId,
71
77
  entityName: note.entityType,
78
+ preloadedTenantPreset: tenantPreset,
72
79
  });
73
80
  if (policyToStrategy(hostPolicy.policy?.strategy ?? null) === "anonymize")
74
81
  continue;
@@ -1,4 +1,5 @@
1
1
  export declare const PAT_FEATURE = "personal-access-tokens";
2
+ export declare const API_TOKEN_ENTITY_NAME: "api-token";
2
3
  export declare const PatErrors: {
3
4
  readonly ownershipDenied: "ownership_denied";
4
5
  readonly reauthRequired: "reauth_required";
@@ -2,6 +2,7 @@
2
2
  // Pure constants — client-marked so the web screen (web/) may import screen-id
3
3
  // and QN names without pulling the feature's server runtime barrel.
4
4
  export const PAT_FEATURE = "personal-access-tokens";
5
+ export const API_TOKEN_ENTITY_NAME = "api-token";
5
6
  // Snake_case reason strings (Error-Reasons guard: no colons/dashes).
6
7
  export const PatErrors = {
7
8
  ownershipDenied: "ownership_denied",
@@ -2,7 +2,7 @@ import { EXT_TOKEN_VERIFIER, } from "@cosmicdrift/kumiko-bundled-features/auth-f
2
2
  import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
3
3
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
5
- import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE } from "./constants.js";
5
+ import { API_TOKEN_ENTITY_NAME, PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, } from "./constants.js";
6
6
  import { buildAvailableScopesQuery } from "./handlers/available-scopes.query.js";
7
7
  import { createPatCreateHandler } from "./handlers/create.write.js";
8
8
  import { listPatQuery } from "./handlers/list.query.js";
@@ -62,7 +62,7 @@ export function createPersonalAccessTokensFeature(options) {
62
62
  // Direct-write store like store_user_sessions: create/revoke write it, the
63
63
  // resolver point-reads it. r.entity would make it a rebuildable projection
64
64
  // whose replay (no token events) would wipe every live token (#498/#494).
65
- r.storeTable(deriveEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
65
+ r.storeTable(deriveEntityTableMeta(API_TOKEN_ENTITY_NAME, apiTokenEntity, { source: "unmanaged" }), {
66
66
  reason: "read_side.api_tokens_direct_write",
67
67
  // create.write encrypts `name` via encryptForDirectWrite (#820).
68
68
  piiEncryptedOnWrite: true,
@@ -6,7 +6,7 @@ import { Temporal } from "temporal-polyfill";
6
6
  import * as z from "zod";
7
7
  import { encryptForDirectWrite, verifyPassword } from "../../shared/index.js";
8
8
  import { UserQueries } from "../../user/index.js";
9
- import { PAT_DEFAULT_EXPIRES_IN_DAYS, PatErrors } from "../constants.js";
9
+ import { API_TOKEN_ENTITY_NAME, PAT_DEFAULT_EXPIRES_IN_DAYS, PatErrors } from "../constants.js";
10
10
  import { mintPatToken } from "../hash.js";
11
11
  import { apiTokenEntity, apiTokenTable } from "../schema/api-token.js";
12
12
  // @wrapper-known error-helper
@@ -67,7 +67,7 @@ export function createPatCreateHandler(opts = {}) {
67
67
  const { raw, hash, prefix } = mintPatToken();
68
68
  const now = Temporal.Now.instant();
69
69
  const id = generateId();
70
- const row = await encryptForDirectWrite(apiTokenEntity, "api-token", {
70
+ const row = await encryptForDirectWrite(apiTokenEntity, API_TOKEN_ENTITY_NAME, {
71
71
  id,
72
72
  userId: event.user.id,
73
73
  tenantId: event.user.tenantId,
@@ -4,7 +4,7 @@ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "temporal-polyfill";
6
6
  import * as z from "zod";
7
- import { PatErrors } from "../constants.js";
7
+ import { API_TOKEN_ENTITY_NAME, PatErrors } from "../constants.js";
8
8
  import { PAT_REVOKED_AGGREGATE_TYPE, PAT_REVOKED_EVENT_QN } from "../pat-revoked-event.js";
9
9
  import { apiTokenTable } from "../schema/api-token.js";
10
10
  // Revoke one of the caller's own tokens. Ownership is enforced in the WHERE
@@ -43,7 +43,7 @@ export const revokePatWrite = defineWriteHandler({
43
43
  i18nKey: "errors.ownershipDenied",
44
44
  details: {
45
45
  scope: "entity",
46
- entityName: "api-token",
46
+ entityName: API_TOKEN_ENTITY_NAME,
47
47
  action: "revoke",
48
48
  userId: event.user.id,
49
49
  },
@@ -1,3 +1,8 @@
1
+ import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
3
+ import { PAT_FEATURE } from "./constants.js";
4
+ const mintFieldKey = (field) => fieldLabelKey(PAT_FEATURE, ACTION_FORM_ENTITY, field);
5
+ const scopeOptionKey = (grant) => fieldOptionLabelKey(PAT_FEATURE, ACTION_FORM_ENTITY, "scopes", grant);
1
6
  export const PAT_FEATURE_I18N = {
2
7
  "screen:api-tokens.title": { en: "Personal Access Tokens" },
3
8
  "screen:api-token-create.title": { en: "Create a new token" },
@@ -23,13 +28,11 @@ export const PAT_FEATURE_I18N = {
23
28
  // (`__action-form__`, see action-form-shim.ts) — required by the i18n
24
29
  // boot-validator (requiredKeysFromScreen); the declarative renderer
25
30
  // resolves these through the schema payload, not a client component's t().
26
- "personal-access-tokens:entity:__action-form__:field:name": { en: "Name" },
27
- "personal-access-tokens:entity:__action-form__:field:scopes": { en: "Per-API access" },
28
- "personal-access-tokens:entity:__action-form__:field:expiresInDays": { en: "Expires in (days)" },
29
- "personal-access-tokens:entity:__action-form__:field:currentPassword": {
30
- en: "Password (to confirm)",
31
- },
32
- "personal-access-tokens:entity:__action-form__:field:mfaCode": { en: "2FA code (if enabled)" },
31
+ [mintFieldKey("name")]: { en: "Name" },
32
+ [mintFieldKey("scopes")]: { en: "Per-API access" },
33
+ [mintFieldKey("expiresInDays")]: { en: "Expires in (days)" },
34
+ [mintFieldKey("currentPassword")]: { en: "Password (to confirm)" },
35
+ [mintFieldKey("mfaCode")]: { en: "2FA code (if enabled)" },
33
36
  };
34
37
  // Per-grant-option labels for the mint form's `scopes` multiSelect —
35
38
  // "<domain> (read)" / "<domain> (read & write)", matching the wording the
@@ -38,13 +41,9 @@ export const PAT_FEATURE_I18N = {
38
41
  export function patScopeOptionTranslations(scopes) {
39
42
  const out = {};
40
43
  for (const [domain, def] of Object.entries(scopes)) {
41
- out[`personal-access-tokens:entity:__action-form__:field:scopes:option:${domain}:read`] = {
42
- en: `${def.label} (read)`,
43
- };
44
+ out[scopeOptionKey(`${domain}:read`)] = { en: `${def.label} (read)` };
44
45
  if (def.write && def.write.length > 0) {
45
- out[`personal-access-tokens:entity:__action-form__:field:scopes:option:${domain}:write`] = {
46
- en: `${def.label} (read & write)`,
47
- };
46
+ out[scopeOptionKey(`${domain}:write`)] = { en: `${def.label} (read & write)` };
48
47
  }
49
48
  }
50
49
  return out;
@@ -1,5 +1,6 @@
1
1
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, createEntity, createTextField, createTimestampField, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { API_TOKEN_ENTITY_NAME } from "../constants.js";
3
4
  // One row per Personal Access Token. Like store_user_sessions this is a
4
5
  // direct-write store (r.unmanagedTable): the create/revoke handlers write it and
5
6
  // the resolver point-reads it on the hot auth path. `tokenHash` is the SHA-256
@@ -80,6 +81,6 @@ export const apiTokenEntity = createEntity({
80
81
  // deriveEntityTableMeta (not buildEntityTable): this is a direct-write store, so
81
82
  // the table must be a WritableTable (post ES-write-brand #742) — same as
82
83
  // sessions' userSessionTable. buildEntityTable is branded executor-only.
83
- export const apiTokenTable = deriveEntityTableMeta("api-token", apiTokenEntity, {
84
+ export const apiTokenTable = deriveEntityTableMeta(API_TOKEN_ENTITY_NAME, apiTokenEntity, {
84
85
  source: "unmanaged",
85
86
  });
@@ -1,10 +1,14 @@
1
- import { i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { i18nKey, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants.js";
3
3
  const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
4
4
  export const patListScreen = {
5
5
  id: PAT_SCREEN_ID,
6
6
  type: "projectionList",
7
7
  query: PatQueries.mine,
8
+ // The `mine` handler honours `limit` only (no offset/total), so a pager would
9
+ // show page 1 forever: send one max-size request and render no pager.
10
+ pagination: false,
11
+ pageSize: MAX_LIST_LIMIT,
8
12
  defaultSort: { field: "createdAt", dir: "desc" },
9
13
  columns: [
10
14
  { field: "name", label: i18nKey("pat.list.col.name") },
@@ -1,5 +1,6 @@
1
1
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { rateLimitStatus } from "./handlers/status.query.js";
3
+ import { RATE_LIMITING_I18N } from "./i18n.js";
3
4
  // Opt-in feature. Loading it does NOT install rate-limit middleware —
4
5
  // the framework auto-wires the L3 dispatcher hook and the resolver
5
6
  // when (a) at least one handler declared a rateLimit option, OR (b) the
@@ -17,5 +18,6 @@ export function createRateLimitingFeature() {
17
18
  recommended: false,
18
19
  });
19
20
  r.queryHandler(rateLimitStatus);
21
+ r.translations({ keys: RATE_LIMITING_I18N });
20
22
  }, { dedupeOptions: {} });
21
23
  }
@@ -1,23 +1,31 @@
1
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
1
2
  import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
2
3
  import { RateLimitErrors } from "../constants.js";
4
+ // Typed against RateLimitPer so a renamed dimension in bucket.ts
5
+ // breaks the typecheck instead of silently denying access.
6
+ const BUCKET_OWNER_KIND = {
7
+ tenant: "tenant",
8
+ "tenant+handler": "tenant",
9
+ user: "user",
10
+ "user+handler": "user",
11
+ };
12
+ function isOwnableDimension(dimension) {
13
+ return Object.hasOwn(BUCKET_OWNER_KIND, dimension);
14
+ }
3
15
  function ownsBucket(bucket, caller) {
4
16
  const [dimension, subject, ...handler] = bucket.split(":");
5
17
  if (dimension === undefined)
6
18
  return false;
7
- const owner = dimension === "tenant" || dimension === "tenant+handler"
8
- ? caller.tenantId
9
- : dimension === "user" || dimension === "user+handler"
10
- ? caller.id
11
- : undefined;
12
- if (owner === undefined)
19
+ if (!isOwnableDimension(dimension))
13
20
  return false;
21
+ const owner = BUCKET_OWNER_KIND[dimension] === "tenant" ? caller.tenantId : caller.id;
14
22
  // Segment-exact: a startsWith check would pass `tenant:<own-id>-other`.
15
23
  if (subject !== owner)
16
24
  return false;
17
25
  return dimension.endsWith("+handler") ? handler.length > 0 : handler.length === 0;
18
26
  }
19
27
  export function bucketAccessDenied(bucket, caller) {
20
- if (caller.roles.includes("SystemAdmin"))
28
+ if (caller.roles.includes(ROLES.SystemAdmin))
21
29
  return undefined;
22
30
  if (ownsBucket(bucket, caller))
23
31
  return undefined;
@@ -0,0 +1,10 @@
1
+ export declare const RATE_LIMITING_I18N: {
2
+ readonly "rateLimiting.errors.bucketOutsideTenant": {
3
+ readonly en: "You can only inspect rate-limit buckets that belong to your own tenant or user.";
4
+ readonly de: "Du kannst nur Rate-Limit-Buckets deines eigenen Mandanten oder Benutzers einsehen.";
5
+ };
6
+ readonly "rateLimiting.errors.resolverUnavailable": {
7
+ readonly en: "Rate limiting is not available on this server.";
8
+ readonly de: "Rate-Limiting ist auf diesem Server nicht verfügbar.";
9
+ };
10
+ };
@@ -0,0 +1,10 @@
1
+ export const RATE_LIMITING_I18N = {
2
+ "rateLimiting.errors.bucketOutsideTenant": {
3
+ en: "You can only inspect rate-limit buckets that belong to your own tenant or user.",
4
+ de: "Du kannst nur Rate-Limit-Buckets deines eigenen Mandanten oder Benutzers einsehen.",
5
+ },
6
+ "rateLimiting.errors.resolverUnavailable": {
7
+ en: "Rate limiting is not available on this server.",
8
+ de: "Rate-Limiting ist auf diesem Server nicht verfügbar.",
9
+ },
10
+ };
@@ -9,6 +9,7 @@ import { rotateJob } from "./handlers/rotate.job.js";
9
9
  import { createSetHandler } from "./handlers/set.write.js";
10
10
  import { secretReadSchema } from "./secrets-context.js";
11
11
  import { tenantSecretEntity, tenantSecretsTable } from "./table.js";
12
+ const ROTATE_ESCAPE_HATCH_REASON = "re-encrypts every tenant's secrets with the new master key";
12
13
  /**
13
14
  * Env-vars contract for the `secrets` feature. Apps merge this via
14
15
  * `composeEnvSchema({ features: [secretsFeature, ...] })` so boot-time
@@ -130,9 +131,9 @@ export function createSecretsFeature(opts = {}) {
130
131
  name: "rotate",
131
132
  trigger: { manual: true },
132
133
  escapeHatch: {
133
- reason: "re-encrypts every tenant's secrets with the new master key",
134
+ reason: ROTATE_ESCAPE_HATCH_REASON,
134
135
  },
135
- handler: (payload, ctx) => rotateJob(payload, ctx, ctx.db.unsafeRaw("re-encrypts every tenant's secrets with the new master key")),
136
+ handler: (payload, ctx) => rotateJob(payload, ctx, ctx.db.unsafeRaw(ROTATE_ESCAPE_HATCH_REASON)),
136
137
  });
137
138
  // Pre-ES had a separate `retention-cleanup` job scrubbing the audit
138
139
  // table on a compliance-driven schedule (90d default). Post-ES the
@@ -17,7 +17,7 @@ export const seoConfigQuery = defineQueryHandler({
17
17
  name: "config",
18
18
  schema: z.object({}),
19
19
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
20
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
20
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
21
21
  description: "Reads the tenant's SEO metadata settings — organization name and logo URL, Twitter site handle, LLMs summary and default OG image — as plain strings; use it to inspect what the sitemap, llms.txt and page meta tags will publish.",
22
22
  handler: async (_query, ctx) => {
23
23
  const read = async (qualifiedKey) => {
@@ -1,4 +1,5 @@
1
1
  export declare const SESSIONS_FEATURE: "sessions";
2
+ export declare const USER_SESSION_ENTITY_NAME: "user-session";
2
3
  export declare const SessionHandlers: {
3
4
  readonly revoke: "sessions:write:user-session:revoke";
4
5
  readonly revokeAllOthers: "sessions:write:user-session:revoke-all-others";
@@ -1,4 +1,5 @@
1
1
  export const SESSIONS_FEATURE = "sessions";
2
+ export const USER_SESSION_ENTITY_NAME = "user-session";
2
3
  // Qualified write handler names — entity prefix is "userSession", snake_case
3
4
  // "user_session" on the wire.
4
5
  export const SessionHandlers = {
@@ -1,6 +1,7 @@
1
1
  import { EXT_SESSION_STORE, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
2
2
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { USER_SESSION_ENTITY_NAME } from "./constants.js";
4
5
  import { cleanupJob } from "./handlers/cleanup.job.js";
5
6
  import { detailQuery } from "./handlers/detail.query.js";
6
7
  import { listQuery } from "./handlers/list.query.js";
@@ -13,6 +14,7 @@ import { userSessionEntity } from "./schema/user-session.js";
13
14
  import { sessionDetailScreen, sessionListScreen, sessionMineScreen } from "./screens.js";
14
15
  import { createSessionCallbacks, } from "./session-callbacks.js";
15
16
  import { SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema } from "./session-revoked-event.js";
17
+ const CLEANUP_ESCAPE_HATCH_REASON = "deletes expired session rows of every tenant";
16
18
  // Reads the late-bind setter off a mounted sessions feature's exports.
17
19
  // run{Prod,Dev}App call it once the DB connection is concrete — the feature
18
20
  // itself is constructed in app run-config long before a db exists, so the
@@ -66,7 +68,7 @@ export function createSessionsFeature(options) {
66
68
  // (#498/#494). r.storeTable keeps the migration DDL but opts the
67
69
  // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
68
70
  // which are direct-write stores too.
69
- r.storeTable(deriveEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
71
+ r.storeTable(deriveEntityTableMeta(USER_SESSION_ENTITY_NAME, userSessionEntity, { source: "unmanaged" }), {
70
72
  reason: "read_side.user_sessions_direct_write",
71
73
  // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
72
74
  piiEncryptedOnWrite: true,
@@ -118,8 +120,8 @@ export function createSessionsFeature(options) {
118
120
  r.job({
119
121
  name: "cleanup",
120
122
  trigger: { manual: true },
121
- escapeHatch: { reason: "deletes expired session rows of every tenant" },
122
- handler: (payload, ctx) => cleanupJob(payload, ctx, ctx.db.unsafeRaw("deletes expired session rows of every tenant")),
123
+ escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
124
+ handler: (payload, ctx) => cleanupJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
123
125
  });
124
126
  // Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
125
127
  // the supported cross-feature path: entity-keyed, not prefixed by the
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  export declare const revokeAllForUserWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"user-session:revoke-all-for-user", z.ZodObject<{
3
3
  userId: z.ZodString;
4
4
  tenantId: z.ZodOptional<z.ZodString>;
5
+ exceptSessionId: z.ZodOptional<z.ZodUUID>;
5
6
  }, z.core.$strip>, {
6
7
  count: number;
7
8
  userId: string;
@@ -14,7 +14,10 @@ const REVOKE_ALL_SESSIONS_REASON = "revokes the target user's sessions in every
14
14
  // Art. 18) and potentially other ops flows ("ban user", "compromised
15
15
  // account"). Unlike revoke-all-others, the caller's own session (if any)
16
16
  // is revoked too — the caller is System (cron/operator/cross-feature),
17
- // not the end-user themselves.
17
+ // not the end-user themselves. A caller that IS the end-user (an admin
18
+ // editing their own roles) passes `exceptSessionId` to keep that one
19
+ // session alive; the event then carries keptSessionId so the SSE streams of
20
+ // that session stay open too.
18
21
  //
19
22
  // Tenant-scope: the userSession schema persists tenantId per row (a user
20
23
  // can have sessions in multiple tenants). Omitting `tenantId` revokes
@@ -28,9 +31,10 @@ export const revokeAllForUserWrite = defineWriteHandler({
28
31
  schema: z.object({
29
32
  userId: z.string().min(1),
30
33
  tenantId: z.string().min(1).optional(),
34
+ exceptSessionId: z.uuid().optional(),
31
35
  }),
32
36
  access: { roles: access.privileged },
33
- description: "Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it; use it for operator actions such as freezing or banning an account.",
37
+ description: "Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it, optionally sparing one session (exceptSessionId); use it for operator actions such as freezing or banning an account, or a role change that must not end the editor's own session.",
34
38
  agent: { risk: "high" },
35
39
  escapeHatch: {
36
40
  reason: REVOKE_ALL_SESSIONS_REASON,
@@ -41,6 +45,9 @@ export const revokeAllForUserWrite = defineWriteHandler({
41
45
  userId: event.payload.userId,
42
46
  revokedAt: null,
43
47
  ...(event.payload.tenantId !== undefined && { tenantId: event.payload.tenantId }),
48
+ ...(event.payload.exceptSessionId !== undefined && {
49
+ id: { ne: event.payload.exceptSessionId },
50
+ }),
44
51
  });
45
52
  // Lightweight append alongside the direct-write above (#1559) — see
46
53
  // revoke.write.ts for why this isn't a lifecycle event on
@@ -68,6 +75,9 @@ export const revokeAllForUserWrite = defineWriteHandler({
68
75
  const payload = sessionRevokedSchema.parse({
69
76
  userId: event.payload.userId,
70
77
  sessionIds: updated.map((row) => row.id),
78
+ ...(event.payload.exceptSessionId !== undefined && {
79
+ keptSessionId: event.payload.exceptSessionId,
80
+ }),
71
81
  });
72
82
  const reqCtx = requestContext.get();
73
83
  await append(runner, {
@@ -4,7 +4,7 @@ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "temporal-polyfill";
6
6
  import * as z from "zod";
7
- import { SessionErrors } from "../constants.js";
7
+ import { SessionErrors, USER_SESSION_ENTITY_NAME } from "../constants.js";
8
8
  import { userSessionTable } from "../schema/user-session.js";
9
9
  import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, } from "../session-revoked-event.js";
10
10
  // Revoke a single session by id (= JWT jti). Three distinguishable outcomes:
@@ -66,7 +66,7 @@ export const revokeWrite = defineWriteHandler({
66
66
  i18nKey: "errors.ownershipDenied",
67
67
  details: {
68
68
  scope: "entity",
69
- entityName: "user-session",
69
+ entityName: USER_SESSION_ENTITY_NAME,
70
70
  action: "revoke",
71
71
  userId: event.user.id,
72
72
  },
@@ -1,5 +1,6 @@
1
1
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, createEntity, createTextField, createTimestampField, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { USER_SESSION_ENTITY_NAME } from "../constants.js";
3
4
  // userSession — one row per signed-in client (browser tab, mobile app).
4
5
  // The PRIMARY key is the sid — generated by the framework at login time and
5
6
  // embedded in the JWT's `jti` claim. Using sid-as-PK means the auth middleware
@@ -78,6 +79,6 @@ export const userSessionEntity = createEntity({
78
79
  // revocation is a column write, not an aggregate). Handlers write via ctx.db;
79
80
  // the meta carries no executor-only brand so those writes stay legal. See
80
81
  // feature.ts for the rebuild-exclusion rationale (#494/#498).
81
- export const userSessionTable = deriveEntityTableMeta("user-session", userSessionEntity, {
82
+ export const userSessionTable = deriveEntityTableMeta(USER_SESSION_ENTITY_NAME, userSessionEntity, {
82
83
  source: "unmanaged",
83
84
  });
@@ -1,10 +1,14 @@
1
- import { access, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, i18nKey, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SESSION_MINE_SCREEN_ID, SessionHandlers, SessionQueries, } from "./constants.js";
3
3
  const listAccess = { roles: access.admin };
4
4
  export const sessionListScreen = {
5
5
  id: SESSION_LIST_SCREEN_ID,
6
6
  type: "projectionList",
7
7
  query: SessionQueries.list,
8
+ // The `list` handler honours `limit` only (no offset/total), so a pager would
9
+ // show page 1 forever: send one max-size request and render no pager.
10
+ pagination: false,
11
+ pageSize: MAX_LIST_LIMIT,
8
12
  // Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
9
13
  // desc) — kept in sync by hand, boot-validator only requires the field
10
14
  // be present once the query accepts `sort` (fw#2230).
@@ -6,7 +6,7 @@ import { Temporal } from "temporal-polyfill";
6
6
  import { encryptForDirectWrite } from "../shared/index.js";
7
7
  import { tenantMembershipsTable } from "../tenant/index.js";
8
8
  import { isPrincipalBlocked, userTable } from "../user/index.js";
9
- import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS } from "./constants.js";
9
+ import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS, USER_SESSION_ENTITY_NAME, } from "./constants.js";
10
10
  import { userSessionEntity, userSessionTable } from "./schema/user-session.js";
11
11
  import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, sessionRevokedSchema, } from "./session-revoked-event.js";
12
12
  // Re-exported so existing `../sessions` importers keep working unchanged.
@@ -19,7 +19,7 @@ export function createSessionCallbacks(opts) {
19
19
  const sid = generateId();
20
20
  const now = Temporal.Now.instant();
21
21
  const expiresAt = now.add({ milliseconds: ttlMs });
22
- await insertOne(db, userSessionTable, await encryptForDirectWrite(userSessionEntity, "user-session", {
22
+ await insertOne(db, userSessionTable, await encryptForDirectWrite(userSessionEntity, USER_SESSION_ENTITY_NAME, {
23
23
  id: sid,
24
24
  tenantId: user.tenantId,
25
25
  userId: user.id,
@@ -10,7 +10,7 @@ export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
10
10
  export { isTenantDb } from "./is-tenant-db.js";
11
11
  export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.js";
12
12
  export { mapWithConcurrency } from "./map-with-concurrency.js";
13
- export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility.js";
13
+ export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisible, } from "./parent-visibility.js";
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
16
16
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
@@ -10,7 +10,7 @@ export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
10
10
  export { isTenantDb } from "./is-tenant-db.js";
11
11
  export { createLockoutCounter } from "./lockout-counter.js";
12
12
  export { mapWithConcurrency } from "./map-with-concurrency.js";
13
- export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility.js";
13
+ export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisible, } from "./parent-visibility.js";
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
16
16
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
@@ -1,6 +1,6 @@
1
1
  // Generic Redis-backed failed-attempt counter with auto-expiring lockout.
2
2
  // Extracted from auth-email-password/lockout-store.ts and
3
- // auth-mfa/mfa-verify-attempts.ts (infra#446) — both were byte-identical
3
+ // auth-mfa/mfa-verify-attempts.ts — both were byte-identical
4
4
  // INCR/NX logic, differing only in their Redis key prefixes.
5
5
  // Two keys per subject so each can carry its own TTL:
6
6
  // - count-key: 24h, carries the streak. Monotonic — once threshold is
@@ -1,4 +1,9 @@
1
1
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  export declare function parentRowIsVisible(registry: Registry, entityType: string, entityId: string, user: SessionUser, db: TenantDb): Promise<boolean>;
4
5
  export declare function joinRowParentIsVisible(registry: Registry, joinEntityName: string, payload: Readonly<Record<string, unknown>>, user: SessionUser, db: TenantDb): Promise<boolean>;
6
+ export declare function denyUnlessJoinRowParentVisible(registry: Registry, joinEntityName: string, payload: {
7
+ readonly entityType: string;
8
+ readonly entityId: string;
9
+ }, user: SessionUser, db: TenantDb): Promise<WriteFailure | null>;
@@ -1,9 +1,15 @@
1
1
  import { createEntityExecutor, isUuid, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
2
3
  // Keyed by definition identity, not by entity name — two test stacks can
3
4
  // register different EntityDefinitions under the same entity name.
4
5
  const executorsByEntity = new WeakMap();
6
+ // Postgres `serial` is int4; a larger digit string would raise 22003 (numeric
7
+ // out of range) inside the write transaction instead of denying cleanly.
8
+ const SERIAL_ID_MAX = 2_147_483_647;
5
9
  function idShapeMatchesEntity(entity, entityId) {
6
- return entity.idType === "serial" ? /^\d+$/.test(entityId) : isUuid(entityId);
10
+ if (entity.idType !== "serial")
11
+ return isUuid(entityId);
12
+ return /^\d{1,10}$/.test(entityId) && Number(entityId) <= SERIAL_ID_MAX;
7
13
  }
8
14
  // Checks whether a client-supplied (entityType, entityId) host reference is one
9
15
  // the caller can see via that entity's own read path — tenant scope plus its
@@ -52,3 +58,13 @@ export async function joinRowParentIsVisible(registry, joinEntityName, payload,
52
58
  }
53
59
  return parentRowIsVisible(registry, entityType, entityId, user, db);
54
60
  }
61
+ // Write-handler gate over joinRowParentIsVisible: null means proceed, anything
62
+ // else is returned as-is. The host reference is client input, so this must run
63
+ // FIRST in the handler, ahead of every other lookup — a denied caller then
64
+ // can't tell an invisible parent apart from a missing assignment or an unknown
65
+ // tag/folder; every path answers with the same NotFoundError.
66
+ export async function denyUnlessJoinRowParentVisible(registry, joinEntityName, payload, user, db) {
67
+ if (await joinRowParentIsVisible(registry, joinEntityName, payload, user, db))
68
+ return null;
69
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
70
+ }