@cosmicdrift/kumiko-samples 0.352.0 → 0.354.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/public-names.ts +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +17 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +4 -2
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +19 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +0 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
collectPiiSubjectFields,
|
|
4
4
|
configuredPiiSubjectKms,
|
|
5
5
|
encryptPiiFieldValues,
|
|
6
|
+
KeyErasedError,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
8
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { subjectErasedConflict } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
10
|
|
|
9
11
|
// Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
|
|
10
12
|
// PII encryption never runs — every insert of subject-annotated fields must
|
|
@@ -18,12 +20,17 @@ export async function encryptForDirectWrite(
|
|
|
18
20
|
): Promise<Record<string, unknown>> {
|
|
19
21
|
const kms = configuredPiiSubjectKms();
|
|
20
22
|
if (!kms) return row;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
try {
|
|
24
|
+
return await encryptPiiFieldValues(
|
|
25
|
+
row,
|
|
26
|
+
entity,
|
|
27
|
+
collectPiiSubjectFields(entity),
|
|
28
|
+
kms,
|
|
29
|
+
{ requestId: requestContext.get()?.requestId ?? fallbackRequestId },
|
|
30
|
+
{ entityName },
|
|
31
|
+
);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (e instanceof KeyErasedError) throw subjectErasedConflict(e);
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
29
36
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { collectErasureFailure, throwIfErasureFailed } from "./assert-erased.js";
|
|
2
|
+
export { assertNoWhereRuleInOwnershipWrite } from "./assert-no-where-rule-in-ownership-write.js";
|
|
2
3
|
export {
|
|
3
4
|
type ChunkedMigrationOptions,
|
|
4
5
|
type ChunkedMigrationResult,
|
|
@@ -14,17 +15,18 @@ export { decryptStoredPii } from "./decrypt-stored-pii.js";
|
|
|
14
15
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write.js";
|
|
15
16
|
export { entitiesOf } from "./entities-of.js";
|
|
16
17
|
export { isWithinGracePeriod } from "./grace-period.js";
|
|
17
|
-
export { hasWhereRule } from "./has-where-rule.js";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
|
|
19
19
|
export { isTenantDb } from "./is-tenant-db.js";
|
|
20
20
|
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.js";
|
|
21
21
|
export { mapWithConcurrency } from "./map-with-concurrency.js";
|
|
22
22
|
export {
|
|
23
23
|
denyUnlessJoinRowParentVisible,
|
|
24
|
+
hostRowIsGone,
|
|
24
25
|
joinRowParentIsVisible,
|
|
25
26
|
parentRowIsVisible,
|
|
26
27
|
} from "./parent-visibility.js";
|
|
27
28
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
29
|
+
export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
|
|
28
30
|
export {
|
|
29
31
|
redactBotTokens,
|
|
30
32
|
redactEmailAddresses,
|
|
@@ -37,7 +39,7 @@ export {
|
|
|
37
39
|
signRowBoundGrant,
|
|
38
40
|
} from "./row-bound-grant.js";
|
|
39
41
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
|
40
|
-
export { sessionLocaleField } from "./session-locale-field.js";
|
|
42
|
+
export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
|
|
41
43
|
export { sessionTimezoneField } from "./session-timezone-field.js";
|
|
42
44
|
export {
|
|
43
45
|
peekTokenSubject,
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
buildEntityTable,
|
|
3
|
+
type EventStoreExecutor,
|
|
4
|
+
type TenantDb,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
2
6
|
import {
|
|
3
7
|
createEntityExecutor,
|
|
4
8
|
type EntityDefinition,
|
|
@@ -61,6 +65,32 @@ export async function parentRowIsVisible(
|
|
|
61
65
|
return (await executor.detail({ id: entityId }, user, db)) !== null;
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
const hostTablesByEntity = new WeakMap<EntityDefinition, ReturnType<typeof buildEntityTable>>();
|
|
69
|
+
|
|
70
|
+
// True when the host row can never be read again by anyone: its entityType no
|
|
71
|
+
// longer names a registered entity, or the row is hard-deleted / soft-deleted.
|
|
72
|
+
// Deliberately skips the caller's `access.read` ownership (unlike
|
|
73
|
+
// parentRowIsVisible): a row hidden from this caller but alive for another team
|
|
74
|
+
// is NOT gone, and its join row must stay.
|
|
75
|
+
export async function hostRowIsGone(
|
|
76
|
+
registry: Registry,
|
|
77
|
+
entityType: string,
|
|
78
|
+
entityId: string,
|
|
79
|
+
db: TenantDb,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
const entity = registry.getEntity(entityType);
|
|
82
|
+
if (!entity) return true;
|
|
83
|
+
if (!idShapeMatchesEntity(entity, entityId)) return true;
|
|
84
|
+
|
|
85
|
+
let table = hostTablesByEntity.get(entity);
|
|
86
|
+
if (!table) {
|
|
87
|
+
table = buildEntityTable(entityType, entity, { relations: registry.getRelations(entityType) });
|
|
88
|
+
hostTablesByEntity.set(entity, table);
|
|
89
|
+
}
|
|
90
|
+
const row = await db.fetchOne(table, { id: entityId });
|
|
91
|
+
return row === undefined || row["isDeleted"] === true;
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
// Write-path half of the parentRef gate (fw#2766). Resolves the join entity's
|
|
65
95
|
// own `parentRef` declaration from the registry — the same object the read
|
|
66
96
|
// path's SQL gate reads — so the field names and the allowedTypes allowlist
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
|
|
3
|
+
export function rateLimitedTextResponse(error: RateLimitError): Response {
|
|
4
|
+
return new Response("rate limited", {
|
|
5
|
+
status: 429,
|
|
6
|
+
headers: {
|
|
7
|
+
"content-type": "text/plain; charset=utf-8",
|
|
8
|
+
"retry-after": String(error.details.retryAfterSeconds),
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -54,6 +54,19 @@ function anchoredPurpose(purpose: string, anchor: string): string {
|
|
|
54
54
|
return `${purpose}:${anchor}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// The HMAC input is `${purpose}:${anchor}:${subject}.${expiresAtMs}` with no
|
|
58
|
+
// length prefixes. Colon-free anchor and subject make the split unambiguous
|
|
59
|
+
// from the right, so two different (purpose, anchor, subject) triples can
|
|
60
|
+
// never share a signature. The purpose may keep colons (tenant-handover
|
|
61
|
+
// namespaces its purpose by entity type).
|
|
62
|
+
function hasDelimiter(value: string): boolean {
|
|
63
|
+
return value.includes(":");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isUnambiguousPart(value: string | null | undefined): value is string {
|
|
67
|
+
return !!value && !hasDelimiter(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
export function signRowBoundGrant(args: {
|
|
58
71
|
readonly subject: string;
|
|
59
72
|
readonly purpose: string;
|
|
@@ -62,6 +75,9 @@ export function signRowBoundGrant(args: {
|
|
|
62
75
|
readonly secret: string;
|
|
63
76
|
readonly now?: Temporal.Instant;
|
|
64
77
|
}): { readonly token: string; readonly expiresAt: Temporal.Instant } {
|
|
78
|
+
if (hasDelimiter(args.anchor) || hasDelimiter(args.subject)) {
|
|
79
|
+
throw new Error("row-bound grant: subject and anchor must not contain ':'");
|
|
80
|
+
}
|
|
65
81
|
return signToken(
|
|
66
82
|
args.subject,
|
|
67
83
|
anchoredPurpose(args.purpose, args.anchor),
|
|
@@ -90,7 +106,7 @@ export async function redeemRowBoundGrant(args: {
|
|
|
90
106
|
if (!args.secret) return FAILED;
|
|
91
107
|
|
|
92
108
|
const subject = peekTokenSubject(args.token);
|
|
93
|
-
if (!subject) return FAILED;
|
|
109
|
+
if (!isUnambiguousPart(subject)) return FAILED;
|
|
94
110
|
|
|
95
111
|
// The subject is unverified attacker input at this point, so a lookup that
|
|
96
112
|
// throws on it (e.g. a non-uuid value against a uuid column) must not
|
|
@@ -107,7 +123,7 @@ export async function redeemRowBoundGrant(args: {
|
|
|
107
123
|
});
|
|
108
124
|
return FAILED;
|
|
109
125
|
}
|
|
110
|
-
if (!anchor) return FAILED;
|
|
126
|
+
if (!isUnambiguousPart(anchor)) return FAILED;
|
|
111
127
|
|
|
112
128
|
const verified = verifyToken(
|
|
113
129
|
args.token,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
1
2
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
4
|
|
|
@@ -7,3 +8,9 @@ export function sessionLocaleField(
|
|
|
7
8
|
if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
|
|
8
9
|
return { locale: canonicalizeLocaleTag(locale) };
|
|
9
10
|
}
|
|
11
|
+
|
|
12
|
+
// Only an explicit request signal (X-Locale) counts: the boot default is not a
|
|
13
|
+
// choice the new user made.
|
|
14
|
+
export function registrationLocaleFromRequest(): ReturnType<typeof sessionLocaleField> {
|
|
15
|
+
return sessionLocaleField(requestContext.get()?.locale);
|
|
16
|
+
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type EntityDefinition,
|
|
28
28
|
type FeatureRegistrar,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
-
import {
|
|
30
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
31
31
|
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants.js";
|
|
32
32
|
import { createTagAssignmentEntity, tagEntity } from "./entity.js";
|
|
33
33
|
import { createTagAssignmentExecutor } from "./executor.js";
|
|
@@ -201,17 +201,7 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
|
|
|
201
201
|
"admissible as a host instead.",
|
|
202
202
|
);
|
|
203
203
|
}
|
|
204
|
-
|
|
205
|
-
throw new Error(
|
|
206
|
-
"createTagsFeature({ ownership }): ownership.write must not contain a " +
|
|
207
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
208
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
209
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
210
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
211
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
212
|
-
"leave it unset.",
|
|
213
|
-
);
|
|
214
|
-
}
|
|
204
|
+
assertNoWhereRuleInOwnershipWrite("createTagsFeature", opts.ownership?.write);
|
|
215
205
|
const access = resolveAccess(opts);
|
|
216
206
|
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
217
207
|
registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ctx.templateResolver at runtime.
|
|
4
4
|
|
|
5
5
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
-
import type {
|
|
6
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -76,7 +76,7 @@ export type TemplateResolverApi = {
|
|
|
76
76
|
readonly resolveTemplate: (args: ResolveRequest) => Promise<TemplateResource>;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
export function createTemplateResolverApi(db:
|
|
79
|
+
export function createTemplateResolverApi(db: DbRunner): TemplateResolverApi {
|
|
80
80
|
return {
|
|
81
81
|
findExact: async ({ tenantId, slug, kind, locale, scope }) => {
|
|
82
82
|
const effectiveTenantId = scope === "system" ? SYSTEM_TENANT_ID : tenantId;
|
|
@@ -106,7 +106,7 @@ export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
async function fetchTemplate(
|
|
109
|
-
db:
|
|
109
|
+
db: DbRunner,
|
|
110
110
|
tenantId: string,
|
|
111
111
|
slug: string,
|
|
112
112
|
kind: TemplateKind,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
|
|
5
5
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
6
6
|
|
|
7
7
|
// List für Admin-UI: filterbar nach kind / locale / status. Liefert
|
|
@@ -10,7 +10,7 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
|
10
10
|
export const listQuery = defineQueryHandler({
|
|
11
11
|
name: "list",
|
|
12
12
|
schema: z.object({
|
|
13
|
-
kind: z.enum(
|
|
13
|
+
kind: z.enum(TEMPLATE_KINDS).optional(),
|
|
14
14
|
locale: z.string().min(2).max(8).optional(),
|
|
15
15
|
status: z.enum(TEMPLATE_STATUSES).optional(),
|
|
16
16
|
includeSystem: z.boolean().default(true),
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "createTenantFeature fails the boot when a declared assignable app role is missing from its assignableAppRoles option",
|
|
6
|
+
"detail": "Final review batch E1: bundled features\n`createTenantFeature` now fails the boot when an `assignableRole` declaration is missing from its `assignableAppRoles` option, because the members and invite screens would otherwise omit the role and a save of the member-roles form would silently strip it. `composeFeatures(includeBundled)` already passes the option. Agent-callable handlers that change access or books now resolve risk `high`: `tenant:write:disable`, `tenant:write:updateMemberRoles`, `user:write:user:update`, `tier-engine:write:set-tenant-tier`, `ledger:write:create-transaction` and `ledger:write:reverse-transaction`. `in_app_messages` gets a `(tenant_id, user_id, created_at)` index. `GET /files/:id/download-url` no longer puts a personal `fileName` into the signed URL; the hint carries a neutral name derived from the MIME type, while `GET /files/:id` still sends the real name in its response header. The forget-subject denial audit event goes through the shared `appendDomainEventCore`, which the pipeline barrel now exports.",
|
|
7
|
+
"migration": "Pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature when you mount it by hand; composeFeatures(includeBundled) already does."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.354.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "tenant:write:disable and tenant:write:updateMemberRoles resolve agent risk high"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.353.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "Tenant names are encrypted under the tenant record and crypto-shredded when the tenant is destroyed",
|
|
18
|
+
"detail": "Tenant names are PII encrypted under the tenant record and crypto-shredded on tenant destroy\n`tenant.name` is now `personal: { of: \"id\" }`, so it is stored as `kumiko-pii:` ciphertext in events and in `read_tenants`. Signup no longer writes the email into the tenant name; it uses the generated tenant key. The tenant destroy job erases the tenant record key and purges the tenant's search documents. The tenant queries (`me`, memberships, tenant-directory, user list, cap-overview, tenants-missing-profile) decrypt the name, and the tenant list sorts by `key` because encrypted names are not sortable.",
|
|
19
|
+
"migration": "After the bump run the PII backfill (backfillEventPiiEncryption), then rebuild the read_tenants projection; the backfill only encrypts event payloads. Raw reads of read_tenants.name in apps now return ciphertext and must go through decryptStoredPii or the tenant query handlers. Tenants that were already destroyed (read_tenants.destroyed_at set) get the erased sentinel instead of a fresh record key."
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "0.341.0",
|
|
4
23
|
"type": "improvement",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { decryptStoredPii, mapWithConcurrency } from "../shared/index.js";
|
|
2
|
+
|
|
3
|
+
// Bounded like tenant:query:members: each decrypt hits the KMS adapter's small pool.
|
|
4
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
5
|
+
|
|
6
|
+
// Raw reads of read_tenants return the stored column, which is ciphertext
|
|
7
|
+
// under an active KMS; executor reads (crud.list/detail) decrypt on their own.
|
|
8
|
+
export async function decryptTenantNames<T extends { readonly name?: unknown }>(
|
|
9
|
+
rows: readonly T[],
|
|
10
|
+
fallbackRequestId: string,
|
|
11
|
+
): Promise<T[]> {
|
|
12
|
+
return mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, async (row) =>
|
|
13
|
+
typeof row.name === "string"
|
|
14
|
+
? { ...row, name: await decryptStoredPii(row.name, "name", fallbackRequestId) }
|
|
15
|
+
: row,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -81,8 +81,15 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
|
|
|
81
81
|
r.systemScope();
|
|
82
82
|
r.requires("config");
|
|
83
83
|
r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
|
|
84
|
+
// The elevation guards read the registry, the screens only these options: a role the
|
|
85
|
+
// server accepts but the screen omits is silently stripped on the next member-roles save.
|
|
84
86
|
r.bootCheck(({ features }) => {
|
|
85
|
-
collectAssignableAppRoles(features)
|
|
87
|
+
for (const [role, assignableFrom] of collectAssignableAppRoles(features)) {
|
|
88
|
+
if (options?.assignableAppRoles?.get(role) === assignableFrom) continue;
|
|
89
|
+
throw new Error(
|
|
90
|
+
`[tenant] assignable app role "${role}" (assignableFrom "${assignableFrom}") is declared but missing from the tenant screens' options; pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
86
93
|
});
|
|
87
94
|
r.entity("tenant", tenantEntity);
|
|
88
95
|
r.entity("tenant-membership", tenantMembershipEntity);
|
|
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
5
6
|
import { tenantTable } from "../schema/tenant.js";
|
|
6
7
|
|
|
7
8
|
// Direct query — query handlers don't have a tenant-crud handle. A direct
|
|
@@ -33,6 +34,8 @@ export const meQuery = defineQueryHandler({
|
|
|
33
34
|
}
|
|
34
35
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
35
36
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
36
|
-
|
|
37
|
+
if (row === undefined) return null;
|
|
38
|
+
const [decrypted] = await decryptTenantNames([row], "tenant:me");
|
|
39
|
+
return decrypted ?? null;
|
|
37
40
|
},
|
|
38
41
|
});
|
|
@@ -3,6 +3,7 @@ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/e
|
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
6
7
|
import { tenantMembershipsTable } from "../membership-table.js";
|
|
7
8
|
import { tenantTable } from "../schema/tenant.js";
|
|
8
9
|
|
|
@@ -32,9 +33,12 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
32
33
|
// prefix would be indistinguishable). A single IN-batch over all
|
|
33
34
|
// tenantIds instead of fetchOne per membership (#324).
|
|
34
35
|
type TenantRow = { id: unknown; name?: unknown; key?: unknown; isEnabled?: unknown };
|
|
35
|
-
const tenants = await
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const tenants = await decryptTenantNames(
|
|
37
|
+
await selectMany<TenantRow>(db, tenantTable, {
|
|
38
|
+
id: rows.map((row) => row["tenantId"]),
|
|
39
|
+
}),
|
|
40
|
+
"tenant:memberships",
|
|
41
|
+
);
|
|
38
42
|
const tenantById = new Map<unknown, TenantRow>(tenants.map((t) => [t.id, t]));
|
|
39
43
|
|
|
40
44
|
return rows
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
9
10
|
import { tenantTable } from "../schema/tenant.js";
|
|
10
11
|
import { isSystemAdmin } from "./is-system-admin.js";
|
|
11
12
|
|
|
@@ -48,7 +49,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
48
49
|
tenants = await selectMany(db, tenantTable, undefined, {
|
|
49
50
|
limit: scanLimit,
|
|
50
51
|
orderBy: [
|
|
51
|
-
{ col: "
|
|
52
|
+
{ col: "key", direction: "asc" },
|
|
52
53
|
{ col: "id", direction: "asc" },
|
|
53
54
|
],
|
|
54
55
|
});
|
|
@@ -57,10 +58,15 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
57
58
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
58
59
|
tenants = row ? [row] : [];
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// name is ciphertext at rest, so the SQL page is keyed by the plaintext
|
|
62
|
+
// `key`; label order and search happen in memory after decrypting.
|
|
63
|
+
const decrypted = await decryptTenantNames(tenants, "tenant:tenant-directory");
|
|
64
|
+
const labeled = decrypted
|
|
65
|
+
.map((tenant) => {
|
|
66
|
+
const id = String(tenant.id);
|
|
67
|
+
return { id, label: typeof tenant.name === "string" ? tenant.name : id };
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
64
70
|
const needle = search?.toLowerCase();
|
|
65
71
|
const rows =
|
|
66
72
|
needle === undefined
|
|
@@ -14,6 +14,8 @@ function createToggleTenantHandler(enable: boolean) {
|
|
|
14
14
|
name: verbName,
|
|
15
15
|
schema: z.object({ id: z.uuid() }),
|
|
16
16
|
access: { roles: ["SystemAdmin"] },
|
|
17
|
+
// Disabling locks every member out; enabling is its undo.
|
|
18
|
+
...(enable ? {} : { agent: { risk: "high" as const } }),
|
|
17
19
|
description: enable
|
|
18
20
|
? "Re-enables a disabled tenant so its members can sign in and switch to it again; this is the undo for tenant:write:disable."
|
|
19
21
|
: "Marks a tenant disabled so it stops counting as a membership — its members can no longer sign in to it or switch to it — while leaving all of its data in place.",
|
|
@@ -72,6 +72,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
72
72
|
access: { roles: ["system", ...access.admin] },
|
|
73
73
|
description:
|
|
74
74
|
"Sets the roles a user holds in a tenant, rejecting reserved role names and any grant that would raise the target above the caller; roles the caller could not grant (platform roles such as DataProtectionOfficer or TenantOwner, undeclared or higher-tier app roles) are kept, only the system user replaces the full list; admins act on their own tenant, SystemAdmins on any.",
|
|
75
|
+
agent: { risk: "high" },
|
|
75
76
|
handler: async (event, ctx) => {
|
|
76
77
|
if (!ctx.systemDb) {
|
|
77
78
|
throw new InternalError({
|
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
TenantHandlers,
|
|
8
8
|
TenantQueries,
|
|
9
9
|
} from "./constants.js";
|
|
10
|
+
export { decryptTenantNames } from "./decrypt-tenant-names.js";
|
|
10
11
|
export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
|
|
11
12
|
export type { InvitationStatus } from "./invitation-table.js";
|
|
12
13
|
export {
|
|
@@ -26,6 +26,7 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
26
26
|
import {
|
|
27
27
|
createEntity,
|
|
28
28
|
createMultiSelectField,
|
|
29
|
+
createNumberField,
|
|
29
30
|
createSelectField,
|
|
30
31
|
createTextField,
|
|
31
32
|
createTimestampField,
|
|
@@ -94,6 +95,11 @@ export const tenantInvitationEntity = createEntity({
|
|
|
94
95
|
personal: { of: "invitedBy" },
|
|
95
96
|
find: "exact",
|
|
96
97
|
}),
|
|
98
|
+
// Version of the invitee's membership row when the invitation was issued
|
|
99
|
+
// (0 = no membership yet). Accept compares it against the current version
|
|
100
|
+
// to detect a membership decision made after the invite. NULL only on rows
|
|
101
|
+
// issued before this field existed.
|
|
102
|
+
membershipVersion: createNumberField({ integer: true, min: 0 }),
|
|
97
103
|
// UI-Anzeige — Wahrheit liegt in Redis-TTL.
|
|
98
104
|
expiresAt: createTimestampField({ required: true }),
|
|
99
105
|
},
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createEventStoreExecutor,
|
|
4
4
|
createTenantDb,
|
|
5
5
|
type DbRunner,
|
|
6
|
+
type TenantDb,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
8
|
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
9
|
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -23,7 +24,13 @@ export type InvitedMembershipOptions = {
|
|
|
23
24
|
readonly userId: string;
|
|
24
25
|
readonly tenantId: TenantId;
|
|
25
26
|
readonly role: string;
|
|
26
|
-
readonly
|
|
27
|
+
readonly invitation: InvitationIssuance;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type InvitationIssuance = {
|
|
31
|
+
readonly membershipVersion: number | null;
|
|
32
|
+
readonly insertedAt: Temporal.Instant;
|
|
33
|
+
readonly modifiedAt: Temporal.Instant | null;
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
type MembershipRow = {
|
|
@@ -34,6 +41,21 @@ type MembershipRow = {
|
|
|
34
41
|
readonly modifiedAt: Temporal.Instant | null;
|
|
35
42
|
};
|
|
36
43
|
|
|
44
|
+
const MEMBERSHIP_CREATED_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
// 0 = the user has no membership in the tenant yet.
|
|
47
|
+
export async function membershipVersionOf(
|
|
48
|
+
db: Pick<TenantDb, "fetchOne">,
|
|
49
|
+
tenantId: string,
|
|
50
|
+
userId: string,
|
|
51
|
+
): Promise<number> {
|
|
52
|
+
const membership = await db.fetchOne<{ readonly version: number }>(tenantMembershipsTable, {
|
|
53
|
+
tenantId,
|
|
54
|
+
userId,
|
|
55
|
+
});
|
|
56
|
+
return membership?.version ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
// Add-only, so unlike updateMemberRoles no session revoke or last-TenantAdmin check.
|
|
38
60
|
// Read unfiltered: tenant:query:memberships hides disabled tenants.
|
|
39
61
|
export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedMembershipOptions) {
|
|
@@ -54,7 +76,7 @@ export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedM
|
|
|
54
76
|
}
|
|
55
77
|
|
|
56
78
|
// A membership decision made after the invitation was issued wins over it.
|
|
57
|
-
if (
|
|
79
|
+
if (isSupersededByMembership(existing, options.invitation)) {
|
|
58
80
|
return writeFailure(invitationSupersededError());
|
|
59
81
|
}
|
|
60
82
|
|
|
@@ -71,17 +93,28 @@ export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedM
|
|
|
71
93
|
return grantedMembership(true, roles);
|
|
72
94
|
}
|
|
73
95
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function
|
|
96
|
+
// The pinned version is the membership's version when the invitation was issued
|
|
97
|
+
// (0 = none yet). A different version now means the membership was decided after
|
|
98
|
+
// the invite. Pin 0: a membership that appeared afterwards is an additive grant
|
|
99
|
+
// only while it is untouched since its creation (version 1). A role change bumps
|
|
100
|
+
// the version above 1 and counts as a decision. A removal in between hard-deletes
|
|
101
|
+
// the row and cancels the invitation (remove-member), so it never reaches this check.
|
|
102
|
+
// Rows issued before the pin existed (null) fall back to wall-clock timestamps,
|
|
103
|
+
// which can misjudge changes within the same millisecond but is all they carry.
|
|
104
|
+
function isSupersededByMembership(
|
|
105
|
+
membership: MembershipRow,
|
|
106
|
+
invitation: InvitationIssuance,
|
|
107
|
+
): boolean {
|
|
108
|
+
if (invitation.membershipVersion !== null) {
|
|
109
|
+
if (invitation.membershipVersion === 0) {
|
|
110
|
+
return membership.version > MEMBERSHIP_CREATED_VERSION;
|
|
111
|
+
}
|
|
112
|
+
return membership.version !== invitation.membershipVersion;
|
|
113
|
+
}
|
|
114
|
+
// Resend reuses the invitation row, so its last modification is the latest issuance.
|
|
115
|
+
const issuedAt = invitation.modifiedAt ?? invitation.insertedAt;
|
|
83
116
|
const lastChangedAt = membership.modifiedAt ?? membership.insertedAt;
|
|
84
|
-
return Temporal.Instant.compare(lastChangedAt,
|
|
117
|
+
return Temporal.Instant.compare(lastChangedAt, issuedAt) > 0;
|
|
85
118
|
}
|
|
86
119
|
|
|
87
120
|
function invitationSupersededError(): ConflictError {
|
|
@@ -28,19 +28,17 @@ export const tenantEntity = createEntity({
|
|
|
28
28
|
key: createTextField({
|
|
29
29
|
required: true,
|
|
30
30
|
maxLength: 50,
|
|
31
|
+
sortable: true,
|
|
31
32
|
personal: false,
|
|
32
33
|
reason: "technical_reference",
|
|
33
34
|
}),
|
|
34
|
-
//
|
|
35
|
-
// same class as the `company.legalName` example in the boot-validator's
|
|
36
|
-
// own PII-heuristic doc-comment.
|
|
35
|
+
// Subject is the tenant record itself: tenant events live in the writer's (system) tenant stream, so `personal: "tenant"` would not resolve to this tenant.
|
|
37
36
|
name: createTextField({
|
|
38
37
|
required: true,
|
|
39
38
|
maxLength: 200,
|
|
40
39
|
searchable: true,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
reason: "is_business_data",
|
|
40
|
+
personal: { of: "id" },
|
|
41
|
+
find: "none",
|
|
44
42
|
}),
|
|
45
43
|
isEnabled: createBooleanField({ default: true, filterable: true }),
|
|
46
44
|
// Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
|
|
@@ -43,7 +43,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
|
|
|
43
43
|
entityId: "id",
|
|
44
44
|
},
|
|
45
45
|
],
|
|
46
|
-
defaultSort: { field: "
|
|
46
|
+
defaultSort: { field: "key", dir: "asc" },
|
|
47
47
|
searchable: true,
|
|
48
48
|
access: { roles: ["SystemAdmin"] },
|
|
49
49
|
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Reference fields can opt out of the handover with handover: \"stay\"; boot depth limit and mover share one graph definition",
|
|
6
|
+
"detail": "ReferenceFieldDef gains handover: \"stay\". An entity that should NOT travel with its host but points at it through a plain reference no longer blocks the claim with entity_not_transferable when that reference field carries handover: \"stay\": the edge is not walked, its rows stay in the source tenant, and the reference crosses the tenant boundary after the handover. Without the opt-out the behaviour is unchanged (entity_not_transferable at claim time). Migration: an entity that should NOT travel with its host: mark the reference field handover: \"stay\" (removing transferable alone does not help once rows exist, the claim still fails). The framework now exports one adjacency definition (resolveEntityTransferAdjacency, reference and parentRef edges, non-transferable intermediate nodes included, handover: \"stay\" respected) that the boot validator's depth limit and the tenant-handover mover both use. Consequence: the boot-time MAX_TRANSFER_DEPTH check now also counts parentRef edges and chains running through non-transferable entities, as the mover always did; a graph that was deeper than 5 hops only through such nodes now fails the boot instead of the claim."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.353.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Tenant handover claims fail with transfer_graph_too_deep only when rows exist beyond the last allowed hop",
|
|
12
|
+
"detail": "Tenant handover only fails with `transfer_graph_too_deep` when rows really hang below the fifth hop, and failed workflow runs report the suspended step\nA claim used to fail whenever the last round's entity type had outgoing edges, even with no rows behind them, so a chain of exactly five hops with an unrestricted `parentRef` entity registered was rejected. `workflow.run-failed` carries the real `stepIndex` for the unsupported-suspension case instead of always 0."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.321.0",
|
|
4
16
|
"type": "improvement",
|