@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.
- package/dist/admin-shell/overview-panels.js +2 -2
- package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
- package/dist/agent-tools/agent-doc-lint.js +33 -3
- package/dist/agent-tools/agent-manifest.js +47 -6
- package/dist/agent-tools/filter-ops.d.ts +2 -0
- package/dist/agent-tools/filter-ops.js +4 -0
- package/dist/agent-tools/tool-catalog.js +1 -4
- package/dist/agent-tools/tool-dispatch.js +1 -4
- package/dist/audit/constants.d.ts +1 -1
- package/dist/audit/constants.js +1 -1
- package/dist/auth-email-password/handlers/change-password.write.js +2 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
- package/dist/auth-email-password/handlers/login.write.js +2 -0
- package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
- package/dist/auth-email-password/i18n.js +2 -0
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-gate.js +6 -8
- package/dist/auth-email-password/web/index.d.ts +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
- package/dist/auth-email-password/web/login-screen.js +1 -1
- package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
- package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
- package/dist/auth-email-password/web/session.d.ts +4 -0
- package/dist/auth-email-password/web/session.js +15 -0
- package/dist/auth-mfa/feature.js +3 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
- package/dist/auth-mfa/handlers/verify.write.js +2 -0
- package/dist/auth-mfa/hmac-token-codec.js +3 -0
- package/dist/auth-mfa/mfa-challenge-token.js +4 -0
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
- package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-client.js +4 -10
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/checkout-core.d.ts +5 -5
- package/dist/billing-foundation/checkout-core.js +10 -6
- package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
- package/dist/cap-counter/book-cap-usage.js +15 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- package/dist/cap-counter/feature.js +3 -2
- package/dist/cap-counter/i18n.d.ts +1 -0
- package/dist/cap-counter/i18n.js +6 -5
- package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
- package/dist/cap-overview/i18n.js +5 -0
- package/dist/cap-overview/web/cap-cards-panel.js +4 -1
- package/dist/cap-overview/web/cap-usage-bar.js +1 -1
- package/dist/channel-email/email-channel.js +12 -1
- package/dist/channel-in-app/tables.js +2 -6
- package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
- package/dist/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/web/i18n.js +1 -0
- package/dist/config/write-helpers.d.ts +1 -1
- package/dist/config/write-helpers.js +11 -5
- package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
- package/dist/custom-fields/constants.js +1 -4
- package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/types.js +7 -0
- package/dist/delivery/unsubscribe.js +5 -11
- package/dist/derivatives-sharp/render.js +27 -9
- package/dist/document-ingest-foundation/feature.js +5 -5
- package/dist/document-ingest-foundation/providers.d.ts +1 -0
- package/dist/document-ingest-foundation/providers.js +6 -0
- package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
- package/dist/document-ingest-foundation/write-document-extract.js +10 -0
- package/dist/feature-toggles/global-feature-state-table.js +2 -7
- package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
- package/dist/feature-toggles/toggle-sync-signal.js +3 -2
- package/dist/files-tenant-data/index.js +3 -2
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- package/dist/form-draft/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +7 -8
- package/dist/form-draft/handlers/get.query.js +7 -1
- package/dist/foundation-shared/host-allowlist.d.ts +4 -0
- package/dist/foundation-shared/host-allowlist.js +14 -0
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
- package/dist/foundation-shared/mail-host-policy.js +3 -13
- package/dist/inbound-mail-foundation/connect-routes.js +7 -5
- package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
- package/dist/jobs/handlers/list.query.d.ts +7 -2
- package/dist/jobs/handlers/list.query.js +9 -2
- package/dist/jobs/handlers/retry.write.js +3 -2
- package/dist/jobs/handlers/tenant-failures.query.js +3 -9
- package/dist/jobs/job-run-logger.js +18 -2
- package/dist/jobs/tenant-job-failure-table.js +2 -6
- package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
- package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
- package/dist/ledger/subject-reference.d.ts +4 -0
- package/dist/ledger/subject-reference.js +7 -0
- package/dist/legal-pages/feature.js +8 -1
- package/dist/managed-pages/handlers/branding.query.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
- package/dist/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -0
- package/dist/notes-history/handlers/add-note.write.js +4 -9
- package/dist/notes-history-user-data/hooks.js +8 -1
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.js +2 -2
- package/dist/personal-access-tokens/handlers/create.write.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
- package/dist/personal-access-tokens/i18n.js +12 -13
- package/dist/personal-access-tokens/schema/api-token.js +2 -1
- package/dist/personal-access-tokens/screens.js +5 -1
- package/dist/rate-limiting/feature.js +2 -0
- package/dist/rate-limiting/handlers/bucket-access.js +15 -7
- package/dist/rate-limiting/i18n.d.ts +10 -0
- package/dist/rate-limiting/i18n.js +10 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/seo/handlers/seo-config.query.js +1 -1
- package/dist/sessions/constants.d.ts +1 -0
- package/dist/sessions/constants.js +1 -0
- package/dist/sessions/feature.js +5 -3
- package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
- package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
- package/dist/sessions/handlers/revoke.write.js +2 -2
- package/dist/sessions/schema/user-session.js +2 -1
- package/dist/sessions/screens.js +5 -1
- package/dist/sessions/session-callbacks.js +2 -2
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/lockout-counter.js +1 -1
- package/dist/shared/parent-visibility.d.ts +5 -0
- package/dist/shared/parent-visibility.js +17 -1
- package/dist/shared/row-bound-grant.js +20 -9
- package/dist/shared/single-use-token-store.js +5 -13
- package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
- package/dist/step-dispatcher/webhook-runner.js +18 -17
- package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
- package/dist/subscription-mollie/plugin-methods.js +7 -1
- package/dist/subscription-mollie/verify-webhook.js +6 -0
- package/dist/subscription-stripe/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- package/dist/tags/handlers/assign-tag.write.js +4 -10
- package/dist/tags/handlers/remove-tag.write.js +4 -11
- package/dist/template-resolver/api.d.ts +2 -1
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
- package/dist/template-resolver/handlers/by-slug.query.js +2 -2
- package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
- package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
- package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
- package/dist/template-resolver/handlers/collection-shared.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/list.query.js +1 -1
- package/dist/template-resolver/seeding.js +41 -39
- package/dist/template-resolver/table.d.ts +8 -5
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +2 -5
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver-user-data/hooks.js +1 -1
- package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
- package/dist/tenant/cancel-pending-invitation.js +22 -0
- package/dist/tenant/feature.js +4 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +17 -4
- package/dist/tenant/handlers/remove-member.write.js +9 -14
- package/dist/tenant/handlers/team-list.query.js +2 -2
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +13 -3
- package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +28 -12
- package/dist/tenant/i18n.js +4 -0
- package/dist/tenant/index.d.ts +1 -1
- package/dist/tenant/index.js +1 -1
- package/dist/tenant/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- package/dist/tenant/seeding.d.ts +4 -0
- package/dist/tenant/seeding.js +9 -2
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +27 -1
- package/dist/tenant-lifecycle/feature.js +2 -1
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/tier-engine/feature.js +3 -2
- package/dist/user/command-schemas.d.ts +2 -2
- package/dist/user/handlers/create.write.d.ts +1 -1
- package/dist/user/handlers/create.write.js +8 -2
- package/dist/user/handlers/update.write.d.ts +1 -1
- package/dist/user/handlers/update.write.js +8 -2
- package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
- package/dist/user-data-rights/feature.js +22 -9
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
- package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
- package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
- package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
- package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
- package/dist/user-data-rights/run-export-jobs.js +74 -41
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/user-data-rights/run-user-export.js +2 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
- package/dist/user-profile/feature.js +6 -1
- package/dist/user-profile/handlers/change-email.write.js +2 -0
- package/dist/workflow-runner/db/queries/due-runs.js +6 -9
- package/dist/workflow-runner/event-subscriber.js +29 -9
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
- package/dist/workflow-runner/pending-projection.js +8 -1
- package/package.json +11 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +12 -0
- package/src/delivery/changes.json +7 -0
- package/src/derivatives-sharp/changes.json +11 -0
- package/src/document-ingest-foundation/changes.json +12 -0
- package/src/inbound-mail-foundation/changes.json +12 -0
- package/src/jobs/changes.json +13 -0
- package/src/legal-pages/changes.json +8 -1
- package/src/managed-pages/changes.json +5 -0
- package/src/metrics/changes.json +6 -0
- package/src/rate-limiting/changes.json +5 -0
- package/src/secrets/changes.json +7 -0
- package/src/sessions/changes.json +5 -0
- package/src/shared/changes.json +6 -0
- package/src/step-dispatcher/changes.json +5 -0
- package/src/subscription-mollie/changes.json +7 -0
- package/src/subscription-stripe/changes.json +11 -0
- package/src/template-resolver/changes.json +7 -0
- package/src/tenant/changes.json +29 -0
- package/src/user-data-rights/changes.json +22 -0
- 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;
|
package/dist/metrics/compute.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/metrics/window.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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;
|
|
@@ -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(
|
|
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,
|
|
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:
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
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[
|
|
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[
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|
package/dist/secrets/feature.js
CHANGED
|
@@ -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:
|
|
134
|
+
reason: ROTATE_ESCAPE_HATCH_REASON,
|
|
134
135
|
},
|
|
135
|
-
handler: (payload, ctx) => rotateJob(payload, ctx, ctx.db.unsafeRaw(
|
|
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";
|
package/dist/sessions/feature.js
CHANGED
|
@@ -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(
|
|
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:
|
|
122
|
-
handler: (payload, ctx) => cleanupJob(payload, ctx, ctx.db.unsafeRaw(
|
|
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:
|
|
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(
|
|
82
|
+
export const userSessionTable = deriveEntityTableMeta(USER_SESSION_ENTITY_NAME, userSessionEntity, {
|
|
82
83
|
source: "unmanaged",
|
|
83
84
|
});
|
package/dist/sessions/screens.js
CHANGED
|
@@ -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,
|
|
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,
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/shared/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|