@cosmicdrift/kumiko-samples 0.373.0 → 0.377.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 +6 -4
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/constants.ts +1 -0
- package/packages/bundled-features/src/account-security/index.ts +1 -0
- package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/client-actions.ts +36 -0
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -4
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
- package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
- package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
- package/packages/bundled-features/src/cap-overview/changes.json +7 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +15 -76
- package/packages/bundled-features/src/cap-overview/i18n.ts +0 -2
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
- package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
- package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/config/feature.ts +2 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
- package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
- package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
- package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
- package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
- package/packages/bundled-features/src/host-routing/README.md +7 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/constants.ts +4 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
- package/packages/bundled-features/src/host-routing/feature.ts +122 -41
- package/packages/bundled-features/src/host-routing/host.ts +28 -1
- package/packages/bundled-features/src/host-routing/index.ts +3 -0
- package/packages/bundled-features/src/host-routing/options.ts +45 -2
- package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
- package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
- package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
- package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
- package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/page-render/branding.ts +1 -1
- package/packages/bundled-features/src/page-render/layout.ts +2 -2
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +4 -4
- package/packages/bundled-features/src/seo/sitemap.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
- package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +18 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +34 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +9 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +125 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
- package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
- package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -8
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -1
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +7 -33
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +24 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
- package/packages/bundled-features/src/waitlist/changes.json +6 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
- package/samples/apps/admin-console/package.json +0 -3
- package/samples/apps/admin-console/src/app/client.tsx +1 -1
- package/samples/apps/admin-console/src/app/shell.tsx +1 -1
- package/samples/apps/config-demo/package.json +1 -3
- package/samples/apps/config-demo/src/app/client.tsx +1 -1
- package/samples/apps/config-demo/src/app/shell.tsx +2 -2
- package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
- package/samples/apps/config-demo/tsconfig.json +0 -12
- package/samples/apps/marketing-demo/package.json +1 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
- package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
- package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
- package/samples/apps/showcase/package.json +1 -3
- package/samples/apps/showcase/src/app/client.tsx +1 -1
- package/samples/apps/showcase/src/app/shell.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
- package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
- package/samples/apps/styleguide/package.json +1 -3
- package/samples/apps/styleguide/src/app/client.tsx +1 -1
- package/samples/apps/styleguide/src/app/shell.tsx +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
- package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
- package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +17 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +8 -0
- package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
- package/samples/apps/styleguide/tsconfig.json +0 -12
- package/samples/apps/ui-walkthrough/package.json +1 -3
- package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
- package/samples/apps/use-all-bundled/bin/main.ts +3 -2
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -0
- package/samples/apps/use-all-bundled/package.json +1 -3
- package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
- package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
- package/samples/apps/workspaces/package.json +1 -3
- package/samples/apps/workspaces/src/app/client.tsx +1 -1
- package/samples/apps/workspaces/src/app/shell.tsx +1 -1
- package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
- package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-landing/src/feature.ts +1 -1
- package/samples/recipes/apex-surface-auth/README.md +1 -1
- package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
- package/samples/recipes/compose-stacks/README.md +7 -4
- package/samples/recipes/embedded-entity-form/package.json +0 -1
- package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/record-detail-layout/package.json +0 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/user-profile/README.md +1 -1
- package/samples/recipes/wizard-form/package.json +0 -1
- package/samples/recipes/wizard-form/src/web/index.ts +2 -2
- package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
- package/samples/recipes/writeform-section/package.json +0 -1
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.374.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "tenant-caps:list pages by offset and rejects cursor",
|
|
6
|
+
"detail": "The cap overview list showed page 1 on every page because the pager sends offset and the query only read cursor. tenant-caps:list now takes offset like the pager; a cursor in the payload is rejected with a 400 validation error and sort is an enum of the sortable fields. The unused cap-overview.errors.invalidCursor and sortFieldUnsupported keys are gone.",
|
|
7
|
+
"migration": "Callers of cap-overview:query:tenant-caps:list must send offset instead of cursor, and only a sortable field name as sort. Apps that override the removed i18n keys cap-overview.errors.invalidCursor or cap-overview.errors.sortFieldUnsupported can delete them. No caller in the kumiko-enterprise, kumiko-platform, kumiko-studio or app repos sends a cursor or defines these keys."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.365.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildEntityTable,
|
|
3
|
-
decodeCursor,
|
|
4
|
-
encodeCursor,
|
|
5
|
-
selectMany,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { buildEntityTable, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
7
2
|
import {
|
|
8
3
|
type ConfigAccessor,
|
|
9
4
|
definePagedQueryHandler,
|
|
10
|
-
|
|
5
|
+
pagedListSchema,
|
|
6
|
+
pageRows,
|
|
11
7
|
type TenantId,
|
|
12
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
9
|
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
14
|
-
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
15
10
|
import * as z from "zod";
|
|
16
11
|
import { subscriptionsProjectionTable } from "../../billing-foundation/index.ts";
|
|
17
12
|
import type { CapLimitContext } from "../../cap-counter/index.ts";
|
|
@@ -59,7 +54,6 @@ const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
|
|
|
59
54
|
type FilterOp = z.infer<typeof FILTER_OP>;
|
|
60
55
|
|
|
61
56
|
const SORTABLE_FIELDS = ["name", "tier", "billing"] as const;
|
|
62
|
-
type SortableField = (typeof SORTABLE_FIELDS)[number];
|
|
63
57
|
|
|
64
58
|
function tenantCapKey(tenantId: string, capId: string): string {
|
|
65
59
|
return JSON.stringify([tenantId, capId]);
|
|
@@ -83,10 +77,6 @@ async function resolveLimitsByTenantAndCap(
|
|
|
83
77
|
return new Map(entries);
|
|
84
78
|
}
|
|
85
79
|
|
|
86
|
-
function isSortableField(field: string): field is SortableField {
|
|
87
|
-
return isOneOf(SORTABLE_FIELDS, field);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
80
|
// tier is a plain string with no defined ordering — lt/gt have no meaning
|
|
91
81
|
// on it and are rejected rather than silently treated as "no filter".
|
|
92
82
|
function assertSupportedTierFilterOp(op: FilterOp): void {
|
|
@@ -152,16 +142,10 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
152
142
|
name: "tenant-caps:list",
|
|
153
143
|
description:
|
|
154
144
|
"Lists every tenant on the platform with its tier, billing provider/status and per-cap usage, paged, searchable by name and filterable by tier; use it as the operator-wide capacity overview.",
|
|
155
|
-
schema:
|
|
156
|
-
cursor: z.string().optional(),
|
|
157
|
-
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
|
|
158
|
-
sort: z.string().optional(),
|
|
159
|
-
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
160
|
-
search: z.string().optional(),
|
|
145
|
+
schema: pagedListSchema(SORTABLE_FIELDS).extend({
|
|
161
146
|
filters: z
|
|
162
147
|
.array(z.object({ field: z.string(), op: FILTER_OP, value: z.unknown() }))
|
|
163
148
|
.optional(),
|
|
164
|
-
totalCount: z.boolean().optional(),
|
|
165
149
|
}),
|
|
166
150
|
access: { roles: ["SystemAdmin"] },
|
|
167
151
|
escapeHatch: {
|
|
@@ -190,68 +174,30 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
190
174
|
const assignmentByTenant = new Map(assignments.map((row) => [row.tenantId, row]));
|
|
191
175
|
const subscriptionByTenant = new Map(subscriptions.map((row) => [row.tenantId, row]));
|
|
192
176
|
|
|
193
|
-
// Case-insensitive substring search has no `ilike` operator in the
|
|
194
|
-
// query builder (only `like`, SQL-LIKE case-sensitive) — filtered here
|
|
195
|
-
// in JS, same as tenant/team-list.query.ts's search.
|
|
196
|
-
const search = query.payload.search?.trim().toLowerCase();
|
|
197
177
|
const tierFilter = extractTierFilter(query.payload.filters ?? []);
|
|
198
178
|
|
|
199
179
|
const merged = tenants
|
|
200
|
-
.filter(
|
|
201
|
-
(tenant) =>
|
|
202
|
-
search === undefined || search === "" || tenant.name.toLowerCase().includes(search),
|
|
203
|
-
)
|
|
204
180
|
.map((tenant) => {
|
|
205
181
|
const assignment = assignmentByTenant.get(tenant.id);
|
|
206
182
|
return {
|
|
183
|
+
id: tenant.id,
|
|
207
184
|
tenantId: tenant.id,
|
|
208
185
|
name: tenant.name,
|
|
209
186
|
tier: assignment?.tier ?? "",
|
|
210
|
-
source: assignment?.source ?? null,
|
|
211
187
|
billing: billingLabel(subscriptionByTenant.get(tenant.id), assignment?.source ?? null),
|
|
212
188
|
};
|
|
213
189
|
})
|
|
214
190
|
.filter((row) => matchesTierFilter(row.tier, tierFilter));
|
|
215
191
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
},
|
|
226
|
-
],
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
const sortDirection = query.payload.sortDirection ?? "asc";
|
|
230
|
-
const directionMultiplier = sortDirection === "asc" ? 1 : -1;
|
|
231
|
-
const sorted = [...merged].sort(
|
|
232
|
-
(a, b) => a[sortField].localeCompare(b[sortField]) * directionMultiplier,
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
// ponytail: base64-wrapped offset, not a SQL keyset — rows come from a
|
|
236
|
-
// JS merge of three tables (tenant/tier-assignment/subscription), same
|
|
237
|
-
// rationale as tenant/team-list.query.ts. Holds up to tier-engine's
|
|
238
|
-
// documented "single-pass scan" scale (a few thousand tenants); past
|
|
239
|
-
// that the fix is a real combined read-projection, not a cursor
|
|
240
|
-
// bolted onto this merge.
|
|
241
|
-
const offset = query.payload.cursor ? Number(decodeCursor(query.payload.cursor)) : 0;
|
|
242
|
-
if (!Number.isInteger(offset) || offset < 0) {
|
|
243
|
-
throw new ValidationError({
|
|
244
|
-
fields: [
|
|
245
|
-
{
|
|
246
|
-
path: "cursor",
|
|
247
|
-
code: "invalid_cursor",
|
|
248
|
-
i18nKey: "cap-overview.errors.invalidCursor",
|
|
249
|
-
},
|
|
250
|
-
],
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
const limit = query.payload.limit;
|
|
254
|
-
const page = sorted.slice(offset, offset + limit);
|
|
192
|
+
// Search and paging run in memory because the rows are a JS merge of
|
|
193
|
+
// three tables (tenant/tier-assignment/subscription), same rationale as
|
|
194
|
+
// tenant/team-list.query.ts. Holds up to tier-engine's documented
|
|
195
|
+
// "single-pass scan" scale (a few thousand tenants); past that the fix
|
|
196
|
+
// is a real combined read-projection.
|
|
197
|
+
const { rows: page, total } = pageRows(merged, query.payload, {
|
|
198
|
+
searchFields: ["name"],
|
|
199
|
+
defaultSort: "name",
|
|
200
|
+
});
|
|
255
201
|
|
|
256
202
|
// Usage is computed only for this page's tenant ids, never for the
|
|
257
203
|
// full tenant set; caps without usageBatch still cost one query per
|
|
@@ -302,14 +248,7 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
302
248
|
};
|
|
303
249
|
});
|
|
304
250
|
|
|
305
|
-
|
|
306
|
-
offset + limit < sorted.length ? encodeCursor(String(offset + limit)) : null;
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
rows,
|
|
310
|
-
nextCursor,
|
|
311
|
-
...(query.payload.totalCount === true && { total: merged.length }),
|
|
312
|
-
};
|
|
251
|
+
return { rows, nextCursor: null, ...(total !== undefined && { total }) };
|
|
313
252
|
},
|
|
314
253
|
});
|
|
315
254
|
}
|
|
@@ -17,11 +17,9 @@ export const CAP_OVERVIEW_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
17
17
|
"cap-overview.errors.progressPrimitiveMissing": {
|
|
18
18
|
en: "Usage bar unavailable — Progress primitive is not registered.",
|
|
19
19
|
},
|
|
20
|
-
"cap-overview.errors.invalidCursor": { en: "The pagination cursor is invalid." },
|
|
21
20
|
"cap-overview.errors.filterFieldUnsupported": {
|
|
22
21
|
en: "Filtering by this field is not supported, or tier was filtered more than once.",
|
|
23
22
|
},
|
|
24
|
-
"cap-overview.errors.sortFieldUnsupported": { en: "Sorting by this field is not supported." },
|
|
25
23
|
"cap-overview.errors.tenantOverrideRequiresSystemAdmin": {
|
|
26
24
|
en: "Only SystemAdmin may query another tenant's data.",
|
|
27
25
|
},
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
|
|
2
1
|
// @runtime client
|
|
3
2
|
import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
|
|
4
3
|
import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
|
|
5
5
|
import {
|
|
6
6
|
EmptyState,
|
|
7
7
|
StatCard,
|
|
8
8
|
StatusBadge,
|
|
9
9
|
type StatusTone,
|
|
10
|
-
} from "@cosmicdrift/kumiko-renderer
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer/web";
|
|
11
11
|
import { Database, FileText, Gauge, Hash, Mail, Users } from "lucide-react";
|
|
12
12
|
import type { ComponentType, ReactNode } from "react";
|
|
13
13
|
import { CapOverviewQueries } from "../constants.ts";
|
|
@@ -68,7 +68,7 @@ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNod
|
|
|
68
68
|
return (
|
|
69
69
|
<div
|
|
70
70
|
// Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
|
|
71
|
-
// styles.css's Tailwind v4 @source scan only covers renderer
|
|
71
|
+
// styles.css's Tailwind v4 @source scan only covers renderer/web/src,
|
|
72
72
|
// renderer/src and samples/**/src (see its comment) — bundled-features
|
|
73
73
|
// isn't scanned, so a class unique to this file has no generated rule
|
|
74
74
|
// and silently no-ops (the grid fell back to one implicit column,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import {
|
|
5
5
|
CAP_CARDS_PANEL_COMPONENT,
|
|
6
6
|
CAP_OVERVIEW_FEATURE,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.376.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "resolveEmail receives a typed tenant database",
|
|
6
|
+
"detail": "The context of EmailChannelOptions resolveEmail types db as TenantDb, so resolvers read tables without a cast."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
5
|
import * as z from "zod";
|
|
@@ -185,7 +186,7 @@ type EmailChannelTransports =
|
|
|
185
186
|
export type EmailChannelOptions = EmailChannelTransports & {
|
|
186
187
|
readonly resolveEmail: (
|
|
187
188
|
userId: string,
|
|
188
|
-
ctx: { db:
|
|
189
|
+
ctx: { db: TenantDb; tenantId: TenantId },
|
|
189
190
|
) => Promise<string | null>;
|
|
190
191
|
};
|
|
191
192
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import {
|
|
5
5
|
COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME,
|
|
6
6
|
COMPLIANCE_PROFILES_FEATURE,
|
|
@@ -95,7 +95,7 @@ export function createConfigAccessor(
|
|
|
95
95
|
return configAccessor;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// Pass to the test-stack / server-boot as `
|
|
98
|
+
// Pass to the test-stack / server-boot as `configAccessorFactory` —
|
|
99
99
|
// `buildHandlerContext` mints a per-user `ctx.config` from this.
|
|
100
100
|
export function createConfigAccessorFactory(
|
|
101
101
|
registry: Registry,
|
|
@@ -115,7 +115,7 @@ export function requireConfigResolver(ctx: HandlerContext, handlerName: string):
|
|
|
115
115
|
throw new InternalError({
|
|
116
116
|
message:
|
|
117
117
|
`[${handlerName}] ctx.configResolver missing — pass createConfigAccessorFactory's ` +
|
|
118
|
-
`output via extraContext.
|
|
118
|
+
`output via extraContext.configAccessorFactory and the resolver via ` +
|
|
119
119
|
`extraContext.configResolver at boot.`,
|
|
120
120
|
});
|
|
121
121
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.377.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Forgetting a user also removes their unbound pending uploads",
|
|
6
|
+
"detail": "forget-subject for a user deleted only files bound to a record. Uploads the user made but never attached (file refs without an entity id) stayed behind. They are now removed with their bytes, except refs a record still names in a single-file column."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.372.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// } },
|
|
12
12
|
// ]}
|
|
13
13
|
|
|
14
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
14
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
15
15
|
import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants.ts";
|
|
16
16
|
import { CustomFieldsFormSection } from "./custom-fields-form-section.tsx";
|
|
17
17
|
import { defaultTranslations } from "./i18n.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
|
-
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
4
4
|
import type { MailChrome, MailChromeLink } from "../renderer-foundation/index.ts";
|
|
5
5
|
import { DELIVERY_RESUBSCRIBE_PATH, DELIVERY_UNSUBSCRIBE_PATH } from "./public-names.ts";
|
|
6
6
|
import "./unsubscribe-page-strings.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import {
|
|
5
5
|
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
6
6
|
DELIVERY_ERROR_CELL_COMPONENT,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// projectionList renderer — only this one cell stays TSX.
|
|
6
6
|
|
|
7
7
|
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
-
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer
|
|
8
|
+
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer/web";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { translateOrRaw } from "../../shared/web/translate-or-raw.ts";
|
|
11
11
|
import { DeliveryStatus } from "../public-names.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { FEATURE_TOGGLES_FEATURE } from "../constants.ts";
|
|
5
5
|
import { defaultTranslations } from "./i18n.ts";
|
|
6
6
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.376.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Named S3 provider feature",
|
|
6
|
+
"detail": "createFileProviderS3NamedEnvFeature mounts an additional S3 provider under its own name with an env prefix, next to the default one, so a second bucket needs no hand-written wiring."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.371.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileProviderContext,
|
|
3
|
+
FileProviderPlugin,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
5
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
|
|
7
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
8
|
+
import { createRuntimeS3EnvSchema } from "./env-schema.ts";
|
|
9
|
+
import { createS3ProviderFromEnv } from "./lib/env-helper.ts";
|
|
10
|
+
|
|
11
|
+
export type NamedS3EnvProviderOptions = {
|
|
12
|
+
/** Provider key tenants select in `file-foundation`'s `provider` config; the feature is `file-provider-s3-env-<name>`. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/** Env prefix of the credential set, e.g. `RELEASES_S3_`. */
|
|
15
|
+
readonly envPrefix: string;
|
|
16
|
+
/** Tolerate missing credentials at boot; the provider then fails on its first file operation. */
|
|
17
|
+
readonly lazy?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
|
|
21
|
+
export function createFileProviderS3NamedEnvFeature(options: NamedS3EnvProviderOptions) {
|
|
22
|
+
const envSchema = createRuntimeS3EnvSchema(options.envPrefix);
|
|
23
|
+
const feature = defineFeature(
|
|
24
|
+
`file-provider-s3-env-${options.name}`,
|
|
25
|
+
(r) => {
|
|
26
|
+
r.describe(
|
|
27
|
+
`Registers the "${options.name}" provider for \`file-foundation\`: one shared S3 bucket for every tenant, credentials from the \`${options.envPrefix}*\` env vars. A second bucket next to the \`"s3-env"\` provider (for example release artifacts).`,
|
|
28
|
+
);
|
|
29
|
+
r.uiHints({
|
|
30
|
+
displayLabel: `File Provider · S3 (${options.name})`,
|
|
31
|
+
category: "storage",
|
|
32
|
+
recommended: false,
|
|
33
|
+
});
|
|
34
|
+
r.requires({ features: ["file-foundation"] });
|
|
35
|
+
r.envSchema(envSchema);
|
|
36
|
+
const plugin: FileProviderPlugin = {
|
|
37
|
+
build: async (_ctx: FileProviderContext, _tenantId: string): Promise<FileStorageProvider> =>
|
|
38
|
+
createS3ProviderFromEnv(options.envPrefix), // @wrapper-known semantic-alias
|
|
39
|
+
};
|
|
40
|
+
r.useExtension("fileProvider", options.name, plugin);
|
|
41
|
+
},
|
|
42
|
+
{ dedupeOptions: { name: options.name, envPrefix: options.envPrefix } },
|
|
43
|
+
);
|
|
44
|
+
return featureWithEnvSchema(feature, envSchema);
|
|
45
|
+
}
|
|
@@ -17,17 +17,8 @@ export const s3EndpointSchema = z
|
|
|
17
17
|
.url({ protocol: /^https?$/ })
|
|
18
18
|
.refine(isAcceptableEndpoint, "must use https unless the host is loopback");
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* Every field is optional: an app that mounts the env provider next to
|
|
23
|
-
* another one picks the provider at runtime, and the provider reports
|
|
24
|
-
* missing credentials on its first file operation. */
|
|
25
|
-
export function createS3EnvSchema<const TPrefix extends string>(options: {
|
|
26
|
-
readonly prefix: TPrefix;
|
|
27
|
-
}) {
|
|
28
|
-
const { prefix } = options;
|
|
29
|
-
// @cast-boundary schema-walk — computed template keys widen to an index signature; the literal keys below are exactly the ones S3EnvShape names.
|
|
30
|
-
const shape = {
|
|
20
|
+
function buildS3Shape(prefix: string) {
|
|
21
|
+
return {
|
|
31
22
|
[`${prefix}BUCKET`]: z.string().min(1).optional().describe("S3 bucket."),
|
|
32
23
|
[`${prefix}REGION`]: z.string().min(1).optional().describe("S3 region."),
|
|
33
24
|
[`${prefix}ACCESS_KEY`]: z
|
|
@@ -51,8 +42,24 @@ export function createS3EnvSchema<const TPrefix extends string>(options: {
|
|
|
51
42
|
.enum(["true", "false"])
|
|
52
43
|
.optional()
|
|
53
44
|
.describe('Path-style bucket addressing ("true"), needed by MinIO and some S3 clones.'),
|
|
54
|
-
}
|
|
55
|
-
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
|
|
49
|
+
* `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
|
|
50
|
+
* Every field is optional: an app that mounts the env provider next to
|
|
51
|
+
* another one picks the provider at runtime, and the provider reports
|
|
52
|
+
* missing credentials on its first file operation. */
|
|
53
|
+
export function createS3EnvSchema<const TPrefix extends string>(options: {
|
|
54
|
+
readonly prefix: TPrefix;
|
|
55
|
+
}) {
|
|
56
|
+
// @cast-boundary schema-walk — computed template keys widen to an index signature; the literal keys below are exactly the ones S3EnvShape names.
|
|
57
|
+
return z.object(buildS3Shape(options.prefix) as S3EnvShape<TPrefix>);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Same block for a prefix only known at runtime (named providers); keys are typed as a record. */
|
|
61
|
+
export function createRuntimeS3EnvSchema(prefix: string) {
|
|
62
|
+
return z.object(buildS3Shape(prefix));
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
type OptionalString = z.ZodOptional<z.ZodString>;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Public API of the file-provider-s3 bundled-feature.
|
|
2
2
|
|
|
3
|
+
export {
|
|
4
|
+
createFileProviderS3NamedEnvFeature,
|
|
5
|
+
type NamedS3EnvProviderOptions,
|
|
6
|
+
} from "./env-named-feature.ts";
|
|
3
7
|
export { createS3EnvSchema, s3EndpointSchema, s3EnvSchema } from "./env-schema.ts";
|
|
4
8
|
export {
|
|
5
9
|
createFileProviderS3Feature,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { FOLDER_SECTION_EXTENSION_NAME, FOLDERS_FEATURE_NAME } from "../constants.ts";
|
|
5
5
|
import { FolderSection } from "./folder-section.tsx";
|
|
6
6
|
import { defaultTranslations } from "./i18n.ts";
|
|
@@ -169,10 +169,9 @@ export function FolderSection({
|
|
|
169
169
|
/>
|
|
170
170
|
</Field>
|
|
171
171
|
|
|
172
|
-
{/* Inline create
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
onCreate-Prop bekommt. */}
|
|
172
|
+
{/* Inline create row: the folder input grows, the create button sits next
|
|
173
|
+
to it. Separate row because the combobox has no create-on-type
|
|
174
|
+
affordance; fold in once renderer/web gets an onCreate prop. */}
|
|
176
175
|
<div className="flex items-end gap-2">
|
|
177
176
|
<div className="flex-1">
|
|
178
177
|
<Field id="folder-section-new" label={t("folders.section.newLabel")}>
|
|
@@ -87,3 +87,10 @@ default applies. Options are validated when the feature or dispatch is built.
|
|
|
87
87
|
|
|
88
88
|
Custom domains per tenant, legacy host redirects and app-specific query
|
|
89
89
|
allowlists stay in the app: wrap the dispatch function and fall through to it.
|
|
90
|
+
|
|
91
|
+
## Resolver, redirects and allowlist
|
|
92
|
+
|
|
93
|
+
- `tenantBy: (host, db) => TenantId | null` resolves every host that is neither the apex nor a fixed host (profile subdomains, custom domains). The tenant must be enabled and active; null means 404 for pages and `tenant_required` for the API. `createHostTenantIdResolver(options, db)` exposes the same mapping for managed-pages' `resolveApexTenant`.
|
|
94
|
+
- `tenantPathPrefix` is optional in host and resolver mode and serves `<prefix>/<key>` on the apex. The API half still takes the apex anchor there; an app that needs a path-named tenant on API calls resolves it itself.
|
|
95
|
+
- `redirects: [{ from, to, host?, status? }]` run before host classification. `to` is configured, never derived from the request.
|
|
96
|
+
- `apexAnonymousQueryAllowlist: { "example.com": ["feature:query:x"] }` limits the anonymous apex anchor to the listed query types on that host, for `/api/query` and `/api/stream`; other paths keep the anchor. `example.com` and `www.example.com` share one entry (a host without its own uses the other's). Combined with `tenantBy: "path"` it throws at boot, because the client names its tenant there (X-Tenant wins over the resolver) and would bypass the allowlist.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.376.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "tenantBy resolver, apex path tenants, redirects and anonymous query allowlist",
|
|
6
|
+
"detail": "tenantBy accepts (host, db) => TenantId | null for profile subdomains and custom domains; tenantPathPrefix also works in host and resolver mode for apex path tenants. redirects send fixed 301/302 answers before host classification, apexAnonymousQueryAllowlist limits the anonymous apex anchor to listed query types per host (/api/query and /api/stream), and createHostTenantIdResolver(options, db) maps a Host header to its tenant for managed-pages. Existing \"host\" and \"path\" configs behave as before, except that apexAnonymousQueryAllowlist with tenantBy \"path\" is rejected at boot."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -2,6 +2,7 @@ export const HOST_ROUTING_FEATURE = "host-routing";
|
|
|
2
2
|
|
|
3
3
|
export const HostRoutingQueries = {
|
|
4
4
|
tenantKeyExists: `${HOST_ROUTING_FEATURE}:query:tenant-key-exists`,
|
|
5
|
+
hostTenantExists: `${HOST_ROUTING_FEATURE}:query:host-tenant-exists`,
|
|
5
6
|
} as const;
|
|
6
7
|
|
|
7
8
|
// Matches tenant:write:create's key limit; keeps lookups for absurd keys out
|
|
@@ -22,3 +23,6 @@ const PATH_TENANT_KEY_PATTERN = /^[A-Za-z0-9._~-]+$/;
|
|
|
22
23
|
export function isPathTenantKeyCandidate(value: string): boolean {
|
|
23
24
|
return value.length <= TENANT_KEY_MAX_LENGTH && PATH_TENANT_KEY_PATTERN.test(value);
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
// Longest Host header value a hostname plus port can take.
|
|
28
|
+
export const HOST_HEADER_MAX_LENGTH = 260;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { RateLimitError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
-
import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-headless/apex";
|
|
3
|
+
import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { HostRoutingQueries, isPathTenantKeyCandidate, isTenantKeyCandidate } from "./constants.ts";
|
|
6
|
-
import { classifyHost } from "./host.ts";
|
|
6
|
+
import { classifyHost, splitFirstSegment, tenantKeyFromPath } from "./host.ts";
|
|
7
7
|
import type { HostRoutingOptions, HostRoutingSurface } from "./options.ts";
|
|
8
|
+
import { matchRedirect, sameOriginPath } from "./redirects.ts";
|
|
8
9
|
import { validateHostRoutingOptions } from "./validate-options.ts";
|
|
9
10
|
|
|
10
11
|
// Structurally identical to server-runtime's HostDispatchResult. Defined here
|
|
@@ -43,6 +44,21 @@ async function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string):
|
|
|
43
44
|
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
async function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
|
|
48
|
+
let result: unknown;
|
|
49
|
+
try {
|
|
50
|
+
result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof ValidationError) return false;
|
|
53
|
+
// The shell is harmless: the API resolver is authoritative and answers
|
|
54
|
+
// tenant_required for unknown hosts, while a 404 would hit visitors behind
|
|
55
|
+
// a shared NAT under load.
|
|
56
|
+
if (error instanceof RateLimitError) return true;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
function serve(
|
|
47
63
|
surface: HostRoutingSurface,
|
|
48
64
|
file: string = surface.file,
|
|
@@ -59,20 +75,6 @@ function notFound(surface: HostRoutingSurface | undefined): HostRoutingDispatchR
|
|
|
59
75
|
: { kind: "not-found", file: surface.file, csp: surface.csp };
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
// Redirect targets are always same-origin paths. Collapsing leading slashes
|
|
63
|
-
// and backslashes keeps "/de//evil.example" from turning into the
|
|
64
|
-
// protocol-relative "//evil.example".
|
|
65
|
-
function sameOriginPath(path: string): string {
|
|
66
|
-
return `/${path.replace(/^[/\\]+/, "")}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function splitFirstSegment(path: string): { readonly segment: string; readonly rest: string } {
|
|
70
|
-
const withoutLeading = path.replace(/^\/+/, "");
|
|
71
|
-
const slash = withoutLeading.indexOf("/");
|
|
72
|
-
if (slash === -1) return { segment: withoutLeading, rest: "/" };
|
|
73
|
-
return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
78
|
function apexPage(options: HostRoutingOptions, req: HostRoutingRequest): HostRoutingDispatchResult {
|
|
77
79
|
const { apex, locales } = options;
|
|
78
80
|
if (locales === undefined) return serve(apex);
|
|
@@ -88,27 +90,25 @@ async function tenantPage(
|
|
|
88
90
|
options: HostRoutingOptions,
|
|
89
91
|
key: string | null,
|
|
90
92
|
systemQuery: PageHeadSystemQuery,
|
|
93
|
+
keySource: "path" | "subdomain",
|
|
91
94
|
): Promise<HostRoutingDispatchResult> {
|
|
92
95
|
// No key (bare tenant prefix): the shell handles login and tenant choice.
|
|
93
96
|
if (key === null) return serve(options.tenant);
|
|
94
|
-
const isCandidate =
|
|
97
|
+
const isCandidate = keySource === "path" ? isPathTenantKeyCandidate : isTenantKeyCandidate;
|
|
95
98
|
if (!isCandidate(key)) return notFound(options.unknownTenant);
|
|
96
99
|
return (await isTenantKeyKnown(systemQuery, key))
|
|
97
100
|
? serve(options.tenant)
|
|
98
101
|
: notFound(options.unknownTenant);
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
function tenantKeyFromPath(prefix: string, path: string): string | null | undefined {
|
|
102
|
-
if (path !== prefix && !path.startsWith(`${prefix}/`)) return undefined;
|
|
103
|
-
const { segment } = splitFirstSegment(path.slice(prefix.length));
|
|
104
|
-
return segment === "" ? null : segment;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
104
|
// The routing decision for runProdApp's `hostDispatch` (and, via
|
|
108
105
|
// toDevHostDispatch, the dev server). Unknown hosts fail closed with 404.
|
|
109
106
|
export function createHostDispatch(options: HostRoutingOptions): HostRoutingDispatchFn {
|
|
110
107
|
validateHostRoutingOptions(options);
|
|
108
|
+
const redirects = options.redirects ?? [];
|
|
111
109
|
return async (req, { systemQuery }) => {
|
|
110
|
+
const redirect = matchRedirect(redirects, req.host, req.path, req.search);
|
|
111
|
+
if (redirect !== undefined) return { kind: "redirect", ...redirect };
|
|
112
112
|
const host = classifyHost(req.host, options);
|
|
113
113
|
switch (host.kind) {
|
|
114
114
|
case "fixed":
|
|
@@ -117,11 +117,19 @@ export function createHostDispatch(options: HostRoutingOptions): HostRoutingDisp
|
|
|
117
117
|
return notFound(undefined);
|
|
118
118
|
case "subdomain":
|
|
119
119
|
if (options.tenantBy !== "host") return notFound(undefined);
|
|
120
|
-
return tenantPage(options, host.key, systemQuery);
|
|
120
|
+
return tenantPage(options, host.key, systemQuery, "subdomain");
|
|
121
|
+
case "resolved": {
|
|
122
|
+
if (await isHostKnown(systemQuery, host.hostname)) return serve(options.tenant);
|
|
123
|
+
return notFound(
|
|
124
|
+
host.hostname.endsWith(`.${options.baseDomain.toLowerCase()}`)
|
|
125
|
+
? options.unknownTenant
|
|
126
|
+
: undefined,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
121
129
|
case "apex": {
|
|
122
|
-
if (options.
|
|
130
|
+
if (options.tenantPathPrefix !== undefined) {
|
|
123
131
|
const key = tenantKeyFromPath(options.tenantPathPrefix, req.path);
|
|
124
|
-
if (key !== undefined) return tenantPage(options, key, systemQuery);
|
|
132
|
+
if (key !== undefined) return tenantPage(options, key, systemQuery, "path");
|
|
125
133
|
}
|
|
126
134
|
return apexPage(options, req);
|
|
127
135
|
}
|