@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import {
|
|
4
5
|
type BrandingTokens,
|
|
5
6
|
cachedSecurePageResponse,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
renderSafeMarkdown,
|
|
8
9
|
wrapInLayout,
|
|
9
10
|
} from "../page-render/index.js";
|
|
10
|
-
import type
|
|
11
|
+
import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.js";
|
|
11
12
|
import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.js";
|
|
12
13
|
import { createBrandingQuery } from "./handlers/branding.query.js";
|
|
13
14
|
import { bySlugQuery } from "./handlers/by-slug.query.js";
|
|
@@ -51,16 +52,18 @@ function brandingRevisionSeed(branding: BrandingTokens): string {
|
|
|
51
52
|
]);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
55
|
+
// A query failure or malformed result degrades to the unbranded default
|
|
56
|
+
// (branding is decoration, not a hard dependency of the page render). A rate
|
|
57
|
+
// limit is rethrown so the route answers 429 instead of serving (and caching)
|
|
58
|
+
// an unbranded page.
|
|
57
59
|
async function readBrandingViaSystemQuery(
|
|
58
60
|
systemQuery: SystemQueryFn,
|
|
59
61
|
tenantId: string,
|
|
60
62
|
): Promise<BrandingTokens> {
|
|
61
63
|
try {
|
|
62
64
|
return coerceBranding(await systemQuery(BRANDING_QUERY_QN, {}, tenantId));
|
|
63
|
-
} catch {
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof RateLimitError) throw error;
|
|
64
67
|
return EMPTY_BRANDING;
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -207,12 +210,22 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
207
210
|
// von resolverTrust: "authoritative" zurecht abgelehnt; siehe
|
|
208
211
|
// systemQuery's Doc in http-route.ts). Branding ist Deko → ein
|
|
209
212
|
// Fehlschlag degradiert zum unbranded Default, blockt nie die Page.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
let pageSettled: { ok: true; data: BySlugQueryResult } | { ok: false };
|
|
214
|
+
let brandingResult: BrandingTokens;
|
|
215
|
+
try {
|
|
216
|
+
[pageSettled, brandingResult] = await Promise.all([
|
|
217
|
+
systemQuery(BY_SLUG_QN, { slug, lang }, tenantId)
|
|
218
|
+
.then((data) => ({ ok: true as const, data: data as BySlugQueryResult }))
|
|
219
|
+
.catch((error: unknown) => {
|
|
220
|
+
if (error instanceof RateLimitError) throw error;
|
|
221
|
+
return { ok: false as const };
|
|
222
|
+
}),
|
|
223
|
+
readBrandingViaSystemQuery(systemQuery, tenantId),
|
|
224
|
+
]);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
216
229
|
if (!pageSettled.ok) return c.text("page unavailable", 503);
|
|
217
230
|
|
|
218
231
|
const data = pageSettled.data;
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "notes-history adds a composite index on (tenantId, entityType, entityId) for note entries, so the schema-drift check fails until the app regenerates",
|
|
6
|
+
"detail": "Review batch H2: framework parts of consumer-app findings.\n- `file-derivatives`: the `isPublic` predicate args carry `fileRefId`, so a predicate can reject a client-spoofed FileRef that claims another entity's `entityId`/`fieldName`.\n- `sessions`: `createSessionsFeature({ adminAccess: \"systemAdmin\" })` narrows the admin list/detail queries and both admin screens together (default `\"admin\"`, unchanged).\n- `user-profile`: `change-email` is open to every signed-in user (`openToAll`), so a tenant member whose only role is `TenantAdmin` can change their own email; the handler still re-checks the password.\n- `notes-history`: composite index on `(tenantId, entityType, entityId)` for note entries. Apps run `kumiko-schema generate` after the bump.\n- guards: `// kumiko-lint-ignore a,b reason` suppresses several guards on one line (`lineHasIgnoreTag`); `primitives-discipline` and `no-custom-primitives` honour it.\n- dev-server: `kumiko-build --check` verifies `.kumiko/` is current without writing (exit 1 on drift); `runCodegen` takes `checkOnly`.",
|
|
7
|
+
"migration": "Run `kumiko-schema generate` in apps that mount notes-history and commit the generated migration."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.354.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "read_note_mentions gets a tenant, subject index",
|
|
13
|
+
"migration": "Run `kumiko schema generate`; it emits an in-place CREATE INDEX with no DROP and no rebuild."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.338.0",
|
|
4
17
|
"type": "improvement",
|
|
@@ -91,6 +91,8 @@ export function createNoteEntryEntity(
|
|
|
91
91
|
find: "none",
|
|
92
92
|
}),
|
|
93
93
|
},
|
|
94
|
+
// Every host detail screen reads its notes by tenant + host entity.
|
|
95
|
+
indexes: [{ columns: ["tenantId", "entityType", "entityId"] }],
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -122,6 +124,8 @@ export function createNoteMentionEntity() {
|
|
|
122
124
|
}),
|
|
123
125
|
subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
|
|
124
126
|
},
|
|
127
|
+
// The forget cascade looks mentions up by subject within one tenant.
|
|
128
|
+
indexes: [{ columns: ["tenantId", "subjectId"] }],
|
|
125
129
|
});
|
|
126
130
|
}
|
|
127
131
|
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type EntityDefinition,
|
|
21
21
|
type FeatureRegistrar,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
-
import {
|
|
23
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
24
24
|
import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants.js";
|
|
25
25
|
import { createNoteEntryEntity, noteMentionEntity } from "./entity.js";
|
|
26
26
|
import { createNoteEntryExecutor } from "./executor.js";
|
|
@@ -123,17 +123,7 @@ export function createNotesHistoryFeature(
|
|
|
123
123
|
) {
|
|
124
124
|
return notesHistoryFeature;
|
|
125
125
|
}
|
|
126
|
-
|
|
127
|
-
throw new Error(
|
|
128
|
-
"createNotesHistoryFeature({ ownership }): ownership.write must not contain a " +
|
|
129
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
130
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
131
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
132
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
133
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
134
|
-
"leave it unset.",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
126
|
+
assertNoWhereRuleInOwnershipWrite("createNotesHistoryFeature", opts.ownership?.write);
|
|
137
127
|
// A mount that accepts no parents at all can never take a note write —
|
|
138
128
|
// that's a config mistake, not a valid allowlist. Omit `parents` instead
|
|
139
129
|
// of passing an empty array.
|
|
@@ -66,6 +66,7 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
|
|
|
66
66
|
},
|
|
67
67
|
},
|
|
68
68
|
escapeHatch: {
|
|
69
|
+
grants: ["systemIdentity"],
|
|
69
70
|
reason:
|
|
70
71
|
"Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
|
|
71
72
|
"user:findForAuth) to re-verify the current password before minting the token. Also " +
|
|
@@ -4,10 +4,25 @@ import {
|
|
|
4
4
|
type ProjectionListScreenDefinition,
|
|
5
5
|
type SecretMintScreenDefinition,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import {
|
|
7
|
+
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
|
+
import { fieldOptionLabelKeyPrefix } from "@cosmicdrift/kumiko-headless";
|
|
9
|
+
import {
|
|
10
|
+
PAT_FEATURE,
|
|
11
|
+
PAT_MINT_SCREEN_ID,
|
|
12
|
+
PAT_SCREEN_ID,
|
|
13
|
+
PatHandlers,
|
|
14
|
+
PatQueries,
|
|
15
|
+
} from "./constants.js";
|
|
8
16
|
import type { PatScopeConfig } from "./scopes.js";
|
|
9
17
|
|
|
10
18
|
const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
|
|
19
|
+
// Same keys the mint form's `scopes` multiSelect options resolve through
|
|
20
|
+
// (see patScopeOptionTranslations), so list and form show identical labels.
|
|
21
|
+
const PAT_SCOPE_OPTION_KEY_PREFIX = fieldOptionLabelKeyPrefix(
|
|
22
|
+
PAT_FEATURE,
|
|
23
|
+
ACTION_FORM_ENTITY,
|
|
24
|
+
"scopes",
|
|
25
|
+
);
|
|
11
26
|
|
|
12
27
|
// Both screens render only for tenants whose tier includes the feature; the
|
|
13
28
|
// availability query is rejected by the feature gate otherwise.
|
|
@@ -29,11 +44,17 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
29
44
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
30
45
|
columns: [
|
|
31
46
|
{ field: "name", label: i18nKey("pat.list.col.name") },
|
|
32
|
-
{ field: "prefix", label: i18nKey("pat.list.col.prefix") },
|
|
33
|
-
{
|
|
47
|
+
{ field: "prefix", label: i18nKey("pat.list.col.prefix"), sortable: false },
|
|
48
|
+
{
|
|
49
|
+
field: "scopes",
|
|
50
|
+
label: i18nKey("pat.list.col.scopes"),
|
|
51
|
+
sortable: false,
|
|
52
|
+
renderer: { format: "enumOption", keyPrefix: PAT_SCOPE_OPTION_KEY_PREFIX },
|
|
53
|
+
},
|
|
34
54
|
{
|
|
35
55
|
field: "status",
|
|
36
56
|
label: i18nKey("pat.list.col.status"),
|
|
57
|
+
sortable: false,
|
|
37
58
|
renderer: { format: "enumOption", keyPrefix: PAT_STATUS_OPTION_KEY_PREFIX },
|
|
38
59
|
},
|
|
39
60
|
{
|
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
type FeatureDefinition,
|
|
6
6
|
SYSTEM_TENANT_ID,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
9
|
import { LEGAL_ROUTES } from "../legal-pages/index.js";
|
|
9
10
|
import { cachedSecurePageResponse } from "../page-render/index.js";
|
|
10
|
-
import type
|
|
11
|
+
import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.js";
|
|
11
12
|
import { SEO_CONFIG_KEYS, SEO_DEFAULT_PATHS } from "./constants.js";
|
|
12
13
|
import { seoConfigQuery } from "./handlers/seo-config.query.js";
|
|
13
14
|
import { buildLlmsTxt } from "./llms-txt.js";
|
|
@@ -125,7 +126,9 @@ async function gatherEntries(
|
|
|
125
126
|
lastmod: page.updatedAt,
|
|
126
127
|
});
|
|
127
128
|
}
|
|
128
|
-
} catch {
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// A rate limit must not look like "pages removed" to a crawler.
|
|
131
|
+
if (error instanceof RateLimitError) throw error;
|
|
129
132
|
// managed-pages unreachable/not mounted — degrade to callback-only entries.
|
|
130
133
|
}
|
|
131
134
|
}
|
|
@@ -188,7 +191,13 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
188
191
|
anonymous: true,
|
|
189
192
|
handler: async (c, { systemQuery }) => {
|
|
190
193
|
const { origin, host } = requestHost(c);
|
|
191
|
-
|
|
194
|
+
let entries: SitemapEntry[];
|
|
195
|
+
try {
|
|
196
|
+
entries = await gatherEntries(opts, systemQuery, origin, host);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
192
201
|
const xml = buildSitemapXml(entries);
|
|
193
202
|
const etag = computeRevisionEtag([host, xml]);
|
|
194
203
|
return cachedSecurePageResponse(c.req.raw, {
|
|
@@ -209,10 +218,17 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
209
218
|
const tenantId = opts.managedPages
|
|
210
219
|
? ((await opts.managedPages.resolveApexTenant(host)) ?? SYSTEM_TENANT_ID)
|
|
211
220
|
: SYSTEM_TENANT_ID;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
let entries: SitemapEntry[];
|
|
222
|
+
let seoConfig: SeoConfigValues;
|
|
223
|
+
try {
|
|
224
|
+
[entries, seoConfig] = await Promise.all([
|
|
225
|
+
gatherEntries(opts, systemQuery, origin, host),
|
|
226
|
+
readSeoConfig(systemQuery, tenantId),
|
|
227
|
+
]);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
216
232
|
const sections =
|
|
217
233
|
entries.length > 0
|
|
218
234
|
? [
|
|
@@ -4,18 +4,26 @@ import {
|
|
|
4
4
|
type SessionStoreProvider,
|
|
5
5
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
6
6
|
import { type DbConnection, deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
access,
|
|
9
|
+
defineFeature,
|
|
10
|
+
type FeatureDefinition,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
12
|
import { USER_SESSION_ENTITY_NAME } from "./constants.js";
|
|
9
13
|
import { cleanupJob } from "./handlers/cleanup.job.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
14
|
+
import { createDetailQuery } from "./handlers/detail.query.js";
|
|
15
|
+
import { createListQuery } from "./handlers/list.query.js";
|
|
12
16
|
import { mineQuery } from "./handlers/mine.query.js";
|
|
13
17
|
import { revokeWrite } from "./handlers/revoke.write.js";
|
|
14
18
|
import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write.js";
|
|
15
19
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write.js";
|
|
16
20
|
import { SESSIONS_I18N } from "./i18n.js";
|
|
17
21
|
import { userSessionEntity } from "./schema/user-session.js";
|
|
18
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createSessionDetailScreen,
|
|
24
|
+
createSessionListScreen,
|
|
25
|
+
sessionMineScreen,
|
|
26
|
+
} from "./screens.js";
|
|
19
27
|
import {
|
|
20
28
|
createSessionCallbacks,
|
|
21
29
|
type SessionAllOthersRevoker,
|
|
@@ -45,6 +53,10 @@ export type SessionsFeatureOptions = {
|
|
|
45
53
|
// Session JWT / store_user_sessions TTL. Was previously auth.sessions.expiresInMs
|
|
46
54
|
// (#1372) — lives on the sessions feature now that sessionStore is the wiring.
|
|
47
55
|
readonly expiresInMs?: number;
|
|
56
|
+
// Access preset for the admin list/detail queries and screens; they expose decrypted
|
|
57
|
+
// IP/user-agent of every session in the tenant. A preset name, not a role array, because
|
|
58
|
+
// dedupeOptions must stay primitive.
|
|
59
|
+
readonly adminAccess?: "admin" | "systemAdmin";
|
|
48
60
|
};
|
|
49
61
|
|
|
50
62
|
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
@@ -89,6 +101,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
89
101
|
return defineFeature(
|
|
90
102
|
"sessions",
|
|
91
103
|
(r) => {
|
|
104
|
+
const adminRoles = access[options?.adminAccess ?? "admin"];
|
|
92
105
|
r.describe(
|
|
93
106
|
"Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
94
107
|
);
|
|
@@ -162,8 +175,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
162
175
|
|
|
163
176
|
const queries = {
|
|
164
177
|
mine: r.queryHandler(mineQuery),
|
|
165
|
-
list: r.queryHandler(
|
|
166
|
-
detail: r.queryHandler(
|
|
178
|
+
list: r.queryHandler(createListQuery(adminRoles)),
|
|
179
|
+
detail: r.queryHandler(createDetailQuery(adminRoles)),
|
|
167
180
|
};
|
|
168
181
|
|
|
169
182
|
// Retention: chunked DELETE of expired/revoked rows. Manual trigger
|
|
@@ -222,8 +235,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
222
235
|
|
|
223
236
|
r.translations({ keys: SESSIONS_I18N });
|
|
224
237
|
|
|
225
|
-
r.screen(
|
|
226
|
-
r.screen(
|
|
238
|
+
r.screen(createSessionListScreen(adminRoles));
|
|
239
|
+
r.screen(createSessionDetailScreen(adminRoles));
|
|
227
240
|
r.screen(sessionMineScreen);
|
|
228
241
|
r.nav({
|
|
229
242
|
id: "session-list",
|
|
@@ -16,36 +16,37 @@ const sessionRowSchema = z.object({
|
|
|
16
16
|
userAgent: z.string().nullable(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
export const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
19
|
+
export const createDetailQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
20
|
+
defineQueryHandler({
|
|
21
|
+
name: "user-session:detail",
|
|
22
|
+
schema: z.object({ id: z.uuid() }),
|
|
23
|
+
access: { roles: adminRoles },
|
|
24
|
+
description:
|
|
25
|
+
"Loads one session of the active tenant by its id with owner, timestamps and decrypted IP/user-agent; use it when an admin investigates a specific session found through the session list.",
|
|
26
|
+
// Decrypted IP and user-agent.
|
|
27
|
+
agent: { risk: "high" },
|
|
28
|
+
outputSchema: sessionRowSchema.nullable(),
|
|
29
|
+
handler: async (query, ctx) => {
|
|
30
|
+
const row = await fetchOne<{
|
|
31
|
+
id: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
createdAt: unknown;
|
|
34
|
+
expiresAt: unknown;
|
|
35
|
+
revokedAt: unknown;
|
|
36
|
+
ip: string | null;
|
|
37
|
+
userAgent: string | null;
|
|
38
|
+
}>(ctx.db, userSessionTable, { id: query.payload.id });
|
|
39
|
+
if (!row) return null;
|
|
40
|
+
return {
|
|
41
|
+
id: row.id,
|
|
42
|
+
userId: row.userId,
|
|
43
|
+
createdAt: row.createdAt,
|
|
44
|
+
expiresAt: row.expiresAt,
|
|
45
|
+
revokedAt: row.revokedAt,
|
|
46
|
+
ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
|
|
47
|
+
userAgent: row.userAgent
|
|
48
|
+
? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
|
|
49
|
+
: row.userAgent,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -25,68 +25,71 @@ function isSortableColumn(value: string): value is SortableColumn {
|
|
|
25
25
|
// tenantId column. Includes revoked rows; UI shows revokedAt distinct.
|
|
26
26
|
// No cursor — nextCursor is always null; `limit` only caps the page size,
|
|
27
27
|
// it doesn't offset into a further one.
|
|
28
|
-
export const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
z.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
: r.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
28
|
+
export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
29
|
+
definePagedQueryHandler({
|
|
30
|
+
name: "user-session:list",
|
|
31
|
+
schema: z.object({
|
|
32
|
+
limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
|
|
33
|
+
sort: z.string().optional(),
|
|
34
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
35
|
+
}),
|
|
36
|
+
access: { roles: adminRoles },
|
|
37
|
+
description:
|
|
38
|
+
"Lists every session in the active tenant, live and revoked, sortable by id, user, creation, expiry or revocation, for an admin auditing who is signed in.",
|
|
39
|
+
outputSchema: z.object({
|
|
40
|
+
rows: z.array(
|
|
41
|
+
z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
userId: z.string(),
|
|
44
|
+
createdAt: z.unknown(),
|
|
45
|
+
expiresAt: z.unknown(),
|
|
46
|
+
revokedAt: z.unknown(),
|
|
47
|
+
ip: z.string().nullable(),
|
|
48
|
+
userAgent: z.string().nullable(),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
nextCursor: z.string().nullable(),
|
|
52
|
+
}),
|
|
53
|
+
handler: async (query, ctx) => {
|
|
54
|
+
const requestedSort = query.payload.sort;
|
|
55
|
+
const sortColumn: SortableColumn =
|
|
56
|
+
requestedSort !== undefined && isSortableColumn(requestedSort)
|
|
57
|
+
? requestedSort
|
|
58
|
+
: "createdAt";
|
|
59
|
+
const rows = await selectMany<{
|
|
60
|
+
id: string;
|
|
61
|
+
userId: string;
|
|
62
|
+
createdAt: unknown;
|
|
63
|
+
expiresAt: unknown;
|
|
64
|
+
revokedAt: unknown;
|
|
65
|
+
ip: string | null;
|
|
66
|
+
userAgent: string | null;
|
|
67
|
+
}>(ctx.db, userSessionTable, undefined, {
|
|
68
|
+
// `id` as a tie-breaker keeps row order (and, with `limit` set, row
|
|
69
|
+
// selection) deterministic across identical requests — sortColumn
|
|
70
|
+
// alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
|
|
71
|
+
orderBy:
|
|
72
|
+
sortColumn === "id"
|
|
73
|
+
? { col: "id", direction: query.payload.sortDirection ?? "desc" }
|
|
74
|
+
: [
|
|
75
|
+
{ col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
|
|
76
|
+
{ col: "id", direction: "asc" },
|
|
77
|
+
],
|
|
78
|
+
...(query.payload.limit !== undefined && { limit: query.payload.limit }),
|
|
79
|
+
});
|
|
80
|
+
const decryptedRows = await Promise.all(
|
|
81
|
+
rows.map(async (r) => ({
|
|
82
|
+
id: r.id,
|
|
83
|
+
userId: r.userId,
|
|
84
|
+
createdAt: r.createdAt,
|
|
85
|
+
expiresAt: r.expiresAt,
|
|
86
|
+
revokedAt: r.revokedAt,
|
|
87
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
|
|
88
|
+
userAgent: r.userAgent
|
|
89
|
+
? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
|
|
90
|
+
: r.userAgent,
|
|
91
|
+
})),
|
|
92
|
+
);
|
|
93
|
+
return { rows: decryptedRows, nextCursor: null };
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
SessionQueries,
|
|
14
14
|
} from "./constants.js";
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export const createSessionListScreen = (
|
|
17
|
+
adminRoles: readonly string[] = access.admin,
|
|
18
|
+
): ProjectionListScreenDefinition => ({
|
|
19
19
|
id: SESSION_LIST_SCREEN_ID,
|
|
20
20
|
type: "projectionList",
|
|
21
21
|
query: SessionQueries.list,
|
|
@@ -61,10 +61,12 @@ export const sessionListScreen: ProjectionListScreenDefinition = {
|
|
|
61
61
|
rowClick: true,
|
|
62
62
|
},
|
|
63
63
|
],
|
|
64
|
-
access:
|
|
65
|
-
};
|
|
64
|
+
access: { roles: adminRoles },
|
|
65
|
+
});
|
|
66
66
|
|
|
67
|
-
export const
|
|
67
|
+
export const createSessionDetailScreen = (
|
|
68
|
+
adminRoles: readonly string[] = access.admin,
|
|
69
|
+
): ProjectionDetailScreenDefinition => ({
|
|
68
70
|
id: SESSION_DETAIL_SCREEN_ID,
|
|
69
71
|
type: "projectionDetail",
|
|
70
72
|
query: SessionQueries.detail,
|
|
@@ -97,8 +99,8 @@ export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
|
|
|
97
99
|
ip: i18nKey("sessions.detail.field.ip"),
|
|
98
100
|
userAgent: i18nKey("sessions.detail.field.userAgent"),
|
|
99
101
|
},
|
|
100
|
-
access:
|
|
101
|
-
};
|
|
102
|
+
access: { roles: adminRoles },
|
|
103
|
+
});
|
|
102
104
|
|
|
103
105
|
// Revoke is hidden on the current session — it would sign the user out mid-click; logout covers it.
|
|
104
106
|
export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// Boot validation rejects `{ kind: "where" }` in access.write too (fw#2626);
|
|
4
|
+
// failing at factory time names the option instead of an entity scope.
|
|
5
|
+
export function assertNoWhereRuleInOwnershipWrite(
|
|
6
|
+
factoryName: string,
|
|
7
|
+
writeOwnership: OwnershipMap | undefined,
|
|
8
|
+
): void {
|
|
9
|
+
// skip: no write ownership declared, nothing to validate
|
|
10
|
+
if (!writeOwnership) return;
|
|
11
|
+
const hasWhereRule = Object.values(writeOwnership).some(
|
|
12
|
+
(rule) => rule !== "all" && rule.kind === "where",
|
|
13
|
+
);
|
|
14
|
+
// skip: no where-rule present, the option is valid
|
|
15
|
+
if (!hasWhereRule) return;
|
|
16
|
+
throw new Error(
|
|
17
|
+
`${factoryName}({ ownership }): ownership.write must not contain a ` +
|
|
18
|
+
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
19
|
+
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
20
|
+
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
21
|
+
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
22
|
+
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
23
|
+
"leave it unset.",
|
|
24
|
+
);
|
|
25
|
+
}
|