@cosmicdrift/kumiko-samples 0.340.0 → 0.342.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 -1
- package/packages/bundled-features/src/audit/constants.ts +5 -0
- package/packages/bundled-features/src/audit/feature.ts +47 -6
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +325 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +30 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
- package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
- package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
- package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +9 -1
- package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
- package/packages/bundled-features/src/channel-email/types.ts +4 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +23 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +42 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +63 -7
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { announceSecretChange } from "../announce-secret-change.js";
|
|
3
4
|
import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
|
|
4
5
|
import { requireSecretsContext } from "../feature.js";
|
|
5
|
-
import { checkSecretKeyWrite } from "../write-gate.js";
|
|
6
|
+
import { checkSecretKeyWrite, checkSecretValue } from "../write-gate.js";
|
|
6
7
|
|
|
7
8
|
export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
8
9
|
return defineWriteHandler({
|
|
@@ -32,6 +33,8 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
32
33
|
// framework default unless the caller sent a specific preview.
|
|
33
34
|
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, key);
|
|
34
35
|
if (!gate.ok) return gate.failure;
|
|
36
|
+
const invalidValue = checkSecretValue(ctx.registry, key, value);
|
|
37
|
+
if (invalidValue) return invalidValue;
|
|
35
38
|
const featureRedact = gate.keyDef?.redact;
|
|
36
39
|
const redactFn: (v: string) => string = redactedPreview
|
|
37
40
|
? () => redactedPreview
|
|
@@ -42,6 +45,7 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
42
45
|
...(hint ? { hint } : {}),
|
|
43
46
|
updatedBy: event.user.id,
|
|
44
47
|
});
|
|
48
|
+
announceSecretChange(ctx, event.user.tenantId, key);
|
|
45
49
|
|
|
46
50
|
return {
|
|
47
51
|
isSuccess: true,
|
|
@@ -7,4 +7,8 @@ export const SECRETS_I18N = {
|
|
|
7
7
|
en: "You are not allowed to change this secret.",
|
|
8
8
|
de: "Dieses Geheimnis darf nicht geändert werden.",
|
|
9
9
|
},
|
|
10
|
+
"secrets.errors.invalidValue": {
|
|
11
|
+
en: "This value is not valid for this secret.",
|
|
12
|
+
de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
|
|
13
|
+
},
|
|
10
14
|
} as const;
|
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
tenantSecretEntity,
|
|
43
43
|
tenantSecretsTable,
|
|
44
44
|
} from "./table.js";
|
|
45
|
+
import {
|
|
46
|
+
invalidSecretValueError,
|
|
47
|
+
isSecretValueValid,
|
|
48
|
+
type SecretValueSchemaSource,
|
|
49
|
+
} from "./write-gate.js";
|
|
45
50
|
|
|
46
51
|
// Re-export the framework interface so consumers of bundled-features/secrets
|
|
47
52
|
// don't need to reach into @cosmicdrift/kumiko-framework/secrets separately.
|
|
@@ -50,6 +55,9 @@ export type { Secret, SecretsContext } from "@cosmicdrift/kumiko-framework/secre
|
|
|
50
55
|
export type SecretsContextOptions = {
|
|
51
56
|
readonly db: DbConnection;
|
|
52
57
|
readonly masterKeyProvider: MasterKeyProvider;
|
|
58
|
+
// Source of the declared `valueSchema`s: programmatic `set` enforces the same
|
|
59
|
+
// check as the write handler. Required so a context can't silently skip it.
|
|
60
|
+
readonly registry: SecretValueSchemaSource;
|
|
53
61
|
// Shared DEK cache. Default: a fresh 5-min TTL cache. Pass in a shared
|
|
54
62
|
// instance if several features decrypt overlapping secret sets — lowers
|
|
55
63
|
// provider-call count across the app.
|
|
@@ -164,6 +172,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
164
172
|
|
|
165
173
|
async set(tenantId, keyOrHandle, value, setOpts = {}) {
|
|
166
174
|
const key = resolveKey(keyOrHandle);
|
|
175
|
+
if (!isSecretValueValid(opts.registry, key, value)) throw invalidSecretValueError();
|
|
167
176
|
const envelope = await encryptValue(value, masterKeyProvider);
|
|
168
177
|
const stored: StoredEnvelope = encodeStoredEnvelope(envelope);
|
|
169
178
|
const metadata: StoredMetadata = {
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
import {
|
|
7
7
|
AccessDeniedError,
|
|
8
8
|
NotFoundError,
|
|
9
|
+
ValidationError,
|
|
9
10
|
type WriteFailure,
|
|
10
11
|
writeFailure,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -60,3 +61,43 @@ export function checkSecretKeyWrite(
|
|
|
60
61
|
}
|
|
61
62
|
return { ok: true, keyDef };
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
// The registry surface the value check needs; lets ctx.secrets validate without a full Registry.
|
|
66
|
+
export type SecretValueSchemaSource = Pick<Registry, "getSecretKey" | "findSecretNamespace">;
|
|
67
|
+
|
|
68
|
+
// Carries neither the value nor the schema's issues.
|
|
69
|
+
export function invalidSecretValueError(): ValidationError {
|
|
70
|
+
return new ValidationError(
|
|
71
|
+
{
|
|
72
|
+
fields: [
|
|
73
|
+
{
|
|
74
|
+
path: "value",
|
|
75
|
+
code: "invalid_secret_value",
|
|
76
|
+
i18nKey: "secrets.errors.invalidValue",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{ i18nKey: "secrets.errors.invalidValue" },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isSecretValueValid(
|
|
85
|
+
schemas: SecretValueSchemaSource,
|
|
86
|
+
key: string,
|
|
87
|
+
value: string,
|
|
88
|
+
): boolean {
|
|
89
|
+
const valueSchema =
|
|
90
|
+
schemas.getSecretKey(key)?.valueSchema ?? schemas.findSecretNamespace(key)?.valueSchema;
|
|
91
|
+
return valueSchema === undefined || valueSchema.safeParse(value).success;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Runs after checkSecretKeyWrite, so a caller without the write role never learns
|
|
95
|
+
// whether a value would have passed.
|
|
96
|
+
export function checkSecretValue(
|
|
97
|
+
registry: SecretValueSchemaSource,
|
|
98
|
+
key: string,
|
|
99
|
+
value: string,
|
|
100
|
+
): WriteFailure | undefined {
|
|
101
|
+
if (isSecretValueValid(registry, key, value)) return undefined;
|
|
102
|
+
return writeFailure(invalidSecretValueError());
|
|
103
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Harden system role, invitations query, tenant teardown and addMember",
|
|
6
|
+
"detail": "The system role can no longer be minted into a session through a user's global roles. invitations.query returns an explicit field allowlist and exposes globalRoles only to a SystemAdmin. The tenant destruction sweep waits 60 seconds after the grace period ends so the teardown gate can settle. Behaviour change: tenant:write:addMember now adds members to a foreign tenant only from the framework system context; a SystemAdmin request user is limited to their own tenant and gets 403 otherwise."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.336.1",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
type SessionUser,
|
|
6
|
+
SYSTEM_ROLE,
|
|
7
|
+
SYSTEM_USER_ID,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
AccessDeniedError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
InternalError,
|
|
13
|
+
writeFailure,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
5
15
|
import * as z from "zod";
|
|
6
16
|
import { TenantErrors } from "../constants.js";
|
|
7
17
|
import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles.js";
|
|
@@ -11,6 +21,12 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
|
|
|
11
21
|
entityName: "tenant-membership",
|
|
12
22
|
});
|
|
13
23
|
|
|
24
|
+
// Both halves: test stacks can mint JWTs with the "system" role, but only the
|
|
25
|
+
// framework operator carries SYSTEM_USER_ID.
|
|
26
|
+
function isFrameworkSystemUser(user: SessionUser): boolean {
|
|
27
|
+
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export const addMemberWrite = defineWriteHandler({
|
|
15
31
|
name: "addMember",
|
|
16
32
|
schema: z.object({
|
|
@@ -18,7 +34,7 @@ export const addMemberWrite = defineWriteHandler({
|
|
|
18
34
|
tenantId: z.string(),
|
|
19
35
|
roles: z.array(z.string()).min(1),
|
|
20
36
|
}),
|
|
21
|
-
access: { roles: ["SystemAdmin"] },
|
|
37
|
+
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
22
38
|
description:
|
|
23
39
|
"Grants an existing user membership in a tenant with the given roles, refusing reserved role names and a user who is already a member; use it to add someone to a workspace without going through an invitation.",
|
|
24
40
|
handler: async (event, ctx) => {
|
|
@@ -31,6 +47,14 @@ export const addMemberWrite = defineWriteHandler({
|
|
|
31
47
|
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
32
48
|
"SystemAdmin manages memberships across tenants",
|
|
33
49
|
);
|
|
50
|
+
if (!isFrameworkSystemUser(event.user) && event.payload.tenantId !== event.user.tenantId) {
|
|
51
|
+
return writeFailure(
|
|
52
|
+
new AccessDeniedError({
|
|
53
|
+
message: "only the system context may add members to another tenant",
|
|
54
|
+
details: { reason: TenantErrors.crossTenantMembershipDenied },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
34
58
|
const forbidden = findForbiddenMembershipRole(event.payload.roles);
|
|
35
59
|
if (forbidden !== undefined) return writeFailure(reservedMembershipRoleError(forbidden));
|
|
36
60
|
const existing = await fetchOne(db, tenantMembershipsTable, {
|
|
@@ -4,6 +4,7 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
|
|
6
6
|
import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.js";
|
|
7
|
+
import { isSystemAdmin } from "./is-system-admin.js";
|
|
7
8
|
|
|
8
9
|
// Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
|
|
9
10
|
// own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
|
|
@@ -52,7 +53,20 @@ export const invitationsQuery = defineQueryHandler({
|
|
|
52
53
|
typeof invitedBy === "string"
|
|
53
54
|
? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
|
|
54
55
|
: invitedBy;
|
|
55
|
-
|
|
56
|
+
// Explicit allowlist: the raw row also carries blind-index columns and
|
|
57
|
+
// globalRoles, which only a SystemAdmin may see.
|
|
58
|
+
return {
|
|
59
|
+
id: row["id"],
|
|
60
|
+
tenantId: row["tenantId"],
|
|
61
|
+
email: decryptedEmail,
|
|
62
|
+
role: row["role"],
|
|
63
|
+
status: row["status"],
|
|
64
|
+
invitedBy: decryptedInvitedBy,
|
|
65
|
+
expiresAt: row["expiresAt"],
|
|
66
|
+
insertedAt: row["insertedAt"],
|
|
67
|
+
version: row["version"],
|
|
68
|
+
...(isSystemAdmin(query.user) && { globalRoles: row["globalRoles"] }),
|
|
69
|
+
};
|
|
56
70
|
});
|
|
57
71
|
},
|
|
58
72
|
});
|
|
@@ -25,10 +25,13 @@
|
|
|
25
25
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
26
26
|
import {
|
|
27
27
|
createEntity,
|
|
28
|
+
createMultiSelectField,
|
|
28
29
|
createSelectField,
|
|
29
30
|
createTextField,
|
|
30
31
|
createTimestampField,
|
|
31
32
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
33
|
+
// kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
|
|
34
|
+
import { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
|
|
32
35
|
|
|
33
36
|
// Status-const-Object damit Handler-Code keine Magic-Strings nutzt.
|
|
34
37
|
// Bei rename (z.B. "cancelled" → "revoked") fällt jeder caller auf
|
|
@@ -67,6 +70,13 @@ export const tenantInvitationEntity = createEntity({
|
|
|
67
70
|
personal: false,
|
|
68
71
|
reason: "technical_reference",
|
|
69
72
|
}),
|
|
73
|
+
// Global (users.roles) roles granted on accept, on top of the membership
|
|
74
|
+
// role. Only the system-only invite variant sets them; every tenant-admin
|
|
75
|
+
// re-invite resets them to [] so a resend can never carry them over.
|
|
76
|
+
globalRoles: createMultiSelectField({
|
|
77
|
+
options: GLOBAL_ROLE_OPTIONS,
|
|
78
|
+
default: [],
|
|
79
|
+
}),
|
|
70
80
|
// Lifecycle-State. Default "pending"; transitions:
|
|
71
81
|
// pending → accepted | cancelled | expired
|
|
72
82
|
status: createSelectField({
|
|
@@ -23,6 +23,8 @@ export type TenantLifecycleGate = {
|
|
|
23
23
|
// (cached as null) must not grow the Map without bound: past the cap, expired
|
|
24
24
|
// entries are swept and, if still full, the oldest-inserted entries are evicted.
|
|
25
25
|
const GATE_TTL_MS = 3000;
|
|
26
|
+
// Covers other pods' GATE_TTL_MS plus requests still in flight when the grace period (0 under a compliance override) ends.
|
|
27
|
+
export const TEARDOWN_GATE_SETTLE_MS = 60_000;
|
|
26
28
|
/** @internal exported for the bounded-cache test */
|
|
27
29
|
export const GATE_CACHE_MAX_ENTRIES = 5000;
|
|
28
30
|
const gateCache = new Map<
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
|
|
27
27
|
TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
|
|
28
28
|
} from "./constants.js";
|
|
29
|
-
import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
|
|
29
|
+
import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
|
|
30
30
|
import {
|
|
31
31
|
type DestructionStageCtx,
|
|
32
32
|
isDestructionPipelineComplete,
|
|
@@ -321,7 +321,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
321
321
|
const now = args.now ?? T.Now.instant();
|
|
322
322
|
const due = await selectMany<{ id: string }>(args.db, tenantTable, {
|
|
323
323
|
status: "destroyRequested",
|
|
324
|
-
gracePeriodEnd: { lte: now },
|
|
324
|
+
gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
|
|
325
325
|
});
|
|
326
326
|
|
|
327
327
|
let triggered = 0;
|
|
@@ -63,6 +63,13 @@ import {
|
|
|
63
63
|
type TrialGate,
|
|
64
64
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
65
65
|
import { getUnscopedAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
66
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
67
|
+
import {
|
|
68
|
+
CACHE_SYNC_TOPICS,
|
|
69
|
+
type CacheSyncBus,
|
|
70
|
+
isTierAssignmentSyncMessage,
|
|
71
|
+
type TierAssignmentSyncMessage,
|
|
72
|
+
} from "@cosmicdrift/kumiko-framework/redis";
|
|
66
73
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
67
74
|
import * as z from "zod";
|
|
68
75
|
import { tenantTable } from "../tenant/index.js";
|
|
@@ -80,6 +87,7 @@ import { isTrialActive, type TrialPolicy } from "./trial.js";
|
|
|
80
87
|
// from the entity definition — same shape buildEntityTable would produce
|
|
81
88
|
// in the App's drizzle/schema.generated.ts.
|
|
82
89
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
90
|
+
const log = createFallbackLogger("tier-engine");
|
|
83
91
|
|
|
84
92
|
// Event-store-executor für direct-write aus dem auto-default-tier-hook.
|
|
85
93
|
// Pattern wie tenant/seeding.ts: hook sieht AppContext (kein ctx.write),
|
|
@@ -227,10 +235,9 @@ export function createTierEngineFeature<
|
|
|
227
235
|
// Cross-tenant set + read f\u00fcr den tier-admin-Screen. tier-options liefert
|
|
228
236
|
// dem Client die App-Tier-Namen aus der tierMap-Closure (sonst hartkodiert).
|
|
229
237
|
//
|
|
230
|
-
// onAssigned
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// die echte Cache-Update-Funktion eingeh\u00e4ngt \u2014 analog alwaysOnHolder.
|
|
238
|
+
// onAssigned runs after commit (the direct executor write fires no postSave
|
|
239
|
+
// hook). Late-bound: without tierMap it stays a no-op, the tierMap block
|
|
240
|
+
// below installs the real cache update + publish.
|
|
234
241
|
const onTierAssigned: { fn: (tenantId: TenantId, tier: string) => void } = { fn: () => {} };
|
|
235
242
|
r.writeHandler(
|
|
236
243
|
createSetTenantTierWrite({
|
|
@@ -312,6 +319,21 @@ export function createTierEngineFeature<
|
|
|
312
319
|
// mergeAlwaysOn-calls — Late-bind via mutable holder, gefüllt vor allen
|
|
313
320
|
// Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
|
|
314
321
|
const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
|
|
322
|
+
// Bus handed to build(); hooks fire outside build and publish through it.
|
|
323
|
+
const busHolder: { bus: CacheSyncBus | undefined } = { bus: undefined };
|
|
324
|
+
// Out-of-order guard: every cache mutation or reload start takes a ticket; a
|
|
325
|
+
// reload result is applied only while its ticket is still the tenant's latest.
|
|
326
|
+
let ticketCounter = 0;
|
|
327
|
+
const latestTicket = new Map<TenantId, number>();
|
|
328
|
+
const takeTicket = (tenantId: TenantId): number => {
|
|
329
|
+
ticketCounter += 1;
|
|
330
|
+
latestTicket.set(tenantId, ticketCounter);
|
|
331
|
+
return ticketCounter;
|
|
332
|
+
};
|
|
333
|
+
const publishAssignmentChange = (tenantId: TenantId): void => {
|
|
334
|
+
const message: TierAssignmentSyncMessage = { tenantId };
|
|
335
|
+
busHolder.bus?.publish(CACHE_SYNC_TOPICS.tierAssignment, message);
|
|
336
|
+
};
|
|
315
337
|
|
|
316
338
|
// Trial: zeit-abgeleitet aus tenant.inserted_at (≈ Signup), live am
|
|
317
339
|
// Feature-Gate geprüft — NICHT im Resolver-Cache. Der Resolver ist
|
|
@@ -330,16 +352,16 @@ export function createTierEngineFeature<
|
|
|
330
352
|
: new Set();
|
|
331
353
|
const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
|
|
332
354
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// Semantik wie der Hook.
|
|
338
|
-
onTierAssigned.fn = (tenantId, tier) => {
|
|
355
|
+
// Immediate local visibility on the writing pod; other pods learn via the
|
|
356
|
+
// bus and re-read the row.
|
|
357
|
+
const applyAssignment = (tenantId: TenantId, tier: string): void => {
|
|
358
|
+
takeTicket(tenantId);
|
|
339
359
|
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
|
|
360
|
+
publishAssignmentChange(tenantId);
|
|
340
361
|
};
|
|
362
|
+
onTierAssigned.fn = applyAssignment;
|
|
341
363
|
|
|
342
|
-
// Invalidation: tier-assignment events update the cache.
|
|
364
|
+
// Invalidation: tier-assignment CRUD events (e.g. the billing webhook) update the cache.
|
|
343
365
|
r.hook("postSave", { allOf: "tier-assignment" }, async (result) => {
|
|
344
366
|
// result.data has tenantId + tier (after entity-update merge)
|
|
345
367
|
const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
|
|
@@ -349,14 +371,16 @@ export function createTierEngineFeature<
|
|
|
349
371
|
// throwing — der lifecycle-pipeline darf nicht durch hook-fehler
|
|
350
372
|
// blocken (afterCommit-pattern, side-effect-best-effort).
|
|
351
373
|
if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
|
|
352
|
-
|
|
353
|
-
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
|
|
374
|
+
applyAssignment(data.tenantId as TenantId, data.tier);
|
|
354
375
|
});
|
|
355
376
|
r.hook("postDelete", { allOf: "tier-assignment" }, async (payload) => {
|
|
356
377
|
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
357
378
|
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
358
379
|
if (typeof data.tenantId !== "string") return;
|
|
359
|
-
|
|
380
|
+
const tenantId = data.tenantId as TenantId;
|
|
381
|
+
takeTicket(tenantId);
|
|
382
|
+
cache.delete(tenantId);
|
|
383
|
+
publishAssignmentChange(tenantId);
|
|
360
384
|
});
|
|
361
385
|
|
|
362
386
|
// Auto-default-tier-on-tenant-signup: hook fires inTransaction (atomic
|
|
@@ -473,6 +497,60 @@ export function createTierEngineFeature<
|
|
|
473
497
|
);
|
|
474
498
|
}
|
|
475
499
|
|
|
500
|
+
// Read first, then set — never clear-then-refill: a cache miss falls back
|
|
501
|
+
// to the default tier, which would lock paying tenants out meanwhile.
|
|
502
|
+
const reloadTenant = async (tenantId: TenantId): Promise<void> => {
|
|
503
|
+
const ticket = takeTicket(tenantId);
|
|
504
|
+
const row = await fetchOne<AssignmentRow>(deps.db, tierAssignmentTable, { tenantId });
|
|
505
|
+
// skip: a newer write or reload for this tenant started meanwhile and owns the entry
|
|
506
|
+
if (latestTicket.get(tenantId) !== ticket) return;
|
|
507
|
+
if (row) {
|
|
508
|
+
cache.set(
|
|
509
|
+
tenantId,
|
|
510
|
+
mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
|
|
511
|
+
);
|
|
512
|
+
} else {
|
|
513
|
+
cache.delete(tenantId);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const reloadAllTenants = async (): Promise<void> => {
|
|
517
|
+
ticketCounter += 1;
|
|
518
|
+
const startedAtTicket = ticketCounter;
|
|
519
|
+
const current = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
|
|
520
|
+
const loaded = new Map<TenantId, ReadonlySet<string>>();
|
|
521
|
+
for (const row of current) {
|
|
522
|
+
loaded.set(
|
|
523
|
+
row.tenantId as TenantId,
|
|
524
|
+
mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const isNewerThanSnapshot = (tenantId: TenantId): boolean =>
|
|
528
|
+
(latestTicket.get(tenantId) ?? 0) > startedAtTicket;
|
|
529
|
+
for (const tenantId of [...cache.keys()]) {
|
|
530
|
+
if (!loaded.has(tenantId) && !isNewerThanSnapshot(tenantId)) cache.delete(tenantId);
|
|
531
|
+
}
|
|
532
|
+
for (const [tenantId, features] of loaded) {
|
|
533
|
+
if (!isNewerThanSnapshot(tenantId)) cache.set(tenantId, features);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
busHolder.bus = deps.cacheSync;
|
|
538
|
+
deps.cacheSync?.subscribe(CACHE_SYNC_TOPICS.tierAssignment, (message) => {
|
|
539
|
+
// skip: malformed message from another process
|
|
540
|
+
if (!isTierAssignmentSyncMessage(message)) return;
|
|
541
|
+
reloadTenant(message.tenantId).catch((err: unknown) => {
|
|
542
|
+
log.error("tier reload failed, keeping cached set", {
|
|
543
|
+
tenantId: message.tenantId,
|
|
544
|
+
err,
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
deps.cacheSync?.onResync(() => {
|
|
549
|
+
reloadAllTenants().catch((err: unknown) => {
|
|
550
|
+
log.error("tier resync failed, keeping cached sets", { err });
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
476
554
|
// Synchronous resolver-callback for dispatcher hot-path.
|
|
477
555
|
const resolver = (tenantId: TenantId): ReadonlySet<string> => {
|
|
478
556
|
// Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
|
|
@@ -24,14 +24,13 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
|
|
|
24
24
|
// `source: "manual"` markiert den Grant, damit ein späterer Stripe→Tier-Sync ihn
|
|
25
25
|
// nicht plättet. Upsert: ein Aggregat pro Tenant (deterministische aggregate-id).
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// (storage-only ohne tierMap = no-op).
|
|
27
|
+
// Effective-set invalidation: the executor write does not fire the
|
|
28
|
+
// `tier-assignment:postSave` entity hook, and a per-handler postSave would not
|
|
29
|
+
// fire either because this handler returns no lifecycle result (kind "save").
|
|
30
|
+
// Without an explicit update the feature gate would stay on the old tier, so
|
|
31
|
+
// `opts.onAssigned(tenantId, tier)` runs after commit (ctx.scheduleAfterCommit).
|
|
32
|
+
// feature.ts uses it to update its cache and notify the other processes; in
|
|
33
|
+
// storage-only mode without a tierMap it is a no-op.
|
|
35
34
|
|
|
36
35
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
37
36
|
const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEntity, {
|
|
@@ -41,9 +40,9 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
|
|
|
41
40
|
const SET_TENANT_TIER_REASON = "SystemAdmin assigns the tier of the tenant named in the payload";
|
|
42
41
|
|
|
43
42
|
export type SetTenantTierOptions = {
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
/** Runs after the write commits so feature.ts can update the resolver cache
|
|
44
|
+
* and notify the other processes. Without a tierMap there is no resolver
|
|
45
|
+
* and this is a no-op. */
|
|
47
46
|
readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
|
|
48
47
|
/** Tier-Namen aus der tierMap-Closure — ohne sie (storage-only-Mode) bleibt
|
|
49
48
|
* `tier` unvalidiert außer Length/Non-Empty. Mit ihr rejected der Handler
|
|
@@ -72,6 +71,12 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
72
71
|
reason: SET_TENANT_TIER_REASON,
|
|
73
72
|
},
|
|
74
73
|
handler: async (event, ctx) => {
|
|
74
|
+
// Without a commit sink (unit harness) there is no transaction to wait for.
|
|
75
|
+
const afterCommit = (tenantId: TenantId, tier: string): void => {
|
|
76
|
+
const notify = async (): Promise<void> => opts.onAssigned?.(tenantId, tier);
|
|
77
|
+
if (ctx.scheduleAfterCommit) ctx.scheduleAfterCommit(notify);
|
|
78
|
+
else opts.onAssigned?.(tenantId, tier);
|
|
79
|
+
};
|
|
75
80
|
const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
76
81
|
const rawDb = ctx.db.unsafeRaw();
|
|
77
82
|
const tdb = createTenantDb(rawDb, tenantId, "system");
|
|
@@ -91,7 +96,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
91
96
|
tdb,
|
|
92
97
|
);
|
|
93
98
|
if (!result.isSuccess) return result;
|
|
94
|
-
|
|
99
|
+
afterCommit(tenantId, tier);
|
|
95
100
|
return { isSuccess: true as const, data: { tenantId, tier, isNew: false } };
|
|
96
101
|
}
|
|
97
102
|
|
|
@@ -101,7 +106,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
101
106
|
tdb,
|
|
102
107
|
);
|
|
103
108
|
if (!result.isSuccess) return result;
|
|
104
|
-
|
|
109
|
+
afterCommit(tenantId, tier);
|
|
105
110
|
return { isSuccess: true as const, data: { tenantId, tier, isNew: true } };
|
|
106
111
|
},
|
|
107
112
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createEventStoreExecutor,
|
|
4
|
+
createTenantDb,
|
|
5
|
+
type DbRunner,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
+
import { GLOBAL_ROLE_OPTIONS } from "./constants.js";
|
|
11
|
+
import { userEntity, userTable } from "./schema/user.js";
|
|
12
|
+
|
|
13
|
+
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
14
|
+
|
|
15
|
+
type UserRolesRow = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly version: number;
|
|
18
|
+
readonly roles: readonly string[] | string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
22
|
+
|
|
23
|
+
function isGlobalRole(role: string): role is GlobalRole {
|
|
24
|
+
return GLOBAL_ROLE_OPTIONS.some((option) => option === role);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Reads the invitation's stored globalRoles column, keeping only assignable
|
|
28
|
+
// global roles — a hand-edited row cannot smuggle "system" into users.roles.
|
|
29
|
+
export function parseInvitedGlobalRoles(raw: unknown): readonly GlobalRole[] {
|
|
30
|
+
return parseRoles(raw).filter(isGlobalRole);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Add-only: an accepted invitation never removes a global role the user
|
|
34
|
+
// already holds. Users live on SYSTEM_TENANT_ID (tenancy "global").
|
|
35
|
+
export async function grantInvitedGlobalRoles(
|
|
36
|
+
db: DbRunner,
|
|
37
|
+
options: { readonly userId: string; readonly globalRoles: readonly string[] },
|
|
38
|
+
) {
|
|
39
|
+
const existing = await fetchOne<UserRolesRow>(db, userTable, { id: options.userId });
|
|
40
|
+
if (!existing) return writeFailure(new NotFoundError("user", options.userId));
|
|
41
|
+
|
|
42
|
+
const currentRoles = parseRoles(existing.roles);
|
|
43
|
+
const missingRoles = options.globalRoles.filter((role) => !currentRoles.includes(role));
|
|
44
|
+
if (missingRoles.length === 0) return grantedGlobalRoles(currentRoles);
|
|
45
|
+
|
|
46
|
+
const roles = [...currentRoles, ...missingRoles];
|
|
47
|
+
const updateResult = await userExecutor.update(
|
|
48
|
+
{ id: existing.id, version: existing.version, changes: { roles } },
|
|
49
|
+
createSystemUser(SYSTEM_TENANT_ID),
|
|
50
|
+
createTenantDb(db, SYSTEM_TENANT_ID, "system"),
|
|
51
|
+
);
|
|
52
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
53
|
+
return grantedGlobalRoles(roles);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function grantedGlobalRoles(roles: readonly string[]) {
|
|
57
|
+
return { isSuccess: true, data: { roles } } as const;
|
|
58
|
+
}
|
|
@@ -476,6 +476,9 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
476
476
|
method: "GET",
|
|
477
477
|
path: "/user-export/by-token",
|
|
478
478
|
anonymous: true,
|
|
479
|
+
// Entry point of the emailed download link; cheap static page, but
|
|
480
|
+
// anonymous and token-bearing, so bound per-IP probing.
|
|
481
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
479
482
|
handler: async (c) => {
|
|
480
483
|
return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
|
|
481
484
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.342.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Waitlist notifyRecipient may return a promise",
|
|
6
|
+
"detail": "Waitlist `notifyRecipient` may return a promise, so apps can read the admin address from config"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.341.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Waitlist submit is limited per email address"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.341.0",
|
|
15
|
+
"type": "improvement",
|
|
16
|
+
"title": "Add waitlist bundled feature: public signup intake with admin invite and reject, GDPR export and erasure"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.341.0",
|
|
20
|
+
"type": "fix",
|
|
21
|
+
"title": "Waitlist re-invite and reject no longer touch an invitee who already accepted"
|
|
22
|
+
}
|
|
23
|
+
]
|