@cosmicdrift/kumiko-bundled-features 0.304.0 → 0.306.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 +9 -9
- package/src/__tests__/extension-points-typed.test.ts +135 -0
- package/src/__tests__/use-extension-options.test.ts +278 -0
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/i18n.ts +7 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
- package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
- package/src/auth-email-password/web/auth-client.ts +34 -2
- package/src/auth-email-password/web/auth-gate.tsx +5 -1
- package/src/auth-email-password/web/index.ts +4 -1
- package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
- package/src/auth-email-password/web/session.tsx +32 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
- package/src/auth-foundation/types.ts +14 -4
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
- package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +8 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
- package/src/billing-foundation/types.ts +13 -1
- package/src/channel-email/feature.ts +2 -1
- package/src/channel-in-app/feature.ts +2 -1
- package/src/channel-push/feature.ts +2 -1
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
- package/src/config/__tests__/require-system-db.test.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +4 -8
- package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
- package/src/delivery/constants.ts +4 -0
- package/src/delivery/delivery-service.ts +45 -29
- package/src/delivery/feature.ts +2 -1
- package/src/delivery/index.ts +14 -11
- package/src/delivery/types.ts +24 -0
- package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
- package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
- package/src/document-ingest-foundation/changes.json +5 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
- package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/feature.ts +4 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
- package/src/inbound-mail-foundation/types.ts +10 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
- package/src/mail-foundation/feature.ts +15 -4
- package/src/mail-foundation/index.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +5 -4
- package/src/mail-transport-smtp/feature.ts +5 -4
- package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
- package/src/renderer-foundation/changes.json +8 -1
- package/src/renderer-foundation/constants.ts +4 -0
- package/src/renderer-foundation/feature.ts +10 -13
- package/src/renderer-foundation/index.ts +3 -0
- package/src/renderer-foundation/types.ts +23 -1
- package/src/renderer-simple/feature.ts +2 -1
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
- package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
- package/src/tenant-lifecycle/stages.ts +20 -18
- package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
- package/src/user-data-rights/lib/default-mailers.ts +2 -1
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
- package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.306.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.306.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.306.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.306.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.306.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.306.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.306.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.306.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.306.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Ratchet: every extension point a bundled feature declares via
|
|
2
|
+
// `r.extendsRegistrar` and whose options are actually read somewhere must be
|
|
3
|
+
// entered into `KumikoExtensionOptionsMap` — see extension-options-map.ts.
|
|
4
|
+
// New points either join TYPED_EXTENSION_POINTS (with the map augmentation)
|
|
5
|
+
// or UNTYPED_BY_DESIGN (with a reason). Anything else fails this test.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import type { KumikoExtensionOptionsMap } from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
10
|
+
import { authFoundationFeature } from "../auth-foundation/feature";
|
|
11
|
+
import { billingFoundationFeature } from "../billing-foundation/feature";
|
|
12
|
+
import { createCustomFieldsFeature } from "../custom-fields/feature";
|
|
13
|
+
import { createDeliveryFeature } from "../delivery/feature";
|
|
14
|
+
import { createFileDerivativesFeature } from "../file-derivatives/feature";
|
|
15
|
+
import { fileFoundationFeature } from "../file-foundation/feature";
|
|
16
|
+
import { inboundMailFoundationFeature } from "../inbound-mail-foundation/feature";
|
|
17
|
+
import { mailFoundationFeature } from "../mail-foundation/feature";
|
|
18
|
+
import { createRendererFoundationFeature } from "../renderer-foundation/feature";
|
|
19
|
+
import { createTenantLifecycleFeature } from "../tenant-lifecycle/feature";
|
|
20
|
+
import { createTierEngineFeature } from "../tier-engine/feature";
|
|
21
|
+
import { createUserFeature } from "../user/feature";
|
|
22
|
+
import { createUserDataRightsFeature } from "../user-data-rights/feature";
|
|
23
|
+
|
|
24
|
+
const TYPED_EXTENSION_POINTS = [
|
|
25
|
+
"userData",
|
|
26
|
+
"tenantData",
|
|
27
|
+
"storageProvider",
|
|
28
|
+
"searchAdapter",
|
|
29
|
+
"externalResource",
|
|
30
|
+
"infraResource",
|
|
31
|
+
"fileProvider",
|
|
32
|
+
"derivativeRenderer",
|
|
33
|
+
"derivativeOverlayResolver",
|
|
34
|
+
"derivativePublicPredicate",
|
|
35
|
+
"principalStatus",
|
|
36
|
+
"tenantLifecycleStatus",
|
|
37
|
+
"tenantTierResolver",
|
|
38
|
+
"tokenVerifier",
|
|
39
|
+
"sessionStore",
|
|
40
|
+
"tenantResolver",
|
|
41
|
+
"tenantExistence",
|
|
42
|
+
"inboundMailProvider",
|
|
43
|
+
"renderer",
|
|
44
|
+
"deliveryChannel",
|
|
45
|
+
"subscriptionProvider",
|
|
46
|
+
"mailTransport",
|
|
47
|
+
] as const satisfies readonly (keyof KumikoExtensionOptionsMap)[];
|
|
48
|
+
|
|
49
|
+
const UNTYPED_BY_DESIGN: Record<string, string> = {
|
|
50
|
+
customFields: "opt-in marker only — registered options are never read",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Owner-directories that declare at least one extension point via
|
|
54
|
+
// `r.extendsRegistrar` — one entry per feature composed in
|
|
55
|
+
// allRegistrarExtensionNames() below. Catches a new feature that starts
|
|
56
|
+
// calling r.extendsRegistrar without being added to that composition list.
|
|
57
|
+
const DECLARING_FEATURE_DIRS = [
|
|
58
|
+
"auth-foundation",
|
|
59
|
+
"billing-foundation",
|
|
60
|
+
"custom-fields",
|
|
61
|
+
"delivery",
|
|
62
|
+
"file-derivatives",
|
|
63
|
+
"file-foundation",
|
|
64
|
+
"inbound-mail-foundation",
|
|
65
|
+
"mail-foundation",
|
|
66
|
+
"renderer-foundation",
|
|
67
|
+
"tenant-lifecycle",
|
|
68
|
+
"tier-engine",
|
|
69
|
+
"user",
|
|
70
|
+
"user-data-rights",
|
|
71
|
+
].sort();
|
|
72
|
+
|
|
73
|
+
// Scans bundled-features source (excluding tests) for real `r.extendsRegistrar(`
|
|
74
|
+
// call sites, ignoring occurrences inside line comments (e.g. a doc-comment
|
|
75
|
+
// mentioning the call). Returns the sorted, deduped set of owning top-level
|
|
76
|
+
// directories under packages/bundled-features/src.
|
|
77
|
+
function scanExtendsRegistrarOwnerDirs(): readonly string[] {
|
|
78
|
+
const srcDir = new URL("..", import.meta.url).pathname;
|
|
79
|
+
const glob = new Bun.Glob("**/*.ts");
|
|
80
|
+
const owners = new Set<string>();
|
|
81
|
+
for (const relPath of glob.scanSync({ cwd: srcDir, onlyFiles: true })) {
|
|
82
|
+
if (relPath.includes("__tests__/") || relPath.endsWith(".test.ts")) continue;
|
|
83
|
+
const contents = readFileSync(`${srcDir}${relPath}`, "utf8");
|
|
84
|
+
const hasRealCall = contents
|
|
85
|
+
.split("\n")
|
|
86
|
+
.some((line) => line.split("//")[0]?.includes("r.extendsRegistrar("));
|
|
87
|
+
if (!hasRealCall) continue;
|
|
88
|
+
const topLevelDir = relPath.split("/")[0];
|
|
89
|
+
if (topLevelDir) owners.add(topLevelDir);
|
|
90
|
+
}
|
|
91
|
+
return [...owners].sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function allRegistrarExtensionNames(): readonly string[] {
|
|
95
|
+
const features = [
|
|
96
|
+
authFoundationFeature,
|
|
97
|
+
billingFoundationFeature,
|
|
98
|
+
createCustomFieldsFeature(),
|
|
99
|
+
createDeliveryFeature(),
|
|
100
|
+
createFileDerivativesFeature(),
|
|
101
|
+
fileFoundationFeature,
|
|
102
|
+
inboundMailFoundationFeature,
|
|
103
|
+
mailFoundationFeature,
|
|
104
|
+
createRendererFoundationFeature(),
|
|
105
|
+
createTenantLifecycleFeature(),
|
|
106
|
+
createTierEngineFeature(),
|
|
107
|
+
createUserFeature(),
|
|
108
|
+
createUserDataRightsFeature(),
|
|
109
|
+
];
|
|
110
|
+
const names = new Set<string>();
|
|
111
|
+
for (const feature of features) {
|
|
112
|
+
for (const name of Object.keys(feature.registrarExtensions)) {
|
|
113
|
+
names.add(name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...names];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("extension-points-typed ratchet", () => {
|
|
120
|
+
test("every declared extension point is either typed or explicitly untyped-by-design", () => {
|
|
121
|
+
const declared = allRegistrarExtensionNames();
|
|
122
|
+
const known = new Set<string>([...TYPED_EXTENSION_POINTS, ...Object.keys(UNTYPED_BY_DESIGN)]);
|
|
123
|
+
const unaccounted = declared.filter((name) => !known.has(name));
|
|
124
|
+
expect(unaccounted).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("TYPED_EXTENSION_POINTS and UNTYPED_BY_DESIGN don't overlap", () => {
|
|
128
|
+
const overlap = TYPED_EXTENSION_POINTS.filter((name) => name in UNTYPED_BY_DESIGN);
|
|
129
|
+
expect(overlap).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("every directory that calls r.extendsRegistrar is composed into allRegistrarExtensionNames", () => {
|
|
133
|
+
expect(scanExtendsRegistrarOwnerDirs()).toEqual(DECLARING_FEATURE_DIRS);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Imports only via package specifiers so the KumikoExtensionOptionsMap augmentation is proven to reach
|
|
2
|
+
// consumers. Each @ts-expect-error has a positive control next to it, so an unrelated error cannot satisfy it.
|
|
3
|
+
import { expect, expectTypeOf, test } from "bun:test";
|
|
4
|
+
import { MAIL_TRANSPORT_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
5
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import {
|
|
7
|
+
defineFeature,
|
|
8
|
+
EXT_STORAGE_PROVIDER,
|
|
9
|
+
EXT_TENANT_DATA,
|
|
10
|
+
EXT_USER_DATA,
|
|
11
|
+
type ExtensionOptionsArgs,
|
|
12
|
+
type ExtensionOptionsFor,
|
|
13
|
+
type TenantDataDestroyHook,
|
|
14
|
+
type TenantDataHookCtx,
|
|
15
|
+
type TenantId,
|
|
16
|
+
type TenantResourceDestroyHook,
|
|
17
|
+
type TenantResourceHookCtx,
|
|
18
|
+
type UserDataDeleteHook,
|
|
19
|
+
type UserDataDeleteStrategy,
|
|
20
|
+
type UserDataExportHook,
|
|
21
|
+
type UserDataHookCtx,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
|
|
24
|
+
test("useExtension accepts a well-typed tenantData destroy hook", () => {
|
|
25
|
+
defineFeature("probe-tenant-data-ok", (r) => {
|
|
26
|
+
r.useExtension(EXT_TENANT_DATA, "x", {
|
|
27
|
+
destroy: async (ctx: TenantDataHookCtx) => {
|
|
28
|
+
expectTypeOf(ctx.db).not.toBeAny();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("useExtension rejects a tenantData destroy hook with the #3196 wrong ctx shape", () => {
|
|
35
|
+
defineFeature("probe-tenant-data-wrong-ctx", (r) => {
|
|
36
|
+
r.useExtension(EXT_TENANT_DATA, "x", {
|
|
37
|
+
// @ts-expect-error #3196: destroy ctx must be TenantDataHookCtx, not a hand-rolled { db: DbRunner; tenantId } — TenantDb is not a DbRunner.
|
|
38
|
+
destroy: async (_ctx: { readonly db: DbRunner; readonly tenantId: TenantId }) => {},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
expectTypeOf<
|
|
42
|
+
(ctx: { readonly db: DbRunner; readonly tenantId: TenantId }) => Promise<void>
|
|
43
|
+
>().not.toExtend<TenantDataDestroyHook>();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("useExtension requires options for a known extension name — omitting them is a type error", () => {
|
|
47
|
+
defineFeature("probe-tenant-data-missing-options", (r) => {
|
|
48
|
+
// @ts-expect-error tenantData's options are mandatory — a bare (name, entity) call registered no hook, silently.
|
|
49
|
+
r.useExtension(EXT_TENANT_DATA, "x");
|
|
50
|
+
});
|
|
51
|
+
expectTypeOf<[]>().not.toExtend<ExtensionOptionsArgs<typeof EXT_TENANT_DATA>>();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("useExtension infers the destroy hook's ctx type from context (positional form, no annotation)", () => {
|
|
55
|
+
defineFeature("probe-tenant-data-contextual", (r) => {
|
|
56
|
+
r.useExtension(EXT_TENANT_DATA, "x", {
|
|
57
|
+
destroy: async (ctx) => {
|
|
58
|
+
expectTypeOf(ctx).toEqualTypeOf<TenantDataHookCtx>();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("useExtension's object form accepts a well-typed tenantData destroy hook", () => {
|
|
65
|
+
defineFeature("probe-tenant-data-object-form-ok", (r) => {
|
|
66
|
+
r.useExtension({
|
|
67
|
+
name: EXT_TENANT_DATA,
|
|
68
|
+
entity: "x",
|
|
69
|
+
destroy: async (ctx: TenantDataHookCtx) => {
|
|
70
|
+
expectTypeOf(ctx.tenantId).toBeString();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("useExtension's object form rejects the #3196 wrong ctx shape", () => {
|
|
77
|
+
defineFeature("probe-tenant-data-object-form-wrong-ctx", (r) => {
|
|
78
|
+
r.useExtension({
|
|
79
|
+
name: EXT_TENANT_DATA,
|
|
80
|
+
entity: "x",
|
|
81
|
+
// @ts-expect-error #3196: same mismatch as the positional form above.
|
|
82
|
+
destroy: async (_ctx: { readonly db: DbRunner; readonly tenantId: TenantId }) => {},
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
expectTypeOf<
|
|
86
|
+
(ctx: { readonly db: DbRunner; readonly tenantId: TenantId }) => Promise<void>
|
|
87
|
+
>().not.toExtend<TenantDataDestroyHook>();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("useExtension's object form infers the destroy hook's ctx type from context (no annotation)", () => {
|
|
91
|
+
defineFeature("probe-tenant-data-object-form-contextual", (r) => {
|
|
92
|
+
r.useExtension({
|
|
93
|
+
name: EXT_TENANT_DATA,
|
|
94
|
+
entity: "x",
|
|
95
|
+
destroy: async (ctx) => {
|
|
96
|
+
expectTypeOf(ctx).toEqualTypeOf<TenantDataHookCtx>();
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("useExtension accepts a tenantData registration with escapeHatch", () => {
|
|
103
|
+
const feature = defineFeature("probe-tenant-data-escape-hatch", (r) => {
|
|
104
|
+
r.useExtension(EXT_TENANT_DATA, "x", {
|
|
105
|
+
destroy: async (_ctx: TenantDataHookCtx) => {},
|
|
106
|
+
escapeHatch: { reason: "needs raw SQL for a batch delete" },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
expect(feature.extensionUsages).toContainEqual(
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
extensionName: EXT_TENANT_DATA,
|
|
112
|
+
entityName: "x",
|
|
113
|
+
options: expect.objectContaining({
|
|
114
|
+
escapeHatch: { reason: "needs raw SQL for a batch delete" },
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("useExtension accepts export-only userData registration (#972)", () => {
|
|
121
|
+
const exportHook: UserDataExportHook = async (_ctx) => null;
|
|
122
|
+
const feature = defineFeature("probe-user-data-export-only", (r) => {
|
|
123
|
+
r.useExtension(EXT_USER_DATA, "x", { export: exportHook });
|
|
124
|
+
});
|
|
125
|
+
expect(feature.extensionUsages).toContainEqual(
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
extensionName: EXT_USER_DATA,
|
|
128
|
+
entityName: "x",
|
|
129
|
+
options: expect.objectContaining({ export: exportHook }),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("useExtension rejects a userData export hook with the wrong ctx shape", () => {
|
|
135
|
+
defineFeature("probe-user-data-wrong-ctx", (r) => {
|
|
136
|
+
r.useExtension(EXT_USER_DATA, "x", {
|
|
137
|
+
// @ts-expect-error export ctx must be UserDataHookCtx (ctx.db: TenantDb), not a hand-rolled { db: DbRunner; userId }.
|
|
138
|
+
export: async (_ctx: { readonly db: DbRunner; readonly userId: string }) => null,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
expectTypeOf<
|
|
142
|
+
(ctx: { readonly db: DbRunner; readonly userId: string }) => Promise<null>
|
|
143
|
+
>().not.toExtend<UserDataExportHook>();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("useExtension accepts delete-only userData registration with order", () => {
|
|
147
|
+
const deleteHook: UserDataDeleteHook = async (_ctx, _strategy) => undefined;
|
|
148
|
+
const feature = defineFeature("probe-user-data-delete-only", (r) => {
|
|
149
|
+
r.useExtension(EXT_USER_DATA, "x", { delete: deleteHook, order: 1 });
|
|
150
|
+
});
|
|
151
|
+
expect(feature.extensionUsages).toContainEqual(
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
extensionName: EXT_USER_DATA,
|
|
154
|
+
entityName: "x",
|
|
155
|
+
options: expect.objectContaining({ delete: deleteHook, order: 1 }),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("useExtension infers the delete hook's ctx/strategy types from context (no annotation)", () => {
|
|
161
|
+
defineFeature("probe-user-data-delete-contextual", (r) => {
|
|
162
|
+
r.useExtension(EXT_USER_DATA, "x", {
|
|
163
|
+
delete: async (ctx, strategy) => {
|
|
164
|
+
expectTypeOf(ctx).toEqualTypeOf<UserDataHookCtx>();
|
|
165
|
+
expectTypeOf(strategy).toEqualTypeOf<UserDataDeleteStrategy>();
|
|
166
|
+
return undefined;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("useExtension rejects an empty userData registration", () => {
|
|
173
|
+
defineFeature("probe-user-data-empty", (r) => {
|
|
174
|
+
// @ts-expect-error userData needs at least one of export/delete — an empty bag silently registered neither hook.
|
|
175
|
+
r.useExtension(EXT_USER_DATA, "x", {});
|
|
176
|
+
});
|
|
177
|
+
expectTypeOf<Record<string, never>>().not.toExtend<ExtensionOptionsFor<typeof EXT_USER_DATA>>();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("useExtension rejects a userData registration with only order", () => {
|
|
181
|
+
defineFeature("probe-user-data-only-order", (r) => {
|
|
182
|
+
// @ts-expect-error same as the empty-bag case above: order alone registers no hook.
|
|
183
|
+
r.useExtension(EXT_USER_DATA, "x", { order: 1 });
|
|
184
|
+
});
|
|
185
|
+
expectTypeOf<{ readonly order: number }>().not.toExtend<
|
|
186
|
+
ExtensionOptionsFor<typeof EXT_USER_DATA>
|
|
187
|
+
>();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("useExtension accepts an unknown extension name with an arbitrary options bag", () => {
|
|
191
|
+
const feature = defineFeature("probe-unknown-name", (r) => {
|
|
192
|
+
r.useExtension("someAppPoint", "x", { anything: 42, goes: "here" });
|
|
193
|
+
});
|
|
194
|
+
expect(feature.extensionUsages).toContainEqual(
|
|
195
|
+
expect.objectContaining({
|
|
196
|
+
extensionName: "someAppPoint",
|
|
197
|
+
entityName: "x",
|
|
198
|
+
options: { anything: 42, goes: "here" },
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("useExtension accepts an unknown extension name with no options at all", () => {
|
|
204
|
+
const feature = defineFeature("probe-unknown-name-no-options", (r) => {
|
|
205
|
+
r.useExtension("someOtherAppPoint", "x");
|
|
206
|
+
});
|
|
207
|
+
expect(feature.extensionUsages).toContainEqual(
|
|
208
|
+
expect.objectContaining({ extensionName: "someOtherAppPoint", entityName: "x" }),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("useExtension accepts a string-typed extension name with an arbitrary options bag", () => {
|
|
213
|
+
const name: string = "someAppPoint";
|
|
214
|
+
const feature = defineFeature("probe-string-typed-name", (r) => {
|
|
215
|
+
r.useExtension(name, "x", { anything: 42 });
|
|
216
|
+
});
|
|
217
|
+
expect(feature.extensionUsages).toContainEqual(
|
|
218
|
+
expect.objectContaining({
|
|
219
|
+
extensionName: "someAppPoint",
|
|
220
|
+
entityName: "x",
|
|
221
|
+
options: { anything: 42 },
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("useExtension accepts a well-typed storageProvider destroyTenant hook", () => {
|
|
227
|
+
defineFeature("probe-storage-provider-ok", (r) => {
|
|
228
|
+
r.useExtension(EXT_STORAGE_PROVIDER, "x", {
|
|
229
|
+
destroyTenant: async (tenantId: TenantId, ctx: TenantResourceHookCtx) => {
|
|
230
|
+
expectTypeOf(tenantId).toBeString();
|
|
231
|
+
expectTypeOf(ctx.db).not.toBeAny();
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("useExtension infers the storageProvider destroyTenant hook's arg types from context", () => {
|
|
238
|
+
defineFeature("probe-storage-provider-contextual", (r) => {
|
|
239
|
+
r.useExtension(EXT_STORAGE_PROVIDER, "x", {
|
|
240
|
+
destroyTenant: async (tenantId, ctx) => {
|
|
241
|
+
expectTypeOf(tenantId).toEqualTypeOf<TenantId>();
|
|
242
|
+
expectTypeOf(ctx).toEqualTypeOf<TenantResourceHookCtx>();
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("useExtension rejects a storageProvider destroyTenant hook shaped like the tenantData (ctx)-only hook", () => {
|
|
249
|
+
defineFeature("probe-storage-provider-wrong-shape", (r) => {
|
|
250
|
+
r.useExtension(EXT_STORAGE_PROVIDER, "x", {
|
|
251
|
+
// @ts-expect-error storageProvider's destroyTenant takes (tenantId, ctx) — a tenantData-style (ctx)-only hook is missing the tenantId arg.
|
|
252
|
+
destroyTenant: async (_ctx: TenantDataHookCtx) => {},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
expectTypeOf<
|
|
256
|
+
(ctx: TenantDataHookCtx) => Promise<void>
|
|
257
|
+
>().not.toExtend<TenantResourceDestroyHook>();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("useExtension rejects a storageProvider registration without destroyTenant", () => {
|
|
261
|
+
defineFeature("probe-storage-provider-missing-hook", (r) => {
|
|
262
|
+
// @ts-expect-error storageProvider requires destroyTenant — an empty bag silently registered no cleanup hook.
|
|
263
|
+
r.useExtension(EXT_STORAGE_PROVIDER, "x", {});
|
|
264
|
+
});
|
|
265
|
+
expectTypeOf<Record<string, never>>().not.toExtend<
|
|
266
|
+
ExtensionOptionsFor<typeof EXT_STORAGE_PROVIDER>
|
|
267
|
+
>();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("useExtension rejects a mailTransport registration without build", () => {
|
|
271
|
+
defineFeature("probe-mail-transport-missing-build", (r) => {
|
|
272
|
+
// @ts-expect-error mailTransport requires build — an empty bag silently registered no transport factory.
|
|
273
|
+
r.useExtension(MAIL_TRANSPORT_EXTENSION, "x", {});
|
|
274
|
+
});
|
|
275
|
+
expectTypeOf<Record<string, never>>().not.toExtend<
|
|
276
|
+
ExtensionOptionsFor<typeof MAIL_TRANSPORT_EXTENSION>
|
|
277
|
+
>();
|
|
278
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// GET /api/auth/tenants is exempt from the L2 auth-endpoint rate limit;
|
|
2
|
+
// credential endpoints on the same /api/auth/* mount (e.g. login) keep the default limit.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
setupTestStack,
|
|
9
|
+
type TestStack,
|
|
10
|
+
TestUsers,
|
|
11
|
+
unsafeCreateEntityTable,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
|
+
import { createConfigFeature } from "../../config";
|
|
16
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
17
|
+
import { configValuesTable } from "../../config/table";
|
|
18
|
+
import { createTenantFeature } from "../../tenant";
|
|
19
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
20
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
21
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
22
|
+
import { UserHandlers } from "../../user";
|
|
23
|
+
import { createUserFeature } from "../../user/feature";
|
|
24
|
+
import { userEntity } from "../../user/schema/user";
|
|
25
|
+
import { AuthHandlers } from "../constants";
|
|
26
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
27
|
+
|
|
28
|
+
let stack: TestStack;
|
|
29
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
30
|
+
const membershipTenantId = "00000000-0000-4000-8000-000000000001";
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
34
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
35
|
+
|
|
36
|
+
stack = await setupTestStack({
|
|
37
|
+
features: [
|
|
38
|
+
createConfigFeature(),
|
|
39
|
+
createUserFeature(),
|
|
40
|
+
createTenantFeature(),
|
|
41
|
+
createAuthEmailPasswordFeature(),
|
|
42
|
+
],
|
|
43
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
44
|
+
authConfig: {
|
|
45
|
+
membershipQuery: "tenant:query:memberships",
|
|
46
|
+
loginHandler: AuthHandlers.login,
|
|
47
|
+
},
|
|
48
|
+
// No `limit` override — this proves the real framework default (5/60s)
|
|
49
|
+
// exempts GET /api/auth/tenants and still throttles POST /api/auth/login.
|
|
50
|
+
rateLimit: {
|
|
51
|
+
auth: { onFailClosed: () => {} },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
56
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
57
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await stack.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function getTenants(token: string, ip: string): Promise<Response> {
|
|
65
|
+
return stack.app.request("/api/auth/tenants", {
|
|
66
|
+
method: "GET",
|
|
67
|
+
headers: { Authorization: `Bearer ${token}`, "x-forwarded-for": ip },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function postLogin(ip: string, email: string): Promise<Response> {
|
|
72
|
+
return stack.app.request("/api/auth/login", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/json", "x-forwarded-for": ip },
|
|
75
|
+
body: JSON.stringify({ email, password: "wrong-password" }),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("GET /api/auth/tenants is exempt from the L2 auth rate limit", () => {
|
|
80
|
+
test("10 GETs from the same IP all succeed — the 6th would 429 without the exemption", async () => {
|
|
81
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
82
|
+
UserHandlers.create,
|
|
83
|
+
{ email: "tenants-exempt@example.com", passwordHash: "unused", displayName: "exempt-user" },
|
|
84
|
+
TestUsers.systemAdmin,
|
|
85
|
+
);
|
|
86
|
+
await seedTenantMembership(stack.db, {
|
|
87
|
+
userId: created.id,
|
|
88
|
+
tenantId: membershipTenantId,
|
|
89
|
+
roles: ["User"],
|
|
90
|
+
});
|
|
91
|
+
const user: SessionUser = { id: created.id, tenantId: membershipTenantId, roles: ["User"] };
|
|
92
|
+
const token = await stack.jwt.sign(user);
|
|
93
|
+
const ip = "10.70.0.1";
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < 10; i++) {
|
|
96
|
+
const res = await getTenants(token, ip);
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
}
|
|
99
|
+
const body = await (await getTenants(token, ip)).json();
|
|
100
|
+
expect(body.tenants.length).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("POST /api/auth/login keeps the default L2 limit (5/60s)", () => {
|
|
105
|
+
test("6th login attempt from the same IP is 429 via the L2 bucket, not just the route's own limiter", async () => {
|
|
106
|
+
const ip = "10.70.0.2";
|
|
107
|
+
for (let i = 0; i < 5; i++) {
|
|
108
|
+
await postLogin(ip, `nope-${i}@example.com`);
|
|
109
|
+
}
|
|
110
|
+
const blocked = await postLogin(ip, "nope-5@example.com");
|
|
111
|
+
expect(blocked.status).toBe(429);
|
|
112
|
+
const body = (await blocked.json()) as { error: { code: string; details: { bucket: string } } };
|
|
113
|
+
expect(body.error.code).toBe("rate_limited");
|
|
114
|
+
expect(body.error.details.bucket).toBe(`l2:${ip}:/api/auth/login`);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.305.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Session bootstrap failures now render a retryable error screen instead of hanging on \"loading\"",
|
|
6
|
+
"detail": "fetchTenants()/fetchCurrentUser() failures (rate limit, 5xx, network)\nused to reject out of the session bootstrap effect and leave the UI on\nthe loading placeholder forever. SessionStatus gained an \"error\" value\nand SessionState gained a bootstrapFailure field; the auth gate now\nrenders SessionBootstrapErrorScreen with a Retry-After-aware retry\nbutton for this case.",
|
|
7
|
+
"migration": "Code with an exhaustive switch over SessionStatus, or tests/stories that\nhand-build a SessionState literal, needs to add the \"error\" case and the\nbootstrapFailure field. refresh() no longer rejects on bootstrap\nfailures; it resolves and sets status to \"error\" instead."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.300.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -131,6 +131,13 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
131
131
|
"auth.inviteAccept.toggleExisting": "I already have an account",
|
|
132
132
|
"auth.inviteAccept.missingToken": "The invitation link is missing or invalid.",
|
|
133
133
|
"auth.inviteAccept.goToLogin": "Go to sign in",
|
|
134
|
+
"auth.sessionBootstrap.errorTitle": "Couldn't load your session",
|
|
135
|
+
"auth.sessionBootstrap.errorBody":
|
|
136
|
+
"The server could not be reached or returned an error. Please try again.",
|
|
137
|
+
"auth.sessionBootstrap.rateLimitedBody":
|
|
138
|
+
"Too many requests in a short time. Please wait a moment and try again.",
|
|
139
|
+
"auth.sessionBootstrap.retry": "Try again",
|
|
140
|
+
"auth.sessionBootstrap.retrying": "Retrying…",
|
|
134
141
|
"auth.user.menu.label": "Account",
|
|
135
142
|
"auth.user.menu.logout": "Sign out",
|
|
136
143
|
"auth.tenant.switcher.label": "Tenant",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { CSRF_HEADER_NAME } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
3
3
|
import {
|
|
4
|
+
AuthRequestError,
|
|
4
5
|
confirmAccountUnlock,
|
|
5
6
|
confirmSignup,
|
|
6
7
|
csrfHeader,
|
|
@@ -390,6 +391,29 @@ describe("fetchTenants", () => {
|
|
|
390
391
|
|
|
391
392
|
await expect(fetchTenants()).rejects.toThrow("auth/tenants failed: 500");
|
|
392
393
|
});
|
|
394
|
+
|
|
395
|
+
test("429 with Retry-After → AuthRequestError carries status + retryAfterSeconds", async () => {
|
|
396
|
+
globalThis.fetch = mock(
|
|
397
|
+
async () => new Response(null, { status: 429, headers: { "Retry-After": "7" } }),
|
|
398
|
+
) as unknown as typeof fetch;
|
|
399
|
+
|
|
400
|
+
const err = await fetchTenants().catch((e: unknown) => e);
|
|
401
|
+
|
|
402
|
+
expect(err).toBeInstanceOf(AuthRequestError);
|
|
403
|
+
expect((err as AuthRequestError).status).toBe(429);
|
|
404
|
+
expect((err as AuthRequestError).retryAfterSeconds).toBe(7);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("non-ok without Retry-After header → retryAfterSeconds is undefined", async () => {
|
|
408
|
+
globalThis.fetch = mock(
|
|
409
|
+
async () => new Response(null, { status: 500 }),
|
|
410
|
+
) as unknown as typeof fetch;
|
|
411
|
+
|
|
412
|
+
const err = await fetchTenants().catch((e: unknown) => e);
|
|
413
|
+
|
|
414
|
+
expect(err).toBeInstanceOf(AuthRequestError);
|
|
415
|
+
expect((err as AuthRequestError).retryAfterSeconds).toBeUndefined();
|
|
416
|
+
});
|
|
393
417
|
});
|
|
394
418
|
|
|
395
419
|
describe("switchTenant", () => {
|