@cosmicdrift/kumiko-bundled-features 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/dist/auth-email-password/bootstrap.d.ts +74 -0
- package/dist/auth-email-password/bootstrap.js +165 -0
- package/dist/auth-email-password/constants.d.ts +2 -0
- package/dist/auth-email-password/constants.js +5 -0
- package/dist/auth-email-password/errors.d.ts +1 -0
- package/dist/auth-email-password/errors.js +6 -0
- package/dist/auth-email-password/feature.js +2 -1
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
- package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
- package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +1 -0
- package/dist/auth-email-password/invite-token-store.d.ts +3 -0
- package/dist/auth-email-password/invite-token-store.js +5 -0
- package/dist/auth-email-password/magic-link-mail.js +1 -0
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +8 -3
- package/dist/auth-mfa/feature.d.ts +2 -0
- package/dist/auth-mfa/feature.js +2 -1
- package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
- package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
- package/dist/channel-discord/discord-channel.d.ts +4 -0
- package/dist/channel-discord/discord-channel.js +4 -0
- package/dist/channel-discord/feature.js +7 -2
- package/dist/channel-discord/index.d.ts +1 -1
- package/dist/channel-discord/index.js +1 -1
- package/dist/channel-email/email-channel.js +1 -0
- package/dist/channel-slack/feature.js +4 -2
- package/dist/channel-slack/index.d.ts +1 -1
- package/dist/channel-slack/index.js +1 -1
- package/dist/channel-slack/slack-channel.d.ts +4 -0
- package/dist/channel-slack/slack-channel.js +4 -0
- package/dist/channel-teams/feature.js +4 -2
- package/dist/channel-teams/index.d.ts +1 -1
- package/dist/channel-teams/index.js +1 -1
- package/dist/channel-teams/teams-channel.d.ts +4 -0
- package/dist/channel-teams/teams-channel.js +4 -0
- package/dist/channel-telegram/feature.js +4 -2
- package/dist/channel-telegram/index.d.ts +1 -1
- package/dist/channel-telegram/index.js +1 -1
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +28 -12
- package/dist/delivery/chat-webhook-channel.d.ts +5 -1
- package/dist/delivery/chat-webhook-channel.js +10 -5
- package/dist/delivery/chat-webhook-sender.d.ts +6 -2
- package/dist/delivery/chat-webhook-sender.js +13 -8
- package/dist/delivery/delivery-service.d.ts +4 -3
- package/dist/delivery/delivery-service.js +53 -33
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +2 -2
- package/dist/delivery/jobs.js +3 -1
- package/dist/delivery/types.d.ts +5 -3
- package/dist/legal-pages/feature.d.ts +3 -15
- package/dist/legal-pages/feature.js +20 -50
- package/dist/legal-pages/lib/boot-check.d.ts +10 -0
- package/dist/legal-pages/lib/boot-check.js +33 -0
- package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
- package/dist/legal-pages/lib/validate-routes.js +17 -0
- package/dist/page-render/index.d.ts +2 -1
- package/dist/page-render/index.js +1 -0
- package/dist/page-render/layout.d.ts +7 -0
- package/dist/page-render/layout.js +5 -0
- package/dist/page-render/trailing-slash.d.ts +2 -0
- package/dist/page-render/trailing-slash.js +15 -0
- package/dist/renderer-simple/feature.js +1 -0
- package/dist/renderer-simple/index.d.ts +1 -1
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +12 -5
- package/dist/renderer-simple/simple-renderer.js +66 -12
- package/dist/secrets/announce-secret-change.d.ts +8 -0
- package/dist/secrets/announce-secret-change.js +18 -0
- package/dist/secrets/feature.js +1 -1
- package/dist/secrets/handlers/delete.write.js +2 -0
- package/dist/secrets/handlers/set.write.js +6 -1
- package/dist/secrets/i18n.d.ts +4 -0
- package/dist/secrets/i18n.js +4 -0
- package/dist/secrets/secrets-context.d.ts +2 -0
- package/dist/secrets/secrets-context.js +3 -0
- package/dist/secrets/write-gate.d.ts +5 -1
- package/dist/secrets/write-gate.js +24 -1
- package/dist/tenant/constants.d.ts +1 -0
- package/dist/tenant/constants.js +1 -0
- package/dist/tenant/handlers/add-member.write.js +14 -3
- package/dist/tenant/handlers/invitations.query.d.ts +8 -0
- package/dist/tenant/handlers/invitations.query.js +15 -1
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +2 -0
- package/dist/tenant/invitation-table.js +10 -1
- package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
- package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tier-engine/feature.js +83 -14
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
- package/dist/user/invited-global-roles.d.ts +14 -0
- package/dist/user/invited-global-roles.js +35 -0
- package/dist/user-data-rights/feature.js +3 -0
- package/dist/waitlist/constants.d.ts +33 -0
- package/dist/waitlist/constants.js +35 -0
- package/dist/waitlist/entity.d.ts +433 -0
- package/dist/waitlist/entity.js +64 -0
- package/dist/waitlist/feature.d.ts +3 -0
- package/dist/waitlist/feature.js +38 -0
- package/dist/waitlist/handlers/invite.write.d.ts +9 -0
- package/dist/waitlist/handlers/invite.write.js +119 -0
- package/dist/waitlist/handlers/list.query.d.ts +1 -0
- package/dist/waitlist/handlers/list.query.js +10 -0
- package/dist/waitlist/handlers/reject.write.d.ts +7 -0
- package/dist/waitlist/handlers/reject.write.js +80 -0
- package/dist/waitlist/handlers/submit.write.d.ts +13 -0
- package/dist/waitlist/handlers/submit.write.js +112 -0
- package/dist/waitlist/i18n.d.ts +6 -0
- package/dist/waitlist/i18n.js +31 -0
- package/dist/waitlist/index.d.ts +5 -0
- package/dist/waitlist/index.js +4 -0
- package/dist/waitlist/lib.d.ts +10 -0
- package/dist/waitlist/lib.js +24 -0
- package/dist/waitlist/mail.d.ts +15 -0
- package/dist/waitlist/mail.js +73 -0
- package/dist/waitlist/options.d.ts +29 -0
- package/dist/waitlist/options.js +8 -0
- package/dist/waitlist/payloads.d.ts +14 -0
- package/dist/waitlist/payloads.js +47 -0
- package/dist/waitlist/screens.d.ts +2 -0
- package/dist/waitlist/screens.js +42 -0
- package/dist/waitlist/user-data-hooks.d.ts +3 -0
- package/dist/waitlist/user-data-hooks.js +71 -0
- package/package.json +17 -9
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-telegram/changes.json +9 -0
- package/src/delivery/changes.json +7 -0
- package/src/files/changes.json +13 -1
- package/src/renderer-simple/changes.json +6 -0
- package/src/secrets/changes.json +12 -0
- package/src/tenant/changes.json +6 -0
- package/src/waitlist/changes.json +17 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
5
|
+
import type { Redis } from "ioredis";
|
|
6
|
+
import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
|
|
7
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
8
|
+
export type BootstrapInvite = {
|
|
9
|
+
readonly email: string;
|
|
10
|
+
/** Membership role in the tenant, validated like a tenant-admin invite
|
|
11
|
+
* (reserved roles such as SystemAdmin are rejected here). */
|
|
12
|
+
readonly role: string;
|
|
13
|
+
};
|
|
14
|
+
export type BootstrapSeedDeps = {
|
|
15
|
+
readonly tenantId: TenantId;
|
|
16
|
+
readonly db: DbConnection;
|
|
17
|
+
readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
|
|
18
|
+
};
|
|
19
|
+
export type BootstrapConfigValue = string | number | boolean;
|
|
20
|
+
export type BootstrapTenant = {
|
|
21
|
+
readonly id: TenantId;
|
|
22
|
+
readonly key: string;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/** Tenant-scope config values by qualified key (e.g.
|
|
25
|
+
* `"auth-mfa:config:required": "admins"`), written as system writes on
|
|
26
|
+
* EVERY run: config is declarative, so a rerun converges to the plan and
|
|
27
|
+
* a value that already matches is not rewritten. Unlike `seed` it also
|
|
28
|
+
* applies to tenants that already exist. */
|
|
29
|
+
readonly config?: Readonly<Record<string, BootstrapConfigValue>>;
|
|
30
|
+
readonly invites?: readonly BootstrapInvite[];
|
|
31
|
+
};
|
|
32
|
+
export type BootstrapSystemAdmin = {
|
|
33
|
+
readonly email: string;
|
|
34
|
+
/** Home tenant: login needs a membership, so the SystemAdmin is invited
|
|
35
|
+
* into this tenant with `role` and receives the global role on accept. */
|
|
36
|
+
readonly tenantId: TenantId;
|
|
37
|
+
readonly role: string;
|
|
38
|
+
};
|
|
39
|
+
export type BootstrapPlan = {
|
|
40
|
+
readonly tenants: readonly BootstrapTenant[];
|
|
41
|
+
readonly systemAdmins?: readonly BootstrapSystemAdmin[];
|
|
42
|
+
/** Runs for each tenant right after this run created it. An existing
|
|
43
|
+
* tenant is never seeded again — also not when a previous run failed
|
|
44
|
+
* inside the seed (that run fails; clean up by hand). */
|
|
45
|
+
readonly seed?: (deps: BootstrapSeedDeps) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
export type BootstrapDeps = {
|
|
48
|
+
readonly db: DbConnection;
|
|
49
|
+
readonly redis: Redis;
|
|
50
|
+
readonly dispatcher: Dispatcher;
|
|
51
|
+
};
|
|
52
|
+
export type BootstrapTenantOutcome = "created" | "exists";
|
|
53
|
+
export type BootstrapInviteOutcome = "invited" | "resent" | "pending" | "active" | "cancelled" | "role-mismatch";
|
|
54
|
+
export type BootstrapReport = {
|
|
55
|
+
readonly tenants: ReadonlyArray<{
|
|
56
|
+
readonly id: TenantId;
|
|
57
|
+
readonly outcome: BootstrapTenantOutcome;
|
|
58
|
+
readonly seeded: boolean;
|
|
59
|
+
/** Qualified keys this run wrote (new or changed); empty on a no-op rerun. */
|
|
60
|
+
readonly configApplied: readonly string[];
|
|
61
|
+
}>;
|
|
62
|
+
readonly invites: ReadonlyArray<{
|
|
63
|
+
readonly tenantId: TenantId;
|
|
64
|
+
readonly email: string;
|
|
65
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
66
|
+
readonly outcome: BootstrapInviteOutcome;
|
|
67
|
+
}>;
|
|
68
|
+
};
|
|
69
|
+
export declare class BootstrapPlanError extends Error {
|
|
70
|
+
}
|
|
71
|
+
export declare class BootstrapWriteError extends Error {
|
|
72
|
+
}
|
|
73
|
+
export declare function bootstrapTenants(deps: BootstrapDeps, plan: BootstrapPlan): Promise<BootstrapReport>;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
|
|
8
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import { ConfigScopes, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
11
|
+
// kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
|
|
12
|
+
import { ConfigHandlers } from "../config/constants.js";
|
|
13
|
+
// kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
|
|
14
|
+
import { findConfigRow } from "../config/write-helpers.js";
|
|
15
|
+
// kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
|
|
16
|
+
import { TenantHandlers } from "../tenant/constants.js";
|
|
17
|
+
// kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
|
|
18
|
+
import { INVITATION_STATUS, tenantInvitationsTable } from "../tenant/invitation-table.js";
|
|
19
|
+
// kumiko-lint-ignore cross-feature-import bootstrap checks existing memberships
|
|
20
|
+
import { tenantMembershipsTable } from "../tenant/membership-table.js";
|
|
21
|
+
// kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
|
|
22
|
+
import { tenantTable } from "../tenant/schema/tenant.js";
|
|
23
|
+
// kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
|
|
24
|
+
import { userTable } from "../user/schema/user.js";
|
|
25
|
+
import { AuthHandlers } from "./constants.js";
|
|
26
|
+
import { hasLiveInviteToken } from "./invite-token-store.js";
|
|
27
|
+
export class BootstrapPlanError extends Error {
|
|
28
|
+
}
|
|
29
|
+
export class BootstrapWriteError extends Error {
|
|
30
|
+
}
|
|
31
|
+
function planInvites(plan) {
|
|
32
|
+
const tenantIds = new Set(plan.tenants.map((t) => t.id));
|
|
33
|
+
const invites = [];
|
|
34
|
+
for (const admin of plan.systemAdmins ?? []) {
|
|
35
|
+
if (!tenantIds.has(admin.tenantId)) {
|
|
36
|
+
throw new BootstrapPlanError(`systemAdmins: ${admin.email} references tenant ${admin.tenantId}, which is not in tenants`);
|
|
37
|
+
}
|
|
38
|
+
invites.push({
|
|
39
|
+
tenantId: admin.tenantId,
|
|
40
|
+
email: admin.email.toLowerCase(),
|
|
41
|
+
role: admin.role,
|
|
42
|
+
globalRoles: ["SystemAdmin"],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
for (const tenant of plan.tenants) {
|
|
46
|
+
for (const invite of tenant.invites ?? []) {
|
|
47
|
+
invites.push({
|
|
48
|
+
tenantId: tenant.id,
|
|
49
|
+
email: invite.email.toLowerCase(),
|
|
50
|
+
role: invite.role,
|
|
51
|
+
globalRoles: [],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// One invitation row per (tenant, email): a second entry would overwrite
|
|
56
|
+
// the first one's role or global roles.
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const invite of invites) {
|
|
59
|
+
const key = `${invite.tenantId}\u0000${invite.email}`;
|
|
60
|
+
if (seen.has(key)) {
|
|
61
|
+
throw new BootstrapPlanError(`${invite.email} is listed twice for tenant ${invite.tenantId} (systemAdmins and tenants[].invites share one invitation per tenant)`);
|
|
62
|
+
}
|
|
63
|
+
seen.add(key);
|
|
64
|
+
}
|
|
65
|
+
return invites;
|
|
66
|
+
}
|
|
67
|
+
async function ensureTenant(deps, tenant) {
|
|
68
|
+
const existing = await fetchOne(deps.db, tenantTable, { id: tenant.id });
|
|
69
|
+
if (existing)
|
|
70
|
+
return "exists";
|
|
71
|
+
const result = await deps.dispatcher.write(TenantHandlers.create, { id: tenant.id, key: tenant.key, name: tenant.name }, createSystemUser(tenant.id));
|
|
72
|
+
if (!result.isSuccess) {
|
|
73
|
+
throw new BootstrapWriteError(`creating tenant ${tenant.key} (${tenant.id}) failed: ${result.error.code} — ${result.error.message}`);
|
|
74
|
+
}
|
|
75
|
+
return "created";
|
|
76
|
+
}
|
|
77
|
+
async function applyTenantConfig(deps, tenant) {
|
|
78
|
+
const applied = [];
|
|
79
|
+
for (const [key, value] of Object.entries(tenant.config ?? {})) {
|
|
80
|
+
const existing = await findConfigRow(deps.db, key, tenant.id, null);
|
|
81
|
+
if (existing?.value === JSON.stringify(value))
|
|
82
|
+
continue;
|
|
83
|
+
const result = await deps.dispatcher.write(ConfigHandlers.set, { key, value, scope: ConfigScopes.tenant },
|
|
84
|
+
// SystemAdmin on top of the system role: keys with the default tenant
|
|
85
|
+
// write access (admin roles) reject a bare system user, system-only keys
|
|
86
|
+
// accept it.
|
|
87
|
+
createSystemUser(tenant.id, ["SystemAdmin"]));
|
|
88
|
+
if (!result.isSuccess) {
|
|
89
|
+
throw new BootstrapWriteError(`setting config ${key} for tenant ${tenant.id} failed: ${result.error.code} — ${result.error.message}`);
|
|
90
|
+
}
|
|
91
|
+
applied.push(key);
|
|
92
|
+
}
|
|
93
|
+
return applied;
|
|
94
|
+
}
|
|
95
|
+
async function holdsInvitedAccess(deps, invite) {
|
|
96
|
+
const user = await fetchOne(deps.db, userTable, {
|
|
97
|
+
email: invite.email,
|
|
98
|
+
isDeleted: false,
|
|
99
|
+
});
|
|
100
|
+
if (!user)
|
|
101
|
+
return false;
|
|
102
|
+
const membership = await fetchOne(deps.db, tenantMembershipsTable, {
|
|
103
|
+
userId: user.id,
|
|
104
|
+
tenantId: invite.tenantId,
|
|
105
|
+
});
|
|
106
|
+
if (!membership)
|
|
107
|
+
return false;
|
|
108
|
+
const userGlobalRoles = parseRoles(user.roles);
|
|
109
|
+
return invite.globalRoles.every((role) => userGlobalRoles.includes(role));
|
|
110
|
+
}
|
|
111
|
+
async function decideInvite(deps, invite) {
|
|
112
|
+
if (await holdsInvitedAccess(deps, invite))
|
|
113
|
+
return "active";
|
|
114
|
+
const invitation = await fetchOne(deps.db, tenantInvitationsTable, {
|
|
115
|
+
tenantId: invite.tenantId,
|
|
116
|
+
email: invite.email,
|
|
117
|
+
});
|
|
118
|
+
if (!invitation)
|
|
119
|
+
return "invited";
|
|
120
|
+
if (invitation.status === INVITATION_STATUS.cancelled)
|
|
121
|
+
return "cancelled";
|
|
122
|
+
const storedGlobalRoles = parseRoles(invitation.globalRoles);
|
|
123
|
+
if (!invite.globalRoles.every((role) => storedGlobalRoles.includes(role))) {
|
|
124
|
+
return "role-mismatch";
|
|
125
|
+
}
|
|
126
|
+
if (invitation.status === INVITATION_STATUS.accepted)
|
|
127
|
+
return "active";
|
|
128
|
+
if (await hasLiveInviteToken(deps.redis, invitation.id))
|
|
129
|
+
return "pending";
|
|
130
|
+
return "resent";
|
|
131
|
+
}
|
|
132
|
+
function isMailingOutcome(outcome) {
|
|
133
|
+
return outcome === "invited" || outcome === "resent";
|
|
134
|
+
}
|
|
135
|
+
export async function bootstrapTenants(deps, plan) {
|
|
136
|
+
const plannedInvites = planInvites(plan);
|
|
137
|
+
const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
|
|
138
|
+
const tenants = [];
|
|
139
|
+
for (const tenant of plan.tenants) {
|
|
140
|
+
const outcome = await ensureTenant(deps, tenant);
|
|
141
|
+
const shouldSeed = outcome === "created" && plan.seed !== undefined;
|
|
142
|
+
if (shouldSeed) {
|
|
143
|
+
await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
|
|
144
|
+
}
|
|
145
|
+
const configApplied = await applyTenantConfig(deps, tenant);
|
|
146
|
+
tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
|
|
147
|
+
}
|
|
148
|
+
const invites = [];
|
|
149
|
+
for (const invite of plannedInvites) {
|
|
150
|
+
const outcome = await decideInvite(deps, invite);
|
|
151
|
+
if (isMailingOutcome(outcome)) {
|
|
152
|
+
const result = await deps.dispatcher.write(AuthHandlers.systemInviteCreate, { email: invite.email, role: invite.role, globalRoles: invite.globalRoles }, createSystemUser(invite.tenantId));
|
|
153
|
+
if (!result.isSuccess) {
|
|
154
|
+
throw new BootstrapWriteError(`inviting ${invite.email} into tenant ${invite.tenantId} failed: ${result.error.code} — ${result.error.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
invites.push({
|
|
158
|
+
tenantId: invite.tenantId,
|
|
159
|
+
email: invite.email,
|
|
160
|
+
globalRoles: invite.globalRoles,
|
|
161
|
+
outcome,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return { tenants, invites };
|
|
165
|
+
}
|
|
@@ -13,6 +13,7 @@ export declare const AuthHandlers: {
|
|
|
13
13
|
readonly signupRequest: "auth-email-password:write:signup-request";
|
|
14
14
|
readonly signupConfirm: "auth-email-password:write:signup-confirm";
|
|
15
15
|
readonly inviteCreate: "auth-email-password:write:invite-create";
|
|
16
|
+
readonly systemInviteCreate: "auth-email-password:write:system-invite-create";
|
|
16
17
|
readonly inviteAccept: "auth-email-password:write:invite-accept";
|
|
17
18
|
readonly inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login";
|
|
18
19
|
readonly inviteSignupComplete: "auth-email-password:write:invite-signup-complete";
|
|
@@ -38,6 +39,7 @@ export declare const AuthErrors: {
|
|
|
38
39
|
readonly invalidInviteToken: "invalid_invite_token";
|
|
39
40
|
readonly inviteEmailMismatch: "invite_email_mismatch";
|
|
40
41
|
readonly inviteAlreadyMember: "invite_already_member";
|
|
42
|
+
readonly inviteAlreadyAccepted: "invite_already_accepted";
|
|
41
43
|
readonly accountLocked: "account_locked";
|
|
42
44
|
readonly accountRestricted: "account_restricted";
|
|
43
45
|
};
|
|
@@ -38,6 +38,9 @@ export const AuthHandlers = {
|
|
|
38
38
|
// inviteSignupComplete: anon user with new email → POST token + password
|
|
39
39
|
// inviteCancel: admin cancels a pending invite
|
|
40
40
|
inviteCreate: "auth-email-password:write:invite-create",
|
|
41
|
+
// System-only variant (access.system) that may carry global roles — see
|
|
42
|
+
// invite-create.write.ts. Dispatched in-process by runBootstrap.
|
|
43
|
+
systemInviteCreate: "auth-email-password:write:system-invite-create",
|
|
41
44
|
inviteAccept: "auth-email-password:write:invite-accept",
|
|
42
45
|
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
43
46
|
inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
|
|
@@ -92,6 +95,8 @@ export const AuthErrors = {
|
|
|
92
95
|
invalidInviteToken: "invalid_invite_token",
|
|
93
96
|
inviteEmailMismatch: "invite_email_mismatch",
|
|
94
97
|
inviteAlreadyMember: "invite_already_member",
|
|
98
|
+
// System invite (waitlist) refuses to reset an invitation the invitee already accepted.
|
|
99
|
+
inviteAlreadyAccepted: "invite_already_accepted",
|
|
95
100
|
// Account-lockout: login refuses with this code when the user's streak of
|
|
96
101
|
// failed attempts has crossed the configured threshold. The error detail
|
|
97
102
|
// carries `retryAfterSeconds` so the UI can show a countdown. Returning a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare function invalidCredentials(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
2
2
|
export declare function invalidInviteToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
3
3
|
export declare function inviteEmailMismatch(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
4
|
+
export declare function inviteAlreadyAccepted(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
4
5
|
export declare function invalidResetToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
5
6
|
export declare function invalidUnlockToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
6
7
|
export declare function invalidVerificationToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
|
|
@@ -19,6 +19,12 @@ export function inviteEmailMismatch() {
|
|
|
19
19
|
}));
|
|
20
20
|
}
|
|
21
21
|
// @wrapper-known error-helper
|
|
22
|
+
export function inviteAlreadyAccepted() {
|
|
23
|
+
return writeFailure(new UnprocessableError(AuthErrors.inviteAlreadyAccepted, {
|
|
24
|
+
i18nKey: "auth.errors.inviteAlreadyAccepted",
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
// @wrapper-known error-helper
|
|
22
28
|
export function invalidResetToken() {
|
|
23
29
|
return writeFailure(new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
24
30
|
i18nKey: "auth.errors.invalidResetToken",
|
|
@@ -5,7 +5,7 @@ import { changePasswordWrite } from "./handlers/change-password.write.js";
|
|
|
5
5
|
import { createConfirmAccountUnlockHandler } from "./handlers/confirm-account-unlock.write.js";
|
|
6
6
|
import { createInviteAcceptHandler } from "./handlers/invite-accept.write.js";
|
|
7
7
|
import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write.js";
|
|
8
|
-
import { createInviteCreateHandler, } from "./handlers/invite-create.write.js";
|
|
8
|
+
import { createInviteCreateHandler, createSystemInviteCreateHandler, } from "./handlers/invite-create.write.js";
|
|
9
9
|
import { inviteInfoQuery } from "./handlers/invite-info.query.js";
|
|
10
10
|
import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write.js";
|
|
11
11
|
import { createLoginHandler } from "./handlers/login.write.js";
|
|
@@ -123,6 +123,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
|
|
|
123
123
|
}
|
|
124
124
|
if (opts.invite) {
|
|
125
125
|
r.writeHandler(createInviteCreateHandler(opts.invite));
|
|
126
|
+
r.writeHandler(createSystemInviteCreateHandler(opts.invite));
|
|
126
127
|
r.writeHandler(createInviteAcceptHandler());
|
|
127
128
|
// Same gates as the `login` handler above (lockout, password,
|
|
128
129
|
// email-verified, account-status, MFA) — see invite-accept-with-login.write.ts.
|
|
@@ -30,6 +30,8 @@ import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../.
|
|
|
30
30
|
import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
|
|
31
31
|
// kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
|
|
32
32
|
import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
|
|
33
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
34
|
+
import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
|
|
33
35
|
// kumiko-lint-ignore cross-feature-import login-style password-check
|
|
34
36
|
import { userTable } from "../../user/schema/user.js";
|
|
35
37
|
import { AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES, AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS, } from "../constants.js";
|
|
@@ -120,6 +122,12 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
|
|
|
120
122
|
});
|
|
121
123
|
if (!grant.isSuccess)
|
|
122
124
|
return grant;
|
|
125
|
+
const globalGrant = await grantInvitedGlobalRoles(dbConn, {
|
|
126
|
+
userId,
|
|
127
|
+
globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
|
|
128
|
+
});
|
|
129
|
+
if (!globalGrant.isSuccess)
|
|
130
|
+
return globalGrant;
|
|
123
131
|
// Invitation → accepted: TenantDb for the invitation's tenant.
|
|
124
132
|
const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
|
|
125
133
|
const updateResult = await invitationExecutor.update({
|
|
@@ -132,7 +140,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
|
|
|
132
140
|
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
133
141
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
134
142
|
// a reserved role on an existing membership must never reach the session.
|
|
135
|
-
const mergedRoles = buildSessionRoles(
|
|
143
|
+
const mergedRoles = buildSessionRoles(globalGrant.data.roles, grant.data.roles);
|
|
136
144
|
// MFA gate runs after membership is granted (mirrors login.write.ts's
|
|
137
145
|
// own order: membership resolution before MFA) — a challenge halts
|
|
138
146
|
// the session mint, not the invite acceptance itself.
|
|
@@ -24,6 +24,8 @@ import { decryptStoredPii } from "../../shared/index.js";
|
|
|
24
24
|
import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
|
|
25
25
|
// kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add (like provisionSignupAccount)
|
|
26
26
|
import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
|
|
27
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
28
|
+
import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
|
|
27
29
|
// kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
|
|
28
30
|
import { userTable } from "../../user/schema/user.js";
|
|
29
31
|
import { invalidInviteToken, inviteEmailMismatch } from "../errors.js";
|
|
@@ -96,6 +98,12 @@ export function createInviteAcceptHandler() {
|
|
|
96
98
|
if (!grant.isSuccess)
|
|
97
99
|
return grant;
|
|
98
100
|
const { alreadyMember } = grant.data;
|
|
101
|
+
const globalGrant = await grantInvitedGlobalRoles(dbConn, {
|
|
102
|
+
userId: event.user.id,
|
|
103
|
+
globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
|
|
104
|
+
});
|
|
105
|
+
if (!globalGrant.isSuccess)
|
|
106
|
+
return globalGrant;
|
|
99
107
|
// Invitation-Status → accepted via event-store-executor.
|
|
100
108
|
// Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
|
|
101
109
|
// (= NICHT der invitation-tenant). Eigene TenantDb für den
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
|
|
2
3
|
import type { AuthMailLocale } from "../email-templates.js";
|
|
4
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
3
5
|
export type InviteCreateData = {
|
|
4
6
|
readonly kind: "invite-created";
|
|
5
7
|
readonly invitationId: string;
|
|
6
8
|
readonly tenantId: string;
|
|
7
9
|
readonly email: string;
|
|
8
10
|
readonly role: string;
|
|
11
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
9
12
|
readonly expiresAt: string;
|
|
10
13
|
};
|
|
11
14
|
export type InviteCreateOptions = {
|
|
@@ -31,3 +34,11 @@ export declare function createInviteCreateHandler(opts: InviteCreateOptions): im
|
|
|
31
34
|
email: z.ZodEmail;
|
|
32
35
|
role: z.ZodString;
|
|
33
36
|
}, z.core.$strip>, InviteCreateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
37
|
+
export declare function createSystemInviteCreateHandler(opts: InviteCreateOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"system-invite-create", z.ZodObject<{
|
|
38
|
+
email: z.ZodEmail;
|
|
39
|
+
role: z.ZodString;
|
|
40
|
+
globalRoles: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
41
|
+
SystemAdmin: "SystemAdmin";
|
|
42
|
+
}>>>;
|
|
43
|
+
}, z.core.$strip>, InviteCreateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
44
|
+
export {};
|
|
@@ -19,15 +19,18 @@
|
|
|
19
19
|
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
20
20
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
21
21
|
import { access, assignableAppRolesOf, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
-
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
22
|
+
import { InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
23
23
|
import { Temporal } from "temporal-polyfill";
|
|
24
24
|
import * as z from "zod";
|
|
25
25
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
|
|
26
26
|
import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
|
|
27
27
|
// kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
|
|
28
28
|
import { findForbiddenMembershipRole, findForbiddenRoleAssignment, reservedMembershipRoleError, unassignableMembershipRoleError, } from "../../tenant/membership-roles.js";
|
|
29
|
+
// kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
|
|
30
|
+
import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
|
|
29
31
|
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
|
|
30
32
|
import { renderInviteEmail } from "../email-templates.js";
|
|
33
|
+
import { inviteAlreadyAccepted } from "../errors.js";
|
|
31
34
|
import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store.js";
|
|
32
35
|
import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
|
|
33
36
|
const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
|
|
@@ -44,102 +47,146 @@ const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitati
|
|
|
44
47
|
function findRankedRole(roles) {
|
|
45
48
|
return roles.find((role) => findForbiddenRoleAssignment(["system"], [role], []) === undefined);
|
|
46
49
|
}
|
|
47
|
-
|
|
50
|
+
function checkAssignableInviteRole(opts, inviterRoles, role, ctx) {
|
|
51
|
+
const forbiddenRole = findForbiddenMembershipRole([role]);
|
|
52
|
+
if (forbiddenRole !== undefined)
|
|
53
|
+
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
54
|
+
const elevationForbidden = findForbiddenRoleAssignment(inviterRoles, [role], [], assignableAppRolesOf(ctx.registry));
|
|
55
|
+
if (elevationForbidden !== undefined &&
|
|
56
|
+
!opts.additionalAssignableRoles?.includes(elevationForbidden)) {
|
|
57
|
+
return writeFailure(unassignableMembershipRoleError(elevationForbidden));
|
|
58
|
+
}
|
|
59
|
+
if (opts.canAssignRole && !opts.canAssignRole(inviterRoles, role)) {
|
|
60
|
+
return writeFailure(unassignableMembershipRoleError(role));
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAccepted = false) {
|
|
65
|
+
if (!ctx.redis) {
|
|
66
|
+
return writeFailure(new InternalError({ message: "invite-create requires ctx.redis for token store" }));
|
|
67
|
+
}
|
|
68
|
+
const email = request.email.toLowerCase();
|
|
69
|
+
const tenantId = inviter.tenantId;
|
|
70
|
+
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
71
|
+
// The unique index allows one row per (tenantId, email). Whatever its
|
|
72
|
+
// status, a re-invite resets it to pending with a fresh token.
|
|
73
|
+
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
74
|
+
let invitationId;
|
|
75
|
+
if (existing) {
|
|
76
|
+
// Provisioning callers (waitlist) must not flip an accepted invite back to
|
|
77
|
+
// pending: that would let them cancel a live membership's invitation.
|
|
78
|
+
// The tenant-admin path keeps resetting because re-inviting a member is
|
|
79
|
+
// how roles get re-assigned.
|
|
80
|
+
if (refuseAccepted && existing["status"] === INVITATION_STATUS.accepted) {
|
|
81
|
+
return inviteAlreadyAccepted();
|
|
82
|
+
}
|
|
83
|
+
invitationId = existing["id"]; // @cast-boundary db-row
|
|
84
|
+
const existingVersion = existing["version"]; // @cast-boundary db-row
|
|
85
|
+
// At most one live invite token per invitation: invalidate
|
|
86
|
+
// whatever's there before minting the new one.
|
|
87
|
+
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
88
|
+
const updateResult = await executor.update({
|
|
89
|
+
id: invitationId,
|
|
90
|
+
version: existingVersion,
|
|
91
|
+
changes: {
|
|
92
|
+
role: request.role,
|
|
93
|
+
// Always written, also as []: a tenant-admin resend must drop
|
|
94
|
+
// global roles a previous system invite put on this row.
|
|
95
|
+
globalRoles: [...request.globalRoles],
|
|
96
|
+
status: INVITATION_STATUS.pending,
|
|
97
|
+
invitedBy: inviter.id,
|
|
98
|
+
expiresAt,
|
|
99
|
+
},
|
|
100
|
+
}, inviter, ctx.db);
|
|
101
|
+
if (!updateResult.isSuccess)
|
|
102
|
+
return updateResult;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const createResult = await executor.create({
|
|
106
|
+
email,
|
|
107
|
+
role: request.role,
|
|
108
|
+
globalRoles: [...request.globalRoles],
|
|
109
|
+
status: INVITATION_STATUS.pending,
|
|
110
|
+
invitedBy: inviter.id,
|
|
111
|
+
expiresAt,
|
|
112
|
+
}, inviter, ctx.db);
|
|
113
|
+
if (!createResult.isSuccess)
|
|
114
|
+
return createResult;
|
|
115
|
+
invitationId = createResult.data.id; // @cast-boundary engine-payload
|
|
116
|
+
}
|
|
117
|
+
const token = generateToken();
|
|
118
|
+
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
119
|
+
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
120
|
+
await dispatchMagicLinkMail(ctx.notify, {
|
|
121
|
+
handlerName: "invite-create",
|
|
122
|
+
notificationType: INVITE_NOTIFICATION_TYPE,
|
|
123
|
+
renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: request.role }),
|
|
124
|
+
}, {
|
|
125
|
+
email,
|
|
126
|
+
appUrl: opts.appUrl,
|
|
127
|
+
token,
|
|
128
|
+
expiresAt: expiresAt.toString(),
|
|
129
|
+
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
130
|
+
locale,
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
isSuccess: true,
|
|
134
|
+
data: {
|
|
135
|
+
kind: "invite-created",
|
|
136
|
+
invitationId,
|
|
137
|
+
tenantId,
|
|
138
|
+
email,
|
|
139
|
+
role: request.role,
|
|
140
|
+
globalRoles: request.globalRoles,
|
|
141
|
+
expiresAt: expiresAt.toString(),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function assertAppDefinedAdditionalRoles(opts) {
|
|
48
146
|
const rankedRole = findRankedRole(opts.additionalAssignableRoles ?? []);
|
|
49
147
|
if (rankedRole !== undefined) {
|
|
50
148
|
throw new Error(`[auth-email-password] invite.additionalAssignableRoles must list app-defined roles only; "${rankedRole}" is a framework-ranked role`);
|
|
51
149
|
}
|
|
52
|
-
|
|
53
|
-
|
|
150
|
+
}
|
|
151
|
+
export function createInviteCreateHandler(opts) {
|
|
152
|
+
assertAppDefinedAdditionalRoles(opts);
|
|
153
|
+
const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
|
|
54
154
|
return defineWriteHandler({
|
|
55
155
|
name: "invite-create",
|
|
56
156
|
schema: InviteCreateSchema,
|
|
57
157
|
access: { roles: access.admin },
|
|
58
158
|
description: "Invites an email address into the caller's tenant with a chosen membership role, creating or reusing the invitation and mailing the invitee a fresh accept link; re-inviting the same address invalidates the previous link.",
|
|
59
159
|
handler: async (event, ctx) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
91
|
-
token = generateToken();
|
|
92
|
-
const updateResult = await executor.update({
|
|
93
|
-
id: invitationId,
|
|
94
|
-
version: existingVersion,
|
|
95
|
-
changes: {
|
|
96
|
-
role: event.payload.role,
|
|
97
|
-
status: INVITATION_STATUS.pending,
|
|
98
|
-
invitedBy: event.user.id,
|
|
99
|
-
expiresAt,
|
|
100
|
-
},
|
|
101
|
-
}, event.user, ctx.db);
|
|
102
|
-
if (!updateResult.isSuccess)
|
|
103
|
-
return updateResult;
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
const createResult = await executor.create({
|
|
107
|
-
email,
|
|
108
|
-
role: event.payload.role,
|
|
109
|
-
status: INVITATION_STATUS.pending,
|
|
110
|
-
invitedBy: event.user.id,
|
|
111
|
-
expiresAt,
|
|
112
|
-
}, event.user, ctx.db);
|
|
113
|
-
if (!createResult.isSuccess)
|
|
114
|
-
return createResult;
|
|
115
|
-
invitationId = createResult.data.id; // @cast-boundary engine-payload
|
|
116
|
-
token = generateToken();
|
|
117
|
-
}
|
|
118
|
-
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
119
|
-
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
120
|
-
await dispatchMagicLinkMail(ctx.notify, {
|
|
121
|
-
handlerName: "invite-create",
|
|
122
|
-
notificationType: INVITE_NOTIFICATION_TYPE,
|
|
123
|
-
renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: event.payload.role }),
|
|
124
|
-
}, {
|
|
125
|
-
email,
|
|
126
|
-
appUrl: opts.appUrl,
|
|
127
|
-
token,
|
|
128
|
-
expiresAt: expiresAt.toString(),
|
|
129
|
-
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
130
|
-
locale,
|
|
131
|
-
});
|
|
132
|
-
return {
|
|
133
|
-
isSuccess: true,
|
|
134
|
-
data: {
|
|
135
|
-
kind: "invite-created",
|
|
136
|
-
invitationId,
|
|
137
|
-
tenantId,
|
|
138
|
-
email,
|
|
139
|
-
role: event.payload.role,
|
|
140
|
-
expiresAt: expiresAt.toString(),
|
|
141
|
-
},
|
|
142
|
-
};
|
|
160
|
+
const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
|
|
161
|
+
if (roleRejection)
|
|
162
|
+
return roleRejection;
|
|
163
|
+
return issueInvitation(opts, ttlSeconds, { email: event.payload.email, role: event.payload.role, globalRoles: [] }, event.user, ctx);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const SystemInviteCreateSchema = z.object({
|
|
168
|
+
email: z.email(),
|
|
169
|
+
role: z.string().min(1).max(50),
|
|
170
|
+
globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
|
|
171
|
+
});
|
|
172
|
+
// System-only sibling of invite-create: the only path that can put global
|
|
173
|
+
// roles (SystemAdmin) on an invitation. No session mint path yields the
|
|
174
|
+
// "system" role (users.roles only accepts GLOBAL_ROLE_OPTIONS), so only
|
|
175
|
+
// in-process callers dispatching as createSystemUser (runBootstrap) get here.
|
|
176
|
+
export function createSystemInviteCreateHandler(opts) {
|
|
177
|
+
assertAppDefinedAdditionalRoles(opts);
|
|
178
|
+
const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
|
|
179
|
+
return defineWriteHandler({
|
|
180
|
+
name: "system-invite-create",
|
|
181
|
+
schema: SystemInviteCreateSchema,
|
|
182
|
+
access: { roles: access.system },
|
|
183
|
+
agent: { expose: false },
|
|
184
|
+
description: "System-only invite into the dispatching tenant that may additionally carry global roles, which the invitee receives only when accepting; used by provisioning such as runBootstrap.",
|
|
185
|
+
handler: async (event, ctx) => {
|
|
186
|
+
const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
|
|
187
|
+
if (roleRejection)
|
|
188
|
+
return roleRejection;
|
|
189
|
+
return issueInvitation(opts, ttlSeconds, event.payload, event.user, ctx, true);
|
|
143
190
|
},
|
|
144
191
|
});
|
|
145
192
|
}
|