diff --git a/.changeset/27843-invite-link-only-outcome.md b/.changeset/27843-invite-link-only-outcome.md new file mode 100644 index 00000000000..fd9766241fa --- /dev/null +++ b/.changeset/27843-invite-link-only-outcome.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +feat(plugin-auth): an invite says whether its mail was delivered, and a pending invitation's link can be copied from Setup → Invitations + +Clause-②: yes (widening) +Metadata-protocol: none + +On a deployment that does not deliver mail (the stock `log` transport, or no email service at all), inviting someone used to toast "Invitation sent to …" while nothing left the server, and nothing offered the accept link, so the invitee never arrived. + +- `POST /api/v1/auth/organization/invite-member` (invite and resend) now answers a top-level `outcome` beside the invitation row: `sent` when the deployment delivers mail, `link_only` when it does not. It is decided at call time by the same rule sign-up uses, the email service's own `isConfigured()` answer, so a wired `log` transport is not delivery. The row's columns are unchanged. +- The four `invite_user` actions (Users, Members, Invitations, the organization page) and `resend_invitation` declare `outcomeMessages`: the `link_only` message says the invitation was created and no email went out, and points to Copy Invite Link. `successMessage` stays the fallback. +- New action `sys_invitation.copy_invitation_link` (Copy Invite Link, on the invitation row and record header): answers `{ acceptUrl }`, the same link the invitation mail carries, in a copyable result dialog. The link admits whoever opens it, so the server answers it only to an owner, admin or delegated admin of the invitation's organization, only for a pending invitation, and never while the environment is cloud-managed (`409 RESOURCE_CONFLICT`); it is never logged. +- `PUBLIC_AUTH_FEATURES` books the new action under the `organization` and `cloudManaged` gates. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index cf928795d85..c1248a40c1e 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -140,6 +140,10 @@ export const enObjects: NonNullable = { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", + outcomeMessages: { + sent: "Invitation sent to ${result.email}", + link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + }, params: { role: { options: { @@ -577,6 +581,10 @@ export const enObjects: NonNullable = { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", + outcomeMessages: { + sent: "Invitation sent to ${result.email}", + link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + }, params: { role: { options: { @@ -664,6 +672,10 @@ export const enObjects: NonNullable = { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", + outcomeMessages: { + sent: "Invitation sent to ${result.email}", + link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + }, params: { role: { options: { @@ -794,6 +806,10 @@ export const enObjects: NonNullable = { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", + outcomeMessages: { + sent: "Invitation sent to ${result.email}", + link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + }, params: { role: { options: { @@ -813,7 +829,22 @@ export const enObjects: NonNullable = { }, resend_invitation: { label: "Resend Invitation", - successMessage: "Invitation resent" + successMessage: "Invitation resent", + outcomeMessages: { + sent: "Invitation resent to ${result.email}", + link_only: "Invitation renewed for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + } + }, + copy_invitation_link: { + label: "Copy Invite Link", + resultDialog: { + title: "Invite link", + description: "Anyone who opens this link can accept the invitation, so share it only with the person you invited. It stops working once the invitation is accepted, declined, canceled or expires.", + acknowledge: "Done", + fields: { + acceptUrl: "Invite link" + } + } }, accept_invitation: { label: "Accept Invitation", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 4d6b0ffe6ba..7d60c1cfed4 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -140,6 +140,10 @@ export const esESObjects: NonNullable = { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", + outcomeMessages: { + sent: "Invitación enviada a ${result.email}", + link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + }, params: { role: { options: { @@ -577,6 +581,10 @@ export const esESObjects: NonNullable = { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", + outcomeMessages: { + sent: "Invitación enviada a ${result.email}", + link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + }, params: { role: { options: { @@ -664,6 +672,10 @@ export const esESObjects: NonNullable = { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", + outcomeMessages: { + sent: "Invitación enviada a ${result.email}", + link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + }, params: { role: { options: { @@ -794,6 +806,10 @@ export const esESObjects: NonNullable = { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", + outcomeMessages: { + sent: "Invitación enviada a ${result.email}", + link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + }, params: { role: { options: { @@ -813,7 +829,22 @@ export const esESObjects: NonNullable = { }, resend_invitation: { label: "Reenviar invitación", - successMessage: "Invitación reenviada" + successMessage: "Invitación reenviada", + outcomeMessages: { + sent: "Invitación reenviada a ${result.email}", + link_only: "Invitación renovada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + } + }, + copy_invitation_link: { + label: "Copiar enlace de invitación", + resultDialog: { + title: "Enlace de invitación", + description: "Cualquiera que abra este enlace puede aceptar la invitación, así que compártelo solo con la persona que invitaste. Deja de funcionar cuando la invitación se acepta, se rechaza, se cancela o caduca.", + acknowledge: "Hecho", + fields: { + acceptUrl: "Enlace de invitación" + } + } }, accept_invitation: { label: "Aceptar invitación", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 5fec25ff5ae..6c66a9d97f0 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -140,6 +140,10 @@ export const jaJPObjects: NonNullable = { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", + outcomeMessages: { + sent: "${result.email} に招待を送信しました", + link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + }, params: { role: { options: { @@ -577,6 +581,10 @@ export const jaJPObjects: NonNullable = { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", + outcomeMessages: { + sent: "${result.email} に招待を送信しました", + link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + }, params: { role: { options: { @@ -664,6 +672,10 @@ export const jaJPObjects: NonNullable = { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", + outcomeMessages: { + sent: "${result.email} に招待を送信しました", + link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + }, params: { role: { options: { @@ -794,6 +806,10 @@ export const jaJPObjects: NonNullable = { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", + outcomeMessages: { + sent: "${result.email} に招待を送信しました", + link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + }, params: { role: { options: { @@ -813,7 +829,22 @@ export const jaJPObjects: NonNullable = { }, resend_invitation: { label: "招待を再送", - successMessage: "招待を再送しました" + successMessage: "招待を再送しました", + outcomeMessages: { + sent: "${result.email} に招待を再送しました", + link_only: "${result.email} の招待を更新しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + } + }, + copy_invitation_link: { + label: "招待リンクをコピー", + resultDialog: { + title: "招待リンク", + description: "このリンクを開いた人は誰でも招待を承諾できるため、招待した本人にのみ共有してください。招待が承諾、辞退、キャンセルされるか、期限が切れると、リンクは使えなくなります。", + acknowledge: "完了", + fields: { + acceptUrl: "招待リンク" + } + } }, accept_invitation: { label: "招待を受諾", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 54ca49c0863..3b7e6359a9e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -140,6 +140,10 @@ export const zhCNObjects: NonNullable = { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", + outcomeMessages: { + sent: "已向 ${result.email} 发送邀请", + link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + }, params: { role: { options: { @@ -577,6 +581,10 @@ export const zhCNObjects: NonNullable = { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", + outcomeMessages: { + sent: "已向 ${result.email} 发送邀请", + link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + }, params: { role: { options: { @@ -664,6 +672,10 @@ export const zhCNObjects: NonNullable = { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", + outcomeMessages: { + sent: "已向 ${result.email} 发送邀请", + link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + }, params: { role: { options: { @@ -794,6 +806,10 @@ export const zhCNObjects: NonNullable = { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", + outcomeMessages: { + sent: "已向 ${result.email} 发送邀请", + link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + }, params: { role: { options: { @@ -813,7 +829,22 @@ export const zhCNObjects: NonNullable = { }, resend_invitation: { label: "重新发送邀请", - successMessage: "邀请已重新发送" + successMessage: "邀请已重新发送", + outcomeMessages: { + sent: "已向 ${result.email} 重新发送邀请", + link_only: "已为 ${result.email} 续期邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + } + }, + copy_invitation_link: { + label: "复制邀请链接", + resultDialog: { + title: "邀请链接", + description: "任何打开此链接的人都可以接受该邀请,请只分享给您邀请的人。邀请被接受、拒绝、取消或过期后,链接即失效。", + acknowledge: "完成", + fields: { + acceptUrl: "邀请链接" + } + } }, accept_invitation: { label: "接受邀请", diff --git a/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts b/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts new file mode 100644 index 00000000000..f060c234c69 --- /dev/null +++ b/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #27843 (ruling E on #27797) — the metadata half of "an invite on a server +// that does not deliver mail": +// +// - the four `invite_user` mirrors and `resend_invitation` declare +// `outcomeMessages` for the delivery `outcome` invite-member answers +// (plugin-auth `invitation-delivery-outcome.ts`), and the `link_only` copy +// never says the invitation was sent; +// - `sys_invitation.copy_invitation_link` is declared for plugin-auth's +// handler (`invitation-link-action.ts`): a `script` action bound by +// `target`, its answer shown in a copyable text result dialog, offered on a +// pending row to the grades that reach invite-member and hidden while the +// environment is cloud-managed; +// - the translated bundles carry a translation of every new leaf, never the +// English source `--fill=default` seeds a new key with. +// +// The `visible` predicate is EVALUATED through the canonical engine the +// console delegates to (`@objectstack/formula`) on the PARSED object, so the +// lowered `requiresFeature` / `requiresMembershipReach` envelope is what is +// judged (the shape `cloud-managed-visibility.test.ts` uses). + +import { describe, expect, it } from 'vitest'; +import { celEngine } from '@objectstack/formula'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { INVITE_USER_OUTCOME_MESSAGES, RESEND_INVITATION_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; +import { SysInvitation } from './sys-invitation.object.js'; +import { SysMember } from './sys-member.object.js'; +import { SysOrganization } from './sys-organization.object.js'; +import { SysUser } from './sys-user.object.js'; + +type Action = Record; + +function action(object: unknown, name: string): Action { + const found = ((object as { actions?: Action[] }).actions ?? []).find((a) => a.name === name); + if (!found) throw new Error(`no action ${name}`); + return found; +} + +const MIRRORS: Array<[string, unknown]> = [ + ['sys_user', SysUser], + ['sys_invitation', SysInvitation], + ['sys_member', SysMember], + ['sys_organization', SysOrganization], +]; + +const TRANSLATED = [ + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +] as const; + +/** Any spelling of "sent" a `link_only` copy must not carry. */ +const SAYS_SENT = /\b(re)?sent\b/i; + +/** The interpolation `outcomeMessages` copy reads the invitee's address through. */ +const INVITEE = '${result.email}'; + +const bundleAction = (bundle: unknown, object: string, name: string): Action | undefined => + (bundle as Record }>)?.[object]?._actions?.[name]; + +describe('the invite actions answer by delivery outcome', () => { + it.each(MIRRORS)('%s.invite_user declares the copy for both outcomes', (_name, object) => { + expect(action(object, 'invite_user').outcomeMessages).toEqual(INVITE_USER_OUTCOME_MESSAGES); + }); + + it('sys_invitation.resend_invitation declares the copy for both outcomes', () => { + expect(action(SysInvitation, 'resend_invitation').outcomeMessages).toEqual(RESEND_INVITATION_OUTCOME_MESSAGES); + }); + + it.each([ + ['invite_user', INVITE_USER_OUTCOME_MESSAGES], + ['resend_invitation', RESEND_INVITATION_OUTCOME_MESSAGES], + ] as const)('%s: the link_only copy never says sent; both name the invitee', (_name, copy) => { + expect(copy.link_only).not.toMatch(SAYS_SENT); + expect(copy.sent).toMatch(SAYS_SENT); + expect(copy.sent).toContain(INVITEE); + expect(copy.link_only).toContain(INVITEE); + }); + + it.each(MIRRORS)('%s.invite_user keeps its successMessage as the fallback', (_name, object) => { + expect(action(object, 'invite_user').successMessage).toBeDefined(); + }); +}); + +describe('sys_invitation.copy_invitation_link — the declaration', () => { + const copy = action(SysInvitation, 'copy_invitation_link'); + + it('is a script action bound to its handler by target, on the row menu and the record header', () => { + expect(copy.type).toBe('script'); + expect(copy.target).toBe('copy_invitation_link'); + expect(copy.locations).toEqual(['list_item', 'record_header']); + }); + + it('shows the answer in a copyable text dialog, the acceptUrl field', () => { + expect(copy.resultDialog.format).toBe('text'); + expect(copy.resultDialog.fields).toEqual([{ path: 'acceptUrl', label: 'Invite link' }]); + expect(copy.resultDialog.acknowledge).toBeTruthy(); + }); +}); + +// ── (d) the gates, evaluated ───────────────────────────────────────────────── + +const user = (positions: string[]) => ({ id: 'u1', email: 'a@example.com', positions }); +const OWNER = user(['org_owner']); +const ADMIN = user(['org_admin']); +const DELEGATE = user(['delegated_admin']); +const MEMBER = user(['org_member']); +const ON = { organization: true, cloudManaged: false }; + +function offered(record: Record, current: unknown, features: Record): boolean | string { + const raw = action(SysInvitation, 'copy_invitation_link').visible; + const source = typeof raw === 'string' ? raw : raw?.source; + const r = celEngine.evaluate( + { dialect: 'cel', source }, + { record, extra: { current_user: current, user: current, ctx: { user: current }, features } }, + ); + if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`; + return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`; +} + +describe('sys_invitation.copy_invitation_link — who is offered it', () => { + const pending = { id: 'inv_1', status: 'pending' }; + + it.each([ + ['owner', OWNER], + ['admin', ADMIN], + ['delegated_admin', DELEGATE], + ])('is offered on a pending row to an %s', (_grade, current) => { + expect(offered(pending, current, ON)).toBe(true); + }); + + it('is not offered to a plain member (outside the invite reach)', () => { + expect(offered(pending, MEMBER, ON)).toBe(false); + }); + + it.each(['accepted', 'rejected', 'canceled', 'expired'])('is not offered on a %s row', (status) => { + expect(offered({ id: 'inv_1', status }, OWNER, ON)).toBe(false); + }); + + it('is not offered on a row projected without its status (sparse list face)', () => { + expect(offered({ id: 'inv_1' }, OWNER, ON)).toBe(false); + }); + + it('is hidden while the environment is cloud-managed', () => { + expect(offered(pending, OWNER, { organization: true, cloudManaged: true })).toBe(false); + }); + + it('is hidden when the organization capability is off', () => { + expect(offered(pending, OWNER, { organization: false, cloudManaged: false })).toBe(false); + }); +}); + +describe('the new copy in the translated bundles', () => { + const leaves = (bundle: unknown) => { + const invite = (object: string) => bundleAction(bundle, object, 'invite_user')?.outcomeMessages; + const resend = bundleAction(bundle, 'sys_invitation', 'resend_invitation')?.outcomeMessages; + const copy = bundleAction(bundle, 'sys_invitation', 'copy_invitation_link'); + return [ + ...MIRRORS.flatMap(([name]) => [ + [`${name}.invite_user.outcomeMessages.sent`, invite(name)?.sent], + [`${name}.invite_user.outcomeMessages.link_only`, invite(name)?.link_only], + ]), + ['resend_invitation.outcomeMessages.sent', resend?.sent], + ['resend_invitation.outcomeMessages.link_only', resend?.link_only], + ['copy_invitation_link.label', copy?.label], + ['copy_invitation_link.resultDialog.title', copy?.resultDialog?.title], + ['copy_invitation_link.resultDialog.description', copy?.resultDialog?.description], + ['copy_invitation_link.resultDialog.acknowledge', copy?.resultDialog?.acknowledge], + ['copy_invitation_link.resultDialog.fields.acceptUrl', copy?.resultDialog?.fields?.acceptUrl], + ] as Array<[string, unknown]>; + }; + const english = new Map(leaves(enObjects)); + + it('en carries every new leaf', () => { + for (const [path, value] of english) expect(value, path).toBeTruthy(); + }); + + for (const [locale, bundle] of TRANSLATED) { + it(`${locale} translates every new leaf, keeping the invitee token`, () => { + for (const [path, value] of leaves(bundle)) { + expect(value, `${locale} ${path} missing`).toBeTruthy(); + expect(value, `${locale} ${path} is the English source`).not.toBe(english.get(path)); + if (path.includes('outcomeMessages')) expect(value, `${locale} ${path}`).toContain(INVITEE); + } + }); + } +}); diff --git a/packages/platform-objects/src/identity/invite-outcome-messages.ts b/packages/platform-objects/src/identity/invite-outcome-messages.ts new file mode 100644 index 00000000000..d481fc3a3cf --- /dev/null +++ b/packages/platform-objects/src/identity/invite-outcome-messages.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27843, ruling E on #27797] The success copy of the invite actions, per the + * delivery `outcome` invite-member answers beside the invitation row (plugin-auth + * `invitation-delivery-outcome.ts`): `sent` when the deployment delivers mail, + * `link_only` when it does not (the `log` transport, or no email service), and + * only the invitation's accept link can reach the invitee. + * + * One spelling for the four `invite_user` mirrors (sys_user, sys_member, + * sys_invitation, sys_organization), so one translation serves each, and one + * for `sys_invitation.resend_invitation`. ⛔ A `link_only` message never says + * the invitation was sent: nothing left the server. + * + * `${result.email}` is the `outcomeMessages` interpolation (the scope + * `onSuccess.navigate` declares), the invitee's address on the row. + */ + +/** `invite_user.outcomeMessages`, on all four mirrors. */ +export const INVITE_USER_OUTCOME_MESSAGES = { + sent: 'Invitation sent to ${result.email}', + link_only: + 'Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. ' + + 'Use Copy Invite Link on the invitation to share it.', +} as const; + +/** `sys_invitation.resend_invitation.outcomeMessages`. */ +export const RESEND_INVITATION_OUTCOME_MESSAGES = { + sent: 'Invitation resent to ${result.email}', + link_only: + 'Invitation renewed for ${result.email}. Mail is not configured on this server, so no email went out. ' + + 'Use Copy Invite Link on the invitation to share it.', +} as const; diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 1f7ce92850a..f7d646d1879 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -8,6 +8,7 @@ import { InvitationStatus, MEMBERSHIP_ROLE_MEMBER, } from '@objectstack/spec/identity'; +import { INVITE_USER_OUTCOME_MESSAGES, RESEND_INVITATION_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; /** * sys_invitation — System Invitation Object @@ -76,6 +77,10 @@ export const SysInvitation = ObjectSchema.create({ // `{{ result.email }}`: the invitee's address from the invitation row // invite-member answers (see sys_user's mirror). successMessage: 'Invitation sent to {{ result.email }}', + // [#27843] The copy per delivery outcome invite-member answers: `sent`, + // or `link_only` when this deployment does not deliver mail. The + // `successMessage` above stays the fallback for an answer without one. + outcomeMessages: { ...INVITE_USER_OUTCOME_MESSAGES }, refreshAfter: true, params: [ { field: 'email', required: true }, @@ -118,12 +123,47 @@ export const SysInvitation = ObjectSchema.create({ // that reach that endpoint — delegated_admin included. requiresMembershipReach: 'invite_member', successMessage: 'Invitation resent', + // [#27843] A resend answers the same delivery `outcome` as an invite. + outcomeMessages: { ...RESEND_INVITATION_OUTCOME_MESSAGES }, refreshAfter: true, params: [ { field: 'email', required: true, defaultFromRow: true }, { field: 'role', required: true, defaultFromRow: true }, ], }, + { + // [#27843, ruling E on #27797] The pending invitation's accept link, for + // a deployment whose mail is not delivered (invite-member answers + // `outcome: 'link_only'` there): the one way the invitee is reached. + // The handler is plugin-auth's (`invitation-link-action.ts`), registered + // under `target`; it builds the link the invitation mail carries, and + // enforces this action's gates itself, because the link is a bearer + // credential and `visible` is courtesy. + name: 'copy_invitation_link', + label: 'Copy Invite Link', + icon: 'link', + variant: 'secondary', + locations: ['list_item', 'record_header'], + type: 'script', + target: 'copy_invitation_link', + // The gates of `resend_invitation` (the organization capability and the + // grades that reach invite-member), hidden while cloud-managed as the + // invite mirrors are, and offered on a pending row only. `has()` opens + // the status operand for the sparse `list_item` face (#8990), as on the + // recipient-side actions below. + visible: "features.cloudManaged != true && has(record.status) && record.status == 'pending'", + requiresFeature: 'organization', + requiresMembershipReach: 'invite_member', + resultDialog: { + title: 'Invite link', + description: + 'Anyone who opens this link can accept the invitation, so share it only with the person you invited. ' + + 'It stops working once the invitation is accepted, declined, canceled or expires.', + format: 'text', + fields: [{ path: 'acceptUrl', label: 'Invite link' }], + acknowledge: 'Done', + }, + }, // ── Recipient-side actions (the invited user) ──────────────────── // diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 4dd3b62041e..920dd2d0681 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -7,6 +7,7 @@ import { INVITATION_ROLE_OPTIONS, MEMBERSHIP_ROLE_MEMBER, } from '@objectstack/spec/identity'; +import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; /** * sys_member — System Member Object @@ -113,6 +114,10 @@ export const SysMember = ObjectSchema.create({ // `{{ result.email }}`: the invitee's address from the invitation row // invite-member answers (see sys_user's mirror). successMessage: 'Invitation sent to {{ result.email }}', + // [#27843] The copy per delivery outcome invite-member answers: `sent`, + // or `link_only` when this deployment does not deliver mail. The + // `successMessage` above stays the fallback for an answer without one. + outcomeMessages: { ...INVITE_USER_OUTCOME_MESSAGES }, refreshAfter: true, params: [ { field: 'email', objectOverride: 'sys_invitation', required: true }, diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 927142e3f43..87832048e47 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -2,6 +2,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { INVITATION_ROLE_OPTIONS } from '@objectstack/spec/identity'; +import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; /** * sys_organization — System Organization Object @@ -180,6 +181,10 @@ export const SysOrganization = ObjectSchema.create({ // `{{ result.email }}`: the invitee's address from the invitation row // invite-member answers (see sys_user's mirror). successMessage: 'Invitation sent to {{ result.email }}', + // [#27843] The copy per delivery outcome invite-member answers: `sent`, + // or `link_only` when this deployment does not deliver mail. The + // `successMessage` above stays the fallback for an answer without one. + outcomeMessages: { ...INVITE_USER_OUTCOME_MESSAGES }, refreshAfter: true, params: [ // ⚠️ Neither `email` nor `role` is a field of sys_organization, so diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 67491e481b6..b6157e34321 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -2,6 +2,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { INVITATION_ROLE_OPTIONS } from '@objectstack/spec/identity'; +import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; /** * The BCP-47 shape `sys_user.locale` accepts — a 2–3 letter language subtag @@ -115,6 +116,10 @@ export const SysUser = ObjectSchema.create({ // `{{ result.email }}`: invite-member answers the bare invitation row, // whose top-level `email` is the invitee's address as stored (lowercased). successMessage: 'Invitation sent to {{ result.email }}', + // [#27843] The copy per delivery outcome invite-member answers: `sent`, + // or `link_only` when this deployment does not deliver mail. The + // `successMessage` above stays the fallback for an answer without one. + outcomeMessages: { ...INVITE_USER_OUTCOME_MESSAGES }, refreshAfter: true, params: [ { field: 'email', required: true }, diff --git a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts index 492eb97df58..8c20449115d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts @@ -44,6 +44,7 @@ import type { AuthManager } from './auth-manager.js'; import { SIGN_IN_EMAIL_PATH } from './auth-session-audit.js'; import { narrowDirectoryForExternalCaller } from './external-directory-door.js'; import { rotateCallerBearerOnImpersonation } from './impersonation-bearer-rotation.js'; +import { stampInvitationDeliveryOutcome } from './invitation-delivery-outcome.js'; import { codeInviteRoleNotFound } from './invite-role-not-found-code.js'; import { readRequestClientAddress } from './request-client-address.js'; import { attachSessionToCredentialResponse } from './session-envelope-completion.js'; @@ -60,6 +61,7 @@ export interface AfterHookHost { auditRefusedSignIn: AuthManager['auditRefusedSignIn']; enforceConcurrentCap: AuthManager['enforceConcurrentCap']; externalDirectoryDoorDeps: AuthManager['externalDirectoryDoorDeps']; + isEmailDeliveryLive: AuthManager['isEmailDeliveryLive']; recordPasswordHistory: AuthManager['recordPasswordHistory']; recordSignInOutcome: AuthManager['recordSignInOutcome']; sendChangeEmailNotice: AuthManager['sendChangeEmailNotice']; @@ -224,6 +226,12 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) { // `external-directory-door.ts`. await narrowDirectoryForExternalCaller(ctx, this.externalDirectoryDoorDeps()); + // ── #27843: invite-member says whether its mail was delivered ── + // A success answers `outcome: 'sent' | 'link_only'` beside the + // invitation row, by the one deliverability rule sign-up uses (see + // `invitation-delivery-outcome.ts`). A refusal passes untouched. + await stampInvitationDeliveryOutcome(ctx, () => this.isEmailDeliveryLive()); + // ── #24750: invite-member's unknown-role refusal carries its code ── // better-auth throws it with only a message; the spec promises // `ROLE_NOT_FOUND` (see `invite-role-not-found-code.ts`). It throws diff --git a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts index 3724625b7e1..214359a7340 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts @@ -107,6 +107,7 @@ export interface AuthInstanceHost { implicitLinkRequiresLocalEmailVerified: AuthManager['implicitLinkRequiresLocalEmailVerified']; isAuthGateActive: AuthManager['isAuthGateActive']; isBootstrapCreation: AuthManager['isBootstrapCreation']; + isEmailDeliveryLive: AuthManager['isEmailDeliveryLive']; isOperatorProvisioning: AuthManager['isOperatorProvisioning']; isPlatformAdminUserId: AuthManager['isPlatformAdminUserId']; isSsoWired: AuthManager['isSsoWired']; @@ -716,6 +717,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise this.auditRefusedSignIn(...a), enforceConcurrentCap: (...a) => this.enforceConcurrentCap(...a), externalDirectoryDoorDeps: (...a) => this.externalDirectoryDoorDeps(...a), + isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a), recordPasswordHistory: (...a) => this.recordPasswordHistory(...a), recordSignInOutcome: (...a) => this.recordSignInOutcome(...a), sendChangeEmailNotice: (...a) => this.sendChangeEmailNotice(...a), diff --git a/packages/plugins/plugin-auth/src/auth-manager-organization-plugin.ts b/packages/plugins/plugin-auth/src/auth-manager-organization-plugin.ts index a92daab5258..7d5002305b2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-organization-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-organization-plugin.ts @@ -79,6 +79,7 @@ import { } from './auth-manager.js'; import { buildOrganizationPluginSchema } from './auth-schema-config.js'; import { CLOUD_MANAGED_REFUSAL_CODE, CLOUD_MANAGED_REFUSAL_MESSAGES } from './cloud-managed.js'; +import { invitationAcceptPath } from './invitation-link-action.js'; import { invitationRoleCapFailure, isPlainMemberInvitation } from './invitation-role-cap.js'; import { invitationRoleLabel } from './invitation-role-label.js'; import { applyDeclaredInvitationVerificationToListing } from './list-user-invitations-verification.js'; @@ -537,12 +538,12 @@ export async function buildOrganizationPlugin(this: OrganizationPluginHost) { // operators / UI can fall back to copy-paste flows. Replace this // with a real mail integration when available. sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => { - // The accept-invitation page is a Console SPA route; the Console - // builds the very same link for its "copy invitation link" action - // (`${origin}${BASE_URL}accept-invitation/`). Compose it through + // The accept-invitation page is a Console SPA route. Compose it through // the single-source helper so the `/_console` prefix and absolute - // https origin are guaranteed. - const acceptUrl = this.getConsolePageUrl(`/accept-invitation/${invitation.id}`); + // https origin are guaranteed. [#27843] `sys_invitation.copy_invitation_link` + // answers this very link, built from the same path and composer + // (`invitation-link-action.ts`). + const acceptUrl = this.getConsolePageUrl(invitationAcceptPath(String(invitation.id))); // #2766 V1.5 — placeholder addresses are never real recipients. if (isPlaceholderEmail(recipientEmail)) { throw new Error( diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 1e4bfb88af0..2175fb83308 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1690,6 +1690,7 @@ export class AuthManager { implicitLinkRequiresLocalEmailVerified: (...a) => this.implicitLinkRequiresLocalEmailVerified(...a), isAuthGateActive: (...a) => this.isAuthGateActive(...a), isBootstrapCreation: (...a) => this.isBootstrapCreation(...a), + isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a), isOperatorProvisioning: (...a) => this.isOperatorProvisioning(...a), isPlatformAdminUserId: (...a) => this.isPlatformAdminUserId(...a), isSsoWired: (...a) => this.isSsoWired(...a), @@ -2694,7 +2695,9 @@ export class AuthManager { * the contract gives the package that owns the transport (plugin-email * answers `false` for its `log` transport in every environment, and follows a * transport swapped from the mail settings at runtime). Read at call time, - * never cached at boot. + * never cached at boot. [#27843] invite-member's `outcome` asks it too + * (`invitation-delivery-outcome.ts`), so an invite answers `link_only` on + * exactly the deployments where sign-up would not mail either. * * ⛔ An implementation WITHOUT the member is NOT live: the contract says an * absent member is unknown, and the flow asking — a success answer that only diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 2e5015a5e52..020b425ef2a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -112,6 +112,7 @@ import { type DemoStaffRefusal, } from './demo-staff.js'; import { cloudManagedRefusal } from './cloud-managed.js'; +import { registerCopyInvitationLinkAction } from './invitation-link-action.js'; import { runAdminBanUser, runAdminUnbanUser, @@ -1015,6 +1016,25 @@ export class AuthPlugin implements Plugin { // No `objectql` service (mock mode): nothing to register on. } + // [#27843] `sys_invitation.copy_invitation_link` — the pending + // invitation's accept link, for a deployment whose mail is not delivered + // (invite-member answers `outcome: 'link_only'` there). Registered in + // `start()` beside the hook above, on the same engine: the platform action + // door dispatches it like any other declared `script` action, and the + // handler enforces the invite gates itself (`invitation-link-action.ts`). + try { + const ql = ctx.getService('objectql'); + const authManager = this.authManager; + if (ql && typeof ql.registerAction === 'function') { + registerCopyInvitationLinkAction(ql, { + engine: ql, + consoleUrl: (path) => authManager.getConsoleUrl(path), + }); + } + } catch { + // No `objectql` service (mock mode): nothing to register on. + } + // [#8009] Move any provider row still holding a CLEARTEXT OIDC client // secret in `oidc_config` into the encrypted column. Registered // unconditionally (not under `registerRoutes`) because the disposition of diff --git a/packages/plugins/plugin-auth/src/cloud-managed.ts b/packages/plugins/plugin-auth/src/cloud-managed.ts index 4dd34530cac..12853f23794 100644 --- a/packages/plugins/plugin-auth/src/cloud-managed.ts +++ b/packages/plugins/plugin-auth/src/cloud-managed.ts @@ -39,6 +39,12 @@ export const CLOUD_MANAGED_REFUSAL_MESSAGES = { 'This environment is cloud-managed: the people who use it are invited by the organisation that hosts it, in the cloud, ' + 'so an invitation cannot be created here. Invite them from the organisation in the cloud console; their account ' + 'arrives on their first sign-in. No invitation was created.', + // [#27843] `sys_invitation.copy_invitation_link`: the invitation's accept + // link, which the hosting organisation's cloud invitation replaces here. + invitationLink: + 'This environment is cloud-managed: the people who use it are invited by the organisation that hosts it, in the cloud, ' + + 'so an invitation link is not handed out here. Invite them from the organisation in the cloud console; their account ' + + 'arrives on their first sign-in. No link was returned.', createUser: 'This environment is cloud-managed: its users are invited by the organisation that hosts it, in the cloud, so a ' + 'local user cannot be created here. Invite the person from the organisation in the cloud console; their account ' diff --git a/packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts b/packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts new file mode 100644 index 00000000000..025ff4b87fc --- /dev/null +++ b/packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts @@ -0,0 +1,397 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #27843 (ruling E on #27797) — an invite on a deployment that does not +// deliver mail, the server half. The card's four pins: +// +// (a) invite-member (invite and resend) answers `outcome: 'link_only'` when +// the transport does not deliver (no email service, or the `log` +// transport's `isConfigured() === false`) and `outcome: 'sent'` when it +// does, read at call time, beside the invitation row; a refusal carries +// none. The declared `link_only` copy saying nothing about "sent" is +// pinned beside the metadata (platform-objects +// `invitation-delivery-copy.test.ts`). +// (b) `sys_invitation.copy_invitation_link` answers the very URL the +// invitation mail carries for the same invitation, the canonical origin +// and the Console mount `uiBasePath` included. +// (c) the action is refused for a caller outside the invite reach and for a +// non-pending row. +// (d) the action is refused while the environment is cloud-managed (the +// declaration hides it there too, pinned in platform-objects). +// +// Every invitation is issued, resent, canceled and accepted through the +// vendor's own routes on a real `AuthManager` over the installed better-auth, +// so the rows the handler reads are the rows the product writes. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import { resetCloudManagedForTesting } from '@objectstack/core'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +import { CLOUD_MANAGED_REFUSAL_MESSAGES } from './cloud-managed'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import { + COPY_INVITATION_LINK_ACTION, + INVITATION_OBJECT, + createCopyInvitationLinkHandler, +} from './invitation-link-action'; +import { AUTH_PLUGIN_ID } from './manifest'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-27843'; +// A canonical origin and a Console mount other than the defaults, so the +// link's two composed halves are both measured, not assumed. +const ORIGIN = 'https://crm.acme.test'; +const UI_BASE = '/console'; +const BASE = '/api/v1/auth'; +const DEFAULT_ORG = 'org_default'; +const ACME_ORG = 'org_acme'; +const MANAGED_ENV = 'OS_CLOUD_MANAGED'; + +/** + * A recording email service. `live` is what `isConfigured()` answers, read on + * every call so a leg can swap it between requests; `'absent'` leaves the + * member off. + */ +function recordingEmail(initial: boolean | 'absent') { + const sent: any[] = []; + const state = { live: initial }; + const service: Record = { + async send() { return { id: 'e', status: 'sent' }; }, + async sendTemplate(input: any) { + sent.push(input); + return { id: `e${sent.length}`, status: 'sent' }; + }, + }; + if (initial !== 'absent') service.isConfigured = () => state.live === true; + return { sent, state, service }; +} + +const singleOrgTenancy = () => + ({ + posture: 'single', + requestedPosture: 'single', + isolationActive: false, + requested: false, + degraded: false, + defaultOrgId: async () => DEFAULT_ORG, + }) as any; + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +/** + * One deployment: `Alice Owner` owns `Acme Corp`; Dana is its delegated admin, + * Mo a plain member, and Olga owns only the default organization. + */ +async function stage() { + const engine = createMemoryEngine(); + engine.tables.set('sys_organization', [ + { id: DEFAULT_ORG, name: 'Default', slug: 'default' }, + { id: ACME_ORG, name: 'Acme Corp', slug: 'acme' }, + ]); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + uiBasePath: UI_BASE, + dataEngine: engine as any, + membershipPolicy: 'auto', + getTenancy: () => singleOrgTenancy(), + } as any); + + const direct = (method: 'GET' | 'POST', path: string, body?: unknown, cookie?: string) => + manager.handleRequest( + new Request(`${ORIGIN}${BASE}${path}`, { + method, + headers: { + origin: ORIGIN, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { cookie } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }), + ); + + const signUp = async (email: string, name: string) => { + await inviteForAudienceGate(engine, email); + const res = await direct('POST', '/sign-up/email', { email, password: PASSWORD, name }); + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); + const user = (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === email); + expect(user, `sign-up did not create ${email}`).toBeDefined(); + return { cookie: cookieFrom(res), userId: String(user!.id) }; + }; + const bind = (userId: string, organizationId: string, role: string) => + engine.insert('sys_member', { + id: `mem_${userId}_${organizationId}`, + organization_id: organizationId, + user_id: userId, + role, + created_at: new Date(), + }); + + const owner = await signUp('alice.owner@example.com', 'Alice Owner'); + const ownerDefault = (engine.tables.get('sys_member') ?? []).find( + (m: any) => m.organization_id === DEFAULT_ORG && m.user_id === owner.userId, + ); + expect(ownerDefault, 'the reconciler did not bind the owner to the default organization').toBeDefined(); + ownerDefault!.role = 'owner'; + await bind(owner.userId, ACME_ORG, 'owner'); + const delegate = await signUp('dana.delegate@example.com', 'Dana Delegate'); + await bind(delegate.userId, ACME_ORG, 'delegated_admin'); + const member = await signUp('mo.member@example.com', 'Mo Member'); + await bind(member.userId, ACME_ORG, 'member'); + const outsider = await signUp('olga.outsider@example.com', 'Olga Outsider'); + + /** Invite (or resend) through the vendor's own route, as the owner. */ + const invite = async (email: string, extra: Record = {}) => { + const res = await direct( + 'POST', + '/organization/invite-member', + { email, role: 'member', organizationId: ACME_ORG, ...extra }, + owner.cookie, + ); + const text = await res.clone().text(); + return { status: res.status, body: JSON.parse(text), text }; + }; + + const handler = createCopyInvitationLinkHandler({ + engine: engine as any, + consoleUrl: (path) => manager.getConsoleUrl(path), + }); + /** The platform action door's context for the handler: the caller's session, the row's id. */ + const copyLink = (invitationId: string, callerId: string | undefined) => + handler({ + record: { id: invitationId }, + session: callerId ? { userId: callerId } : undefined, + params: { recordId: invitationId, objectName: INVITATION_OBJECT }, + }); + + const invitationRow = (id: string) => + (engine.tables.get('sys_invitation') ?? []).find((r: any) => r.id === id) as Record; + + return { engine, manager, direct, signUp, invite, owner, delegate, member, outsider, copyLink, invitationRow }; +} + +/** A handler refusal's status and ADR-0112 code. */ +async function refusalOf(call: Promise): Promise<{ status: unknown; code: unknown; message: string }> { + try { + await call; + } catch (err: any) { + return { status: err?.status, code: err?.code, message: String(err?.message) }; + } + throw new Error('the handler answered instead of refusing'); +} + +let savedManaged: string | undefined; +beforeEach(() => { + savedManaged = process.env[MANAGED_ENV]; + delete process.env[MANAGED_ENV]; + resetCloudManagedForTesting(); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); +afterEach(() => { + if (savedManaged === undefined) delete process.env[MANAGED_ENV]; + else process.env[MANAGED_ENV] = savedManaged; + resetCloudManagedForTesting(); + vi.restoreAllMocks(); +}); + +describe('#27843 (a) — invite-member answers its delivery outcome', () => { + it('no email service at all: link_only, beside the invitation row', async () => { + const { invite } = await stage(); + const answer = await invite('bob.builder@acme.test'); + expect(answer.status, answer.text).toBe(200); + expect(answer.body.outcome).toBe('link_only'); + // The row the answer already carried is untouched: `{{ result.email }}` keeps reading. + expect(answer.body.email).toBe('bob.builder@acme.test'); + expect(answer.body.id).toBeTruthy(); + }); + + it('the log transport (isConfigured() false) is not delivery: link_only', async () => { + const { manager, invite } = await stage(); + const email = recordingEmail(false); + manager.setEmailService(email.service as never); + const answer = await invite('bob.builder@acme.test'); + expect(answer.status, answer.text).toBe(200); + expect(answer.body.outcome).toBe('link_only'); + }); + + it('an email service without isConfigured() is unknown, so not delivery: link_only', async () => { + const { manager, invite } = await stage(); + manager.setEmailService(recordingEmail('absent').service as never); + expect((await invite('bob.builder@acme.test')).body.outcome).toBe('link_only'); + }); + + it('a configured transport: sent, and the mail went out', async () => { + const { manager, invite } = await stage(); + const email = recordingEmail(true); + manager.setEmailService(email.service as never); + const answer = await invite('bob.builder@acme.test'); + expect(answer.status, answer.text).toBe(200); + expect(answer.body.outcome).toBe('sent'); + expect(email.sent.map((m) => m.template)).toEqual(['auth.invitation']); + }); + + it('a resend answers it too, by the transport at call time', async () => { + const { manager, invite } = await stage(); + const email = recordingEmail(false); + manager.setEmailService(email.service as never); + const first = await invite('bob.builder@acme.test'); + expect(first.body.outcome).toBe('link_only'); + + email.state.live = true; + const resent = await invite('bob.builder@acme.test', { resend: true }); + expect(resent.status, resent.text).toBe(200); + expect(resent.body.id).toBe(first.body.id); + expect(resent.body.outcome).toBe('sent'); + + email.state.live = false; + expect((await invite('bob.builder@acme.test', { resend: true })).body.outcome).toBe('link_only'); + }); + + it('a refusal carries no outcome (the unknown-role refusal keeps its code)', async () => { + const { invite } = await stage(); + const refused = await invite('bob.builder@acme.test', { role: 'no_such_role' }); + expect(refused.status, refused.text).toBe(400); + expect(refused.body.code).toBe('ROLE_NOT_FOUND'); + expect(refused.body).not.toHaveProperty('outcome'); + }); +}); + +describe('#27843 (b) — copy_invitation_link answers the link the mail carries', () => { + it('the same URL, canonical origin and Console mount included', async () => { + const { manager, invite, owner, copyLink } = await stage(); + const email = recordingEmail(true); + manager.setEmailService(email.service as never); + const { body } = await invite('bob.builder@acme.test'); + const mailed = email.sent[0]?.data?.acceptUrl; + expect(mailed, 'the invitation mail carries an accept URL').toBeTruthy(); + + const answer = await copyLink(String(body.id), owner.userId); + expect(answer).toEqual({ acceptUrl: mailed }); + expect(answer.acceptUrl).toBe(`${ORIGIN}${UI_BASE}/accept-invitation/${body.id}`); + }); + + it('never logs the link', async () => { + const { invite, owner, copyLink } = await stage(); + const { body } = await invite('bob.builder@acme.test'); + const spies = (['info', 'warn', 'error', 'log', 'debug'] as const).map((level) => + vi.spyOn(console, level).mockImplementation(() => {}), + ); + for (const spy of spies) spy.mockClear(); + const { acceptUrl } = await copyLink(String(body.id), owner.userId); + for (const spy of spies) { + for (const call of spy.mock.calls) expect(JSON.stringify(call)).not.toContain(acceptUrl); + } + }); +}); + +describe('#27843 (c) — copy_invitation_link is answered only inside the invite reach, on a pending row', () => { + it('the owner and a delegated admin of the invitation\'s organization are admitted', async () => { + const { invite, owner, delegate, copyLink } = await stage(); + const { body } = await invite('bob.builder@acme.test'); + const asOwner = await copyLink(String(body.id), owner.userId); + expect(asOwner.acceptUrl).toContain(`/accept-invitation/${body.id}`); + expect(await copyLink(String(body.id), delegate.userId)).toEqual(asOwner); + }); + + it('a plain member of the organization is refused 403 PERMISSION_DENIED', async () => { + const { invite, member, copyLink } = await stage(); + const { body } = await invite('bob.builder@acme.test'); + const v = await refusalOf(copyLink(String(body.id), member.userId)); + expect([v.status, v.code]).toEqual([403, 'PERMISSION_DENIED']); + }); + + it('an owner of ANOTHER organization is refused 403: reach is the invitation\'s own organization', async () => { + const { invite, outsider, copyLink } = await stage(); + const { body } = await invite('bob.builder@acme.test'); + const v = await refusalOf(copyLink(String(body.id), outsider.userId)); + expect([v.status, v.code]).toEqual([403, 'PERMISSION_DENIED']); + }); + + it('a caller with no session is refused 401', async () => { + const { invite, copyLink } = await stage(); + const { body } = await invite('bob.builder@acme.test'); + const v = await refusalOf(copyLink(String(body.id), undefined)); + expect([v.status, v.code]).toEqual([401, 'UNAUTHENTICATED']); + }); + + it('a canceled invitation is refused 409, and so is an accepted one', async () => { + const { direct, invite, owner, signUp, copyLink, invitationRow } = await stage(); + const canceled = (await invite('carl.canceled@acme.test')).body.id as string; + const cancelRes = await direct('POST', '/organization/cancel-invitation', { invitationId: canceled }, owner.cookie); + expect(cancelRes.status, await cancelRes.clone().text()).toBe(200); + expect(invitationRow(canceled).status).toBe('canceled'); + + const accepted = (await invite('ada.accepted@acme.test')).body.id as string; + const acceptor = await signUp('ada.accepted@acme.test', 'Ada Accepted'); + const acceptRes = await direct('POST', '/organization/accept-invitation', { invitationId: accepted }, acceptor.cookie); + expect(acceptRes.status, await acceptRes.clone().text()).toBe(200); + expect(invitationRow(accepted).status).toBe('accepted'); + + for (const id of [canceled, accepted]) { + const v = await refusalOf(copyLink(id, owner.userId)); + expect([v.status, v.code], id).toEqual([409, 'RESOURCE_CONFLICT']); + } + }); + + it('reach is judged before status: a plain member learns nothing about a canceled row', async () => { + const { direct, invite, owner, member, copyLink } = await stage(); + const canceled = (await invite('carl.canceled@acme.test')).body.id as string; + await direct('POST', '/organization/cancel-invitation', { invitationId: canceled }, owner.cookie); + const v = await refusalOf(copyLink(canceled, member.userId)); + expect([v.status, v.code]).toEqual([403, 'PERMISSION_DENIED']); + }); + + it('an unknown invitation is refused 404', async () => { + const { owner, copyLink } = await stage(); + const v = await refusalOf(copyLink('inv_never_issued', owner.userId)); + expect([v.status, v.code]).toEqual([404, 'RECORD_NOT_FOUND']); + }); +}); + +describe('#27843 (d) — copy_invitation_link on a cloud-managed environment', () => { + it('is refused 409 with the cloud-managed sentence, even for the owner of a pending row', async () => { + const { invite, owner, copyLink } = await stage(); + const { body } = await invite('bob.builder@acme.test'); + process.env[MANAGED_ENV] = '1'; + resetCloudManagedForTesting(); + const v = await refusalOf(copyLink(String(body.id), owner.userId)); + expect([v.status, v.code]).toEqual([409, 'RESOURCE_CONFLICT']); + expect(v.message).toBe(CLOUD_MANAGED_REFUSAL_MESSAGES.invitationLink); + }); +}); + +describe('#27843 — AuthPlugin registers the handler on the engine', () => { + it('start() registers sys_invitation.copy_invitation_link under plugin-auth\'s package id', async () => { + const registerAction = vi.fn(); + const ql = { registerAction, registerHook: vi.fn() }; + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'objectql') return ql; + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + } as unknown as PluginContext; + const plugin = new AuthPlugin({ secret: SECRET, baseUrl: ORIGIN, uiBasePath: UI_BASE } as any); + await plugin.init(ctx); + await plugin.start(ctx); + + const calls = registerAction.mock.calls.filter(([object, name]) => object === INVITATION_OBJECT && name === COPY_INVITATION_LINK_ACTION); + expect(calls).toHaveLength(1); + expect(typeof calls[0][2]).toBe('function'); + expect(calls[0][3]).toBe(AUTH_PLUGIN_ID); + }); +}); diff --git a/packages/plugins/plugin-auth/src/invitation-delivery-outcome.ts b/packages/plugins/plugin-auth/src/invitation-delivery-outcome.ts new file mode 100644 index 00000000000..ad700372910 --- /dev/null +++ b/packages/plugins/plugin-auth/src/invitation-delivery-outcome.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27843 (ruling E on #27797) — `POST /organization/invite-member` says + * whether its mail was delivered. + * + * ## The defect + * + * On a deployment with no live mail transport (the stock `log` transport, or + * no email service at all) a successful invite answered exactly what a + * delivered one answers: the invitation row. The console's `invite_user` + * action then toasted "Invitation sent to …" while nothing left the server, and + * the invitee never arrived. The server knew the difference and nothing carried + * it to the client. + * + * ## What this does + * + * Every successful answer of the route — a new invitation and a resend + * (`resend: true`) alike, the vendor answers both with the invitation row — + * gains one top-level member beside the row's own columns: + * + * - `outcome: 'sent'` when mail is delivered on this deployment; + * - `outcome: 'link_only'` when it is not, so the invitation exists and only + * its accept link (`sys_invitation.copy_invitation_link`) reaches the + * invitee. + * + * The console reads it through `ActionSchema.outcomeMessages` (the success + * payload's top-level `outcome` names the copy), so the row keeps answering + * `{{ result.email }}` exactly as before. + * + * ## The one rule that decides it + * + * `AuthManager.isEmailDeliveryLive()`, asked at call time: the wired email + * service's own `isConfigured()` answer, the rule sign-up already uses. + * ⛔ Not `hasEmailTransport()` / `isEmailServiceAvailable()`: they answer "a + * service object is wired", which is true on every default boot, whose `log` + * transport writes the mail to the console instead of sending it. + * + * It describes the transport, not the one send: a live transport whose send + * then fails is still `sent` (the vendor's invitation callback swallows that + * failure, and the row is already written), and the admin resends. + * + * Runs in the global `after` hook, where `ctx.context.returned` holds the + * route's answer; a refusal there is an `APIError`, which this leaves alone. + * The payload is completed in place, the shape `session-envelope-completion.ts` + * uses, so every later reader of the answer sees the same object. + */ + +import { INVITE_MEMBER_PATH } from './invite-role-not-found-code.js'; + +/** The closed set of delivery outcomes invite-member answers. */ +export const INVITATION_DELIVERY_OUTCOMES = ['sent', 'link_only'] as const; + +/** One member of {@link INVITATION_DELIVERY_OUTCOMES}. */ +export type InvitationDeliveryOutcome = (typeof INVITATION_DELIVERY_OUTCOMES)[number]; + +/** The outcome for a transport that does (`true`) or does not deliver mail. */ +export function invitationDeliveryOutcome(deliveryLive: boolean): InvitationDeliveryOutcome { + return deliveryLive ? 'sent' : 'link_only'; +} + +/** + * Stamp `outcome` on a successful invite-member answer. A no-op on every other + * path, on a refusal, and on an answer that is not an object. Never throws: an + * answer it cannot read is left exactly as the vendor wrote it. + * + * @param isEmailDeliveryLive the manager's deliverability reader, asked once, + * at call time. + */ +export async function stampInvitationDeliveryOutcome( + ctx: any, + isEmailDeliveryLive: () => boolean, +): Promise { + try { + if (ctx?.path !== INVITE_MEMBER_PATH) return; + const returned = ctx?.context?.returned; + if (!returned || typeof returned !== 'object' || Array.isArray(returned)) return; + try { + const { isAPIError } = await import('better-auth/api'); + if (isAPIError(returned)) return; + } catch { + if (returned instanceof Error) return; + } + (returned as Record).outcome = invitationDeliveryOutcome(isEmailDeliveryLive()); + } catch { + /* leave the answer exactly as the vendor route wrote it */ + } +} diff --git a/packages/plugins/plugin-auth/src/invitation-link-action.ts b/packages/plugins/plugin-auth/src/invitation-link-action.ts new file mode 100644 index 00000000000..d1878133c73 --- /dev/null +++ b/packages/plugins/plugin-auth/src/invitation-link-action.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27843 (ruling E on #27797) — `sys_invitation.copy_invitation_link`, the + * server action that answers a pending invitation's accept link. + * + * ## Why it exists + * + * On a deployment whose mail is not delivered, invite-member answers + * `outcome: 'link_only'` (`invitation-delivery-outcome.ts`): the invitation + * exists and nothing reached the invitee. The governed invite action is the + * metadata one, and nothing beside it handed the link over, so the admin had + * no way to finish the invitation by hand. This action is that way: the row + * menu and the record header offer it, and the console shows its answer in a + * copyable result dialog. + * + * ## The link + * + * `{ acceptUrl }`, composed by {@link invitationAcceptPath} through the + * manager's one Console-page composer (`AuthManager.getConsoleUrl`, the public + * reader of `getConsolePageUrl`), which is exactly what `sendInvitationEmail` + * puts in the mail: the canonical origin, the Console mount `uiBasePath`, and + * `/accept-invitation/`. No second link builder exists. + * + * ## Who gets it — the gates of `resend_invitation`, enforced here + * + * ⛔ The URL is a bearer credential: whoever opens it can accept the + * invitation. The declaration's `visible` predicate is UI courtesy only, and + * this handler's body runs system-elevated at the platform action door, so it + * enforces every gate itself, in this order: + * + * 1. **Cloud-managed** — refused `409 RESOURCE_CONFLICT`, as invite-member + * (resends included) is refused there: the hosting organisation invites in + * the cloud. + * 2. **One invitation, named** — the action runs on a record. + * 3. **An identified caller** — the request's own `session.userId`, never the + * elevated execution context the body is handed. + * 4. **Invite reach** — the caller's `sys_member` row in the invitation's + * organization carries a grade of `MEMBERSHIP_REACH.invite_member` (owner, + * admin, delegated_admin): the grades whose statements reach + * `/organization/invite-member`, which a resend calls. The table is pinned + * to the roles map plugin-auth hands better-auth by + * `membership-reach-table.test.ts`. Before the status gate, so a caller + * outside the reach learns nothing about the row. + * 5. **Pending** — any other status has no link that admits anyone. + * + * The invitation and the membership are re-read from the store under the + * system context, never taken from the record the door projected for the + * caller's view (which may be sparse). + * + * ⛔ Nothing here logs the link, in any environment. + */ + +import { isCloudManaged } from '@objectstack/core'; +import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; +import { MEMBERSHIP_REACH, InvitationStatus } from '@objectstack/spec/identity'; +import { + CLOUD_MANAGED_REFUSAL_CODE, + CLOUD_MANAGED_REFUSAL_MESSAGES, + CLOUD_MANAGED_REFUSAL_STATUS, +} from './cloud-managed.js'; +import { parseOrgRoles } from './invitation-role-cap.js'; +import { AUTH_PLUGIN_ID } from './manifest.js'; +import { withSystemContext } from './objectql-adapter.js'; + +/** The object the action is declared on. */ +export const INVITATION_OBJECT = 'sys_invitation'; + +/** The action's name, and the handler key its declaration's `target` names. */ +export const COPY_INVITATION_LINK_ACTION = 'copy_invitation_link'; + +/** + * The Console page an invitation's accept link opens, relative to the Console + * mount. The one spelling: the invitation mail and this action both compose + * their link from it. + */ +export function invitationAcceptPath(invitationId: string): string { + return `/accept-invitation/${invitationId}`; +} + +/** The answer of {@link COPY_INVITATION_LINK_ACTION}. */ +export interface CopyInvitationLinkAnswer { + acceptUrl: string; +} + +export interface CopyInvitationLinkDeps { + /** The data engine; read under the system context. */ + engine: IDataEngine; + /** The manager's Console-page composer (`AuthManager.getConsoleUrl`). */ + consoleUrl: (path: string) => string; +} + +function refusal(status: number, code: string, message: string): Error { + const err = new Error(message) as Error & { status: number; code: string }; + err.status = status; + err.code = code; + return err; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** The grades that reach `/organization/invite-member`. */ +const INVITE_REACH_GRADES: readonly string[] = MEMBERSHIP_REACH.invite_member.grades; + +/** + * Build the action handler. It receives the platform action door's context + * (`record`, `session`, `params`, …) and answers {@link CopyInvitationLinkAnswer}, + * or throws a refusal carrying its own `status` and ADR-0112 `code`. + */ +export function createCopyInvitationLinkHandler(deps: CopyInvitationLinkDeps) { + const sys = withSystemContext(deps.engine); + return async (ctx: any): Promise => { + if (isCloudManaged()) { + throw refusal(CLOUD_MANAGED_REFUSAL_STATUS, CLOUD_MANAGED_REFUSAL_CODE, CLOUD_MANAGED_REFUSAL_MESSAGES.invitationLink); + } + + const invitationId = nonEmptyString(ctx?.params?.recordId) ?? nonEmptyString(ctx?.record?.id); + if (!invitationId) { + throw refusal(400, 'MISSING_REQUIRED_FIELD', 'Copy invite link runs on one invitation: name it with `recordId`.'); + } + + const callerId = nonEmptyString(ctx?.session?.userId); + if (!callerId) { + throw refusal(401, 'UNAUTHENTICATED', 'Sign in to copy an invite link.'); + } + + const invitation: any = await sys.findOne(INVITATION_OBJECT, { + where: { id: invitationId }, + fields: ['id', 'organization_id', 'status'], + }); + if (!invitation) { + throw refusal(404, 'RECORD_NOT_FOUND', `No invitation '${invitationId}' exists.`); + } + + const organizationId = nonEmptyString(invitation.organization_id); + const membership: any = organizationId + ? await sys.findOne('sys_member', { + where: { organization_id: organizationId, user_id: callerId }, + fields: ['id', 'role'], + }) + : undefined; + const reaches = parseOrgRoles(membership?.role).some((role) => INVITE_REACH_GRADES.includes(role)); + if (!reaches) { + throw refusal( + 403, + 'PERMISSION_DENIED', + "Only an owner, admin or delegated admin of the invitation's organization can copy its invite link, " + + 'the same members who can resend it.', + ); + } + + if (invitation.status !== InvitationStatus.enum.pending) { + throw refusal( + 409, + 'RESOURCE_CONFLICT', + `This invitation is ${String(invitation.status)}, so its link no longer lets anyone join. ` + + 'Only a pending invitation has a link to copy; send a new invitation instead.', + ); + } + + return { acceptUrl: deps.consoleUrl(invitationAcceptPath(String(invitation.id))) }; + }; +} + +/** + * Register {@link COPY_INVITATION_LINK_ACTION} on `sys_invitation`, owned by + * plugin-auth's package id (the id the identity objects are registered under). + */ +export function registerCopyInvitationLinkAction( + ql: Pick, + deps: CopyInvitationLinkDeps, +): void { + ql.registerAction(INVITATION_OBJECT, COPY_INVITATION_LINK_ACTION, createCopyInvitationLinkHandler(deps), AUTH_PLUGIN_ID); +} diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index dcd2b93c2aa..2cffe6543b7 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -120,6 +120,8 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_invitation.actions.invite_user', 'sys_invitation.actions.cancel_invitation', 'sys_invitation.actions.resend_invitation', + // [#27843] The pending invitation's accept link — same gates as resend. + 'sys_invitation.actions.copy_invitation_link', 'sys_team.actions.create_team', 'sys_team.actions.update_team', 'sys_team.actions.remove_team', @@ -248,6 +250,8 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_member.actions.invite_user', 'sys_invitation.actions.invite_user', 'sys_organization.actions.invite_user', + // [#27843] The invitation's accept link: no invitation is handed out here. + 'sys_invitation.actions.copy_invitation_link', 'sys_user.actions.create_user', // Hidden in the console only: the server keeps answering it on an // `env_native` row, so the break-glass admin stays editable.