@cosmicdrift/kumiko-samples 0.340.0 → 0.341.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/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -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-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/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/email-channel.ts +1 -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 +17 -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 +41 -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 +36 -5
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.341.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.341.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>",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
133
133
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
134
134
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
135
|
+
"./waitlist": "./src/waitlist/index.ts",
|
|
135
136
|
"./page-render": "./src/page-render/index.ts",
|
|
136
137
|
"./page-render/web": "./src/page-render/web.ts",
|
|
137
138
|
"./managed-pages": "./src/managed-pages/index.ts",
|
|
@@ -618,6 +619,10 @@
|
|
|
618
619
|
"types": "./dist/legal-pages/web/index.d.ts",
|
|
619
620
|
"default": "./dist/legal-pages/web/index.js"
|
|
620
621
|
},
|
|
622
|
+
"./waitlist": {
|
|
623
|
+
"types": "./dist/waitlist/index.d.ts",
|
|
624
|
+
"default": "./dist/waitlist/index.js"
|
|
625
|
+
},
|
|
621
626
|
"./page-render": {
|
|
622
627
|
"types": "./dist/page-render/index.d.ts",
|
|
623
628
|
"default": "./dist/page-render/index.js"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Passwordless, idempotent first-run provisioning: tenants plus invitations
|
|
2
|
+
// (optionally carrying global roles such as SystemAdmin). Every write goes
|
|
3
|
+
// through the dispatcher as the system user, so hooks, events and jobs fire
|
|
4
|
+
// exactly as for any other writer. Nobody gets a role before accepting: the
|
|
5
|
+
// invitation row holds membership role + global roles until the invitee
|
|
6
|
+
// accepts through the regular invite-accept routes.
|
|
7
|
+
|
|
8
|
+
import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
|
|
9
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
ConfigScopes,
|
|
13
|
+
createSystemUser,
|
|
14
|
+
type TenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
17
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
18
|
+
import type { Redis } from "ioredis";
|
|
19
|
+
// kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
|
|
20
|
+
import { ConfigHandlers } from "../config/constants.js";
|
|
21
|
+
// kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
|
|
22
|
+
import { findConfigRow } from "../config/write-helpers.js";
|
|
23
|
+
// kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
|
|
24
|
+
import { TenantHandlers } from "../tenant/constants.js";
|
|
25
|
+
// kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
|
|
26
|
+
import { INVITATION_STATUS, tenantInvitationsTable } from "../tenant/invitation-table.js";
|
|
27
|
+
// kumiko-lint-ignore cross-feature-import bootstrap checks existing memberships
|
|
28
|
+
import { tenantMembershipsTable } from "../tenant/membership-table.js";
|
|
29
|
+
// kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
|
|
30
|
+
import { tenantTable } from "../tenant/schema/tenant.js";
|
|
31
|
+
// kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
|
|
32
|
+
import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
|
|
33
|
+
// kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
|
|
34
|
+
import { userTable } from "../user/schema/user.js";
|
|
35
|
+
import { AuthHandlers } from "./constants.js";
|
|
36
|
+
import { hasLiveInviteToken } from "./invite-token-store.js";
|
|
37
|
+
|
|
38
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
39
|
+
|
|
40
|
+
export type BootstrapInvite = {
|
|
41
|
+
readonly email: string;
|
|
42
|
+
/** Membership role in the tenant, validated like a tenant-admin invite
|
|
43
|
+
* (reserved roles such as SystemAdmin are rejected here). */
|
|
44
|
+
readonly role: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type BootstrapSeedDeps = {
|
|
48
|
+
readonly tenantId: TenantId;
|
|
49
|
+
readonly db: DbConnection;
|
|
50
|
+
readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type BootstrapConfigValue = string | number | boolean;
|
|
54
|
+
|
|
55
|
+
export type BootstrapTenant = {
|
|
56
|
+
readonly id: TenantId;
|
|
57
|
+
readonly key: string;
|
|
58
|
+
readonly name: string;
|
|
59
|
+
/** Tenant-scope config values by qualified key (e.g.
|
|
60
|
+
* `"auth-mfa:config:required": "admins"`), written as system writes on
|
|
61
|
+
* EVERY run: config is declarative, so a rerun converges to the plan and
|
|
62
|
+
* a value that already matches is not rewritten. Unlike `seed` it also
|
|
63
|
+
* applies to tenants that already exist. */
|
|
64
|
+
readonly config?: Readonly<Record<string, BootstrapConfigValue>>;
|
|
65
|
+
readonly invites?: readonly BootstrapInvite[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type BootstrapSystemAdmin = {
|
|
69
|
+
readonly email: string;
|
|
70
|
+
/** Home tenant: login needs a membership, so the SystemAdmin is invited
|
|
71
|
+
* into this tenant with `role` and receives the global role on accept. */
|
|
72
|
+
readonly tenantId: TenantId;
|
|
73
|
+
readonly role: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type BootstrapPlan = {
|
|
77
|
+
readonly tenants: readonly BootstrapTenant[];
|
|
78
|
+
readonly systemAdmins?: readonly BootstrapSystemAdmin[];
|
|
79
|
+
/** Runs for each tenant right after this run created it. An existing
|
|
80
|
+
* tenant is never seeded again — also not when a previous run failed
|
|
81
|
+
* inside the seed (that run fails; clean up by hand). */
|
|
82
|
+
readonly seed?: (deps: BootstrapSeedDeps) => Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type BootstrapDeps = {
|
|
86
|
+
readonly db: DbConnection;
|
|
87
|
+
readonly redis: Redis;
|
|
88
|
+
readonly dispatcher: Dispatcher;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type BootstrapTenantOutcome = "created" | "exists";
|
|
92
|
+
|
|
93
|
+
export type BootstrapInviteOutcome =
|
|
94
|
+
// fresh invitation mailed
|
|
95
|
+
| "invited"
|
|
96
|
+
// the previous invitation expired unused, a new link was mailed
|
|
97
|
+
| "resent"
|
|
98
|
+
// a still-valid invitation is pending, nothing sent
|
|
99
|
+
| "pending"
|
|
100
|
+
// the invitee accepted earlier or already holds the access
|
|
101
|
+
| "active"
|
|
102
|
+
// a tenant admin cancelled the invitation; bootstrap respects that
|
|
103
|
+
| "cancelled"
|
|
104
|
+
// a tenant admin re-invited the address without the planned global roles;
|
|
105
|
+
// bootstrap does not override that, the operator has to decide
|
|
106
|
+
| "role-mismatch";
|
|
107
|
+
|
|
108
|
+
export type BootstrapReport = {
|
|
109
|
+
readonly tenants: ReadonlyArray<{
|
|
110
|
+
readonly id: TenantId;
|
|
111
|
+
readonly outcome: BootstrapTenantOutcome;
|
|
112
|
+
readonly seeded: boolean;
|
|
113
|
+
/** Qualified keys this run wrote (new or changed); empty on a no-op rerun. */
|
|
114
|
+
readonly configApplied: readonly string[];
|
|
115
|
+
}>;
|
|
116
|
+
readonly invites: ReadonlyArray<{
|
|
117
|
+
readonly tenantId: TenantId;
|
|
118
|
+
readonly email: string;
|
|
119
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
120
|
+
readonly outcome: BootstrapInviteOutcome;
|
|
121
|
+
}>;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type PlannedInvite = {
|
|
125
|
+
readonly tenantId: TenantId;
|
|
126
|
+
readonly email: string;
|
|
127
|
+
readonly role: string;
|
|
128
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
type InvitationRow = {
|
|
132
|
+
readonly id: string;
|
|
133
|
+
readonly status: string;
|
|
134
|
+
readonly globalRoles: unknown;
|
|
135
|
+
};
|
|
136
|
+
type UserRow = { readonly id: string; readonly roles: unknown };
|
|
137
|
+
|
|
138
|
+
export class BootstrapPlanError extends Error {}
|
|
139
|
+
|
|
140
|
+
export class BootstrapWriteError extends Error {}
|
|
141
|
+
|
|
142
|
+
function planInvites(plan: BootstrapPlan): readonly PlannedInvite[] {
|
|
143
|
+
const tenantIds = new Set(plan.tenants.map((t) => t.id));
|
|
144
|
+
const invites: PlannedInvite[] = [];
|
|
145
|
+
for (const admin of plan.systemAdmins ?? []) {
|
|
146
|
+
if (!tenantIds.has(admin.tenantId)) {
|
|
147
|
+
throw new BootstrapPlanError(
|
|
148
|
+
`systemAdmins: ${admin.email} references tenant ${admin.tenantId}, which is not in tenants`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
invites.push({
|
|
152
|
+
tenantId: admin.tenantId,
|
|
153
|
+
email: admin.email.toLowerCase(),
|
|
154
|
+
role: admin.role,
|
|
155
|
+
globalRoles: ["SystemAdmin"],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
for (const tenant of plan.tenants) {
|
|
159
|
+
for (const invite of tenant.invites ?? []) {
|
|
160
|
+
invites.push({
|
|
161
|
+
tenantId: tenant.id,
|
|
162
|
+
email: invite.email.toLowerCase(),
|
|
163
|
+
role: invite.role,
|
|
164
|
+
globalRoles: [],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// One invitation row per (tenant, email): a second entry would overwrite
|
|
169
|
+
// the first one's role or global roles.
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
for (const invite of invites) {
|
|
172
|
+
const key = `${invite.tenantId}\u0000${invite.email}`;
|
|
173
|
+
if (seen.has(key)) {
|
|
174
|
+
throw new BootstrapPlanError(
|
|
175
|
+
`${invite.email} is listed twice for tenant ${invite.tenantId} (systemAdmins and tenants[].invites share one invitation per tenant)`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
seen.add(key);
|
|
179
|
+
}
|
|
180
|
+
return invites;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function ensureTenant(
|
|
184
|
+
deps: BootstrapDeps,
|
|
185
|
+
tenant: BootstrapTenant,
|
|
186
|
+
): Promise<BootstrapTenantOutcome> {
|
|
187
|
+
const existing = await fetchOne(deps.db, tenantTable, { id: tenant.id });
|
|
188
|
+
if (existing) return "exists";
|
|
189
|
+
const result = await deps.dispatcher.write(
|
|
190
|
+
TenantHandlers.create,
|
|
191
|
+
{ id: tenant.id, key: tenant.key, name: tenant.name },
|
|
192
|
+
createSystemUser(tenant.id),
|
|
193
|
+
);
|
|
194
|
+
if (!result.isSuccess) {
|
|
195
|
+
throw new BootstrapWriteError(
|
|
196
|
+
`creating tenant ${tenant.key} (${tenant.id}) failed: ${result.error.code} — ${result.error.message}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return "created";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function applyTenantConfig(
|
|
203
|
+
deps: BootstrapDeps,
|
|
204
|
+
tenant: BootstrapTenant,
|
|
205
|
+
): Promise<readonly string[]> {
|
|
206
|
+
const applied: string[] = [];
|
|
207
|
+
for (const [key, value] of Object.entries(tenant.config ?? {})) {
|
|
208
|
+
const existing = await findConfigRow(deps.db, key, tenant.id, null);
|
|
209
|
+
if (existing?.value === JSON.stringify(value)) continue;
|
|
210
|
+
const result = await deps.dispatcher.write(
|
|
211
|
+
ConfigHandlers.set,
|
|
212
|
+
{ key, value, scope: ConfigScopes.tenant },
|
|
213
|
+
// SystemAdmin on top of the system role: keys with the default tenant
|
|
214
|
+
// write access (admin roles) reject a bare system user, system-only keys
|
|
215
|
+
// accept it.
|
|
216
|
+
createSystemUser(tenant.id, ["SystemAdmin"]),
|
|
217
|
+
);
|
|
218
|
+
if (!result.isSuccess) {
|
|
219
|
+
throw new BootstrapWriteError(
|
|
220
|
+
`setting config ${key} for tenant ${tenant.id} failed: ${result.error.code} — ${result.error.message}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
applied.push(key);
|
|
224
|
+
}
|
|
225
|
+
return applied;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function holdsInvitedAccess(deps: BootstrapDeps, invite: PlannedInvite): Promise<boolean> {
|
|
229
|
+
const user = await fetchOne<UserRow>(deps.db, userTable, {
|
|
230
|
+
email: invite.email,
|
|
231
|
+
isDeleted: false,
|
|
232
|
+
});
|
|
233
|
+
if (!user) return false;
|
|
234
|
+
const membership = await fetchOne(deps.db, tenantMembershipsTable, {
|
|
235
|
+
userId: user.id,
|
|
236
|
+
tenantId: invite.tenantId,
|
|
237
|
+
});
|
|
238
|
+
if (!membership) return false;
|
|
239
|
+
const userGlobalRoles = parseRoles(user.roles);
|
|
240
|
+
return invite.globalRoles.every((role) => userGlobalRoles.includes(role));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function decideInvite(
|
|
244
|
+
deps: BootstrapDeps,
|
|
245
|
+
invite: PlannedInvite,
|
|
246
|
+
): Promise<BootstrapInviteOutcome> {
|
|
247
|
+
if (await holdsInvitedAccess(deps, invite)) return "active";
|
|
248
|
+
const invitation = await fetchOne<InvitationRow>(deps.db, tenantInvitationsTable, {
|
|
249
|
+
tenantId: invite.tenantId,
|
|
250
|
+
email: invite.email,
|
|
251
|
+
});
|
|
252
|
+
if (!invitation) return "invited";
|
|
253
|
+
if (invitation.status === INVITATION_STATUS.cancelled) return "cancelled";
|
|
254
|
+
const storedGlobalRoles = parseRoles(invitation.globalRoles);
|
|
255
|
+
if (!invite.globalRoles.every((role) => storedGlobalRoles.includes(role))) {
|
|
256
|
+
return "role-mismatch";
|
|
257
|
+
}
|
|
258
|
+
if (invitation.status === INVITATION_STATUS.accepted) return "active";
|
|
259
|
+
if (await hasLiveInviteToken(deps.redis, invitation.id)) return "pending";
|
|
260
|
+
return "resent";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isMailingOutcome(outcome: BootstrapInviteOutcome): boolean {
|
|
264
|
+
return outcome === "invited" || outcome === "resent";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function bootstrapTenants(
|
|
268
|
+
deps: BootstrapDeps,
|
|
269
|
+
plan: BootstrapPlan,
|
|
270
|
+
): Promise<BootstrapReport> {
|
|
271
|
+
const plannedInvites = planInvites(plan);
|
|
272
|
+
const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
|
|
273
|
+
|
|
274
|
+
const tenants: Array<BootstrapReport["tenants"][number]> = [];
|
|
275
|
+
for (const tenant of plan.tenants) {
|
|
276
|
+
const outcome = await ensureTenant(deps, tenant);
|
|
277
|
+
const shouldSeed = outcome === "created" && plan.seed !== undefined;
|
|
278
|
+
if (shouldSeed) {
|
|
279
|
+
await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
|
|
280
|
+
}
|
|
281
|
+
const configApplied = await applyTenantConfig(deps, tenant);
|
|
282
|
+
tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const invites: Array<BootstrapReport["invites"][number]> = [];
|
|
286
|
+
for (const invite of plannedInvites) {
|
|
287
|
+
const outcome = await decideInvite(deps, invite);
|
|
288
|
+
if (isMailingOutcome(outcome)) {
|
|
289
|
+
const result = await deps.dispatcher.write(
|
|
290
|
+
AuthHandlers.systemInviteCreate,
|
|
291
|
+
{ email: invite.email, role: invite.role, globalRoles: invite.globalRoles },
|
|
292
|
+
createSystemUser(invite.tenantId),
|
|
293
|
+
);
|
|
294
|
+
if (!result.isSuccess) {
|
|
295
|
+
throw new BootstrapWriteError(
|
|
296
|
+
`inviting ${invite.email} into tenant ${invite.tenantId} failed: ${result.error.code} — ${result.error.message}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
invites.push({
|
|
301
|
+
tenantId: invite.tenantId,
|
|
302
|
+
email: invite.email,
|
|
303
|
+
globalRoles: invite.globalRoles,
|
|
304
|
+
outcome,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { tenants, invites };
|
|
309
|
+
}
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Invitations can carry a global role (SystemAdmin), granted only on accept",
|
|
6
|
+
"detail": "New system-only handler auth-email-password:write:system-invite-create and bootstrapTenants() for idempotent passwordless provisioning. Tenant admins cannot set global roles; a re-invite clears a pending one.",
|
|
7
|
+
"migration": "New column read_tenant_invitations.global_roles: run `kumiko schema generate` and apply the migration before deploying."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.341.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "runBootstrap applies per-tenant config",
|
|
13
|
+
"detail": "BootstrapTenant.config sets tenant-scope config keys as system writes on every run; a value that already matches is not rewritten."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.341.0",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "System invite refuses to reset an accepted invitation (invite_already_accepted)"
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.339.0",
|
|
4
22
|
"type": "improvement",
|
|
@@ -40,6 +40,9 @@ export const AuthHandlers = {
|
|
|
40
40
|
// inviteSignupComplete: anon user with new email → POST token + password
|
|
41
41
|
// inviteCancel: admin cancels a pending invite
|
|
42
42
|
inviteCreate: "auth-email-password:write:invite-create",
|
|
43
|
+
// System-only variant (access.system) that may carry global roles — see
|
|
44
|
+
// invite-create.write.ts. Dispatched in-process by runBootstrap.
|
|
45
|
+
systemInviteCreate: "auth-email-password:write:system-invite-create",
|
|
43
46
|
inviteAccept: "auth-email-password:write:invite-accept",
|
|
44
47
|
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
45
48
|
inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
|
|
@@ -96,6 +99,8 @@ export const AuthErrors = {
|
|
|
96
99
|
invalidInviteToken: "invalid_invite_token",
|
|
97
100
|
inviteEmailMismatch: "invite_email_mismatch",
|
|
98
101
|
inviteAlreadyMember: "invite_already_member",
|
|
102
|
+
// System invite (waitlist) refuses to reset an invitation the invitee already accepted.
|
|
103
|
+
inviteAlreadyAccepted: "invite_already_accepted",
|
|
99
104
|
// Account-lockout: login refuses with this code when the user's streak of
|
|
100
105
|
// failed attempts has crossed the configured threshold. The error detail
|
|
101
106
|
// carries `retryAfterSeconds` so the UI can show a countdown. Returning a
|
|
@@ -28,6 +28,15 @@ export function inviteEmailMismatch() {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// @wrapper-known error-helper
|
|
32
|
+
export function inviteAlreadyAccepted() {
|
|
33
|
+
return writeFailure(
|
|
34
|
+
new UnprocessableError(AuthErrors.inviteAlreadyAccepted, {
|
|
35
|
+
i18nKey: "auth.errors.inviteAlreadyAccepted",
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
// @wrapper-known error-helper
|
|
32
41
|
export function invalidResetToken() {
|
|
33
42
|
return writeFailure(
|
|
@@ -8,6 +8,7 @@ import { createInviteAcceptHandler } from "./handlers/invite-accept.write.js";
|
|
|
8
8
|
import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write.js";
|
|
9
9
|
import {
|
|
10
10
|
createInviteCreateHandler,
|
|
11
|
+
createSystemInviteCreateHandler,
|
|
11
12
|
type InviteCreateOptions,
|
|
12
13
|
} from "./handlers/invite-create.write.js";
|
|
13
14
|
import { inviteInfoQuery } from "./handlers/invite-info.query.js";
|
|
@@ -246,6 +247,7 @@ export function createAuthEmailPasswordFeature(
|
|
|
246
247
|
|
|
247
248
|
if (opts.invite) {
|
|
248
249
|
r.writeHandler(createInviteCreateHandler(opts.invite));
|
|
250
|
+
r.writeHandler(createSystemInviteCreateHandler(opts.invite));
|
|
249
251
|
r.writeHandler(createInviteAcceptHandler());
|
|
250
252
|
// Same gates as the `login` handler above (lockout, password,
|
|
251
253
|
// email-verified, account-status, MFA) — see invite-accept-with-login.write.ts.
|
package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts
CHANGED
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
} from "../../tenant/invitation-table.js";
|
|
43
43
|
// kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
|
|
44
44
|
import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
|
|
45
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
46
|
+
import {
|
|
47
|
+
grantInvitedGlobalRoles,
|
|
48
|
+
parseInvitedGlobalRoles,
|
|
49
|
+
} from "../../user/invited-global-roles.js";
|
|
45
50
|
// kumiko-lint-ignore cross-feature-import login-style password-check
|
|
46
51
|
import { userTable } from "../../user/schema/user.js";
|
|
47
52
|
import {
|
|
@@ -143,6 +148,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
143
148
|
readonly tenantId: TenantId;
|
|
144
149
|
readonly email: string;
|
|
145
150
|
readonly role: string;
|
|
151
|
+
readonly globalRoles: unknown;
|
|
146
152
|
readonly version: number;
|
|
147
153
|
readonly insertedAt: Temporal.Instant;
|
|
148
154
|
readonly modifiedAt: Temporal.Instant | null;
|
|
@@ -222,6 +228,12 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
222
228
|
});
|
|
223
229
|
if (!grant.isSuccess) return grant;
|
|
224
230
|
|
|
231
|
+
const globalGrant = await grantInvitedGlobalRoles(dbConn, {
|
|
232
|
+
userId,
|
|
233
|
+
globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
|
|
234
|
+
});
|
|
235
|
+
if (!globalGrant.isSuccess) return globalGrant;
|
|
236
|
+
|
|
225
237
|
// Invitation → accepted: TenantDb for the invitation's tenant.
|
|
226
238
|
const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
|
|
227
239
|
const updateResult = await invitationExecutor.update(
|
|
@@ -239,7 +251,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
239
251
|
|
|
240
252
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
241
253
|
// a reserved role on an existing membership must never reach the session.
|
|
242
|
-
const mergedRoles = buildSessionRoles(
|
|
254
|
+
const mergedRoles = buildSessionRoles(globalGrant.data.roles, grant.data.roles);
|
|
243
255
|
|
|
244
256
|
// MFA gate runs after membership is granted (mirrors login.write.ts's
|
|
245
257
|
// own order: membership resolution before MFA) — a challenge halts
|
|
@@ -34,6 +34,11 @@ import {
|
|
|
34
34
|
} from "../../tenant/invitation-table.js";
|
|
35
35
|
// kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add (like provisionSignupAccount)
|
|
36
36
|
import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
|
|
37
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
38
|
+
import {
|
|
39
|
+
grantInvitedGlobalRoles,
|
|
40
|
+
parseInvitedGlobalRoles,
|
|
41
|
+
} from "../../user/invited-global-roles.js";
|
|
37
42
|
// kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
|
|
38
43
|
import { userTable } from "../../user/schema/user.js";
|
|
39
44
|
import { invalidInviteToken, inviteEmailMismatch } from "../errors.js";
|
|
@@ -103,6 +108,7 @@ export function createInviteAcceptHandler() {
|
|
|
103
108
|
readonly tenantId: TenantId;
|
|
104
109
|
readonly email: string;
|
|
105
110
|
readonly role: string;
|
|
111
|
+
readonly globalRoles: unknown;
|
|
106
112
|
readonly version: number;
|
|
107
113
|
readonly insertedAt: Temporal.Instant;
|
|
108
114
|
readonly modifiedAt: Temporal.Instant | null;
|
|
@@ -153,6 +159,12 @@ export function createInviteAcceptHandler() {
|
|
|
153
159
|
if (!grant.isSuccess) return grant;
|
|
154
160
|
const { alreadyMember } = grant.data;
|
|
155
161
|
|
|
162
|
+
const globalGrant = await grantInvitedGlobalRoles(dbConn, {
|
|
163
|
+
userId: event.user.id,
|
|
164
|
+
globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
|
|
165
|
+
});
|
|
166
|
+
if (!globalGrant.isSuccess) return globalGrant;
|
|
167
|
+
|
|
156
168
|
// Invitation-Status → accepted via event-store-executor.
|
|
157
169
|
// Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
|
|
158
170
|
// (= NICHT der invitation-tenant). Eigene TenantDb für den
|