Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/skillhub/en/guide/namespace.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ Discover a security issue in the namespace and temporarily freeze all publish op
5. Select a role (Owner / Admin / Member)
6. Confirm to add

**Invite with a link**:

1. An Owner or Admin opens the Members page of an ACTIVE team space and selects "Invite member".
2. Share the copied link securely. It expires after 24 hours and can be redeemed once by one person who is not already a member.
3. The recipient must first pass the usual SkillHub login and account access rules. The page shows the space, current account, and MEMBER permissions. Joining happens only after explicit confirmation.
4. "Revoke invitation" invalidates the link immediately. Creating a new link asks for confirmation before replacing a still usable one; existing members remain members.

Anyone who obtains the link can use it after normal account admission. Do not post it in a public channel. The login return URL does not contain the invitation token; the current browser tab stores it temporarily until joining or terminal invalidation.

**Manage Permissions**:

1. Find the target user in the member list
Expand Down
9 changes: 9 additions & 0 deletions docs/skillhub/guide/namespace.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@
5. 选择角色(Owner / Admin / Member)
6. 确认添加

**通过链接邀请成员**:

1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。
2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。
3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。
4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。

邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。

**管理权限**:

1. 在成员列表中找到目标用户
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/add-namespace-invite-links/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12
20 changes: 20 additions & 0 deletions openspec/changes/add-namespace-invite-links/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Context

现有 Namespace 成员只能由管理员直接添加。`NamespaceMemberService`、平台管理服务、生命周期服务和账号合并均会修改同一 Namespace 的状态或成员,但目前没有共同的 Namespace 行锁。前端已有 Session/CSRF、登录 returnTo 与 BASE_PATH 逻辑;当前 returnTo helper 会保留 fragment,所以 `/join` 必须先移除 Token 再触发登录跳转。

## Decisions

1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。
2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废以及带操作者审计的生命周期操作先锁操作者,再锁 Namespace;接受先锁接收者,再锁 Namespace。当前不写用户审计的 Namespace 删除只锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。
3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。
4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。
5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。
6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。
7. 正式路径沿用 Issue #911 的复数合同:`POST /api/v1/namespaces/{slug}/invitations` 创建或替换,`POST /api/v1/namespace-invitations/preview|accept` 预览及确认。状态查询和作废使用同一 `/namespaces/{slug}/invitations` 路径的 GET/DELETE;保留现有 `/api/web` 别名。Preview 返回 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`、`expiresAt`;Accept 仅返回 `namespaceSlug`、`outcome`。无效邀请的本地化键为 `error.namespace.invitation.invalid`。

## Risks and verification

- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。
- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。
- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。
- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。
30 changes: 30 additions & 0 deletions openspec/changes/add-namespace-invite-links/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Why

Namespace 管理员目前只能逐个选择现有用户添加成员。短期、单次邀请链接能让成员自行加入,同时让管理员控制链接失效和成员权限边界。

## What Changes

- ACTIVE TEAM 的 OWNER/ADMIN 可以生成一条 24 小时内有效、仅一名非成员可兑换的邀请链接;替换未使用链接前须确认。
- 管理员可以单独作废当前邀请,不需要生成新链接;不影响已经加入的成员。
- 持链接者先按现有注册、登录和账号准入策略获得可用账号,再看到空间、当前账号和 MEMBER 权限,明确确认后加入。
- Token 不存明文、不进 URL path/query 或登录回跳参数;创建、预览、兑换、作废均受权限及事务约束。
- 邀请兑换与成员及 Namespace 生命周期操作并发时保持一致,不出现重复成员或部分消费。

## Capabilities

### New Capabilities

- `namespace-invitations`: 邀请创建、替换、作废、预览、确认加入、安全与并发规则。

### Modified Capabilities

无。

## Impact

- Namespace 领域、成员管理、账号合并事务、数据库迁移、审计、Session/CSRF 接口和 OpenAPI 类型。
- Namespace 管理页面、邀请页、登录回跳与子路径部署、多语言文案及浏览器验证。

## 相对 Issue #911 的调整

原 Issue 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
## Purpose

让 Namespace 管理员使用可控的短期单次链接邀请新成员。

## ADDED Requirements

### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请

有权管理员 SHALL 可创建、替换及作废邀请。

只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认,创建响应仅在旧链接当时仍可兑换时标记 `replaced=true`;已消费或过期后的新建不标记替换。管理员可以单独作废当前邀请,且已加入成员不受影响。

#### Scenario: 替换未使用链接
- **WHEN** 管理员再次点击邀请且当前链接仍可兑换
- **THEN** 页面先说明旧链接将失效并要求确认
- **AND** 仅确认后生成新链接,旧链接立即失效

#### Scenario: 单独作废
- **WHEN** 管理员点击作废并确认
- **THEN** 当前链接立即失效,不会创建新链接或移除成员

### Requirement: REQ-INV-02 邀请 SHALL 服从现有账号准入

邀请 SHALL 服从现有账号准入规则。

邀请只授予目标 Namespace 的 MEMBER 权限,不使用户绕过现有注册、登录、外部身份准入和账号审批。接受者必须先拥有可用登录会话,看到目标 Namespace、当前账号和 MEMBER 可访问及发布资源的说明,再明确确认加入。页面加载不得自动兑换。

#### Scenario: 新用户未通过准入
- **WHEN** 持链接者无法按既有策略获得可用账号
- **THEN** 邀请不能使其进入目标 Namespace

#### Scenario: 已登录非成员
- **WHEN** 非成员打开有效链接
- **THEN** 页面展示 Namespace、账号和权限,只有点击确认才新增 MEMBER

### Requirement: REQ-INV-03 Token SHALL 避免意外泄露

Token SHALL 避免出现在日志、持久化明文和登录 URL 中。

Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#token`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。

#### Scenario: 经过登录回跳
- **WHEN** 未登录者打开邀请后登录
- **THEN** Token 不出现在登录回跳 URL,回到原标签页后才从 sessionStorage 恢复邀请

#### Scenario: 子路径部署
- **WHEN** 应用部署在 `/skillhub/`
- **THEN** 分享链接指向公开基地址下 `/skillhub/join#token`,应用内 returnTo 仍为 `/join`

### Requirement: REQ-INV-04 服务端 SHALL 在接受时重新判断状态

服务端 SHALL 在接受邀请时重新判断全部状态。

预览不消费邀请。接受时重新检查 Token 是否仍为当前、用户是否已有成员关系、Namespace 是否 ACTIVE TEAM、邀请是否未消费且未过期。Token 匹配当前邀请且用户已是成员时返回 ALREADY_MEMBER,不再检查过期、消费或 Namespace 冻结归档状态,也不消费。未知、过期、替换、作废或已用 Token 对非成员返回统一失效响应。冻结或归档时非成员不能加入也不消费,恢复后仅在原有效期内可兑换。
统一失效响应使用 HTTP 404、envelope `code=404` 与 `data=null`;前端只按状态处理,不解析本地化文案。

#### Scenario: 预览后链接被替换
- **WHEN** 用户看到预览后管理员替换或作废了链接
- **THEN** 接受请求失败且不新增成员

#### Scenario: 已有成员打开当前链接
- **WHEN** Token 仍是当前邀请且当前用户已经是成员
- **THEN** 返回 ALREADY_MEMBER,直接进入 Namespace,不消耗邀请

#### Scenario: 被移除者重用已消费链接
- **WHEN** 曾兑换的成员后来被移除
- **THEN** 原链接仍保持已消费,不能再次加入

### Requirement: REQ-INV-05 并发操作 SHALL 保持成员和邀请原子一致

并发操作 SHALL 保持成员和邀请原子一致。

邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废及带操作者审计的生命周期操作锁操作者,接受锁接收者;当前不写用户审计的 Namespace 删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。

#### Scenario: 两人同时兑换
- **WHEN** 两个非成员同时确认同一链接
- **THEN** 最多一人新增 MEMBER,另一人收到统一失效结果

#### Scenario: 管理员直接添加与邀请并发
- **WHEN** 管理员直接添加同一用户和该用户兑换邀请同时发生
- **THEN** 不出现未处理的唯一约束异常、重复成员或错误消费

### Requirement: REQ-INV-06 前端 SHALL 完成链接分享和状态闭环

前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。

空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token 并自动使用 replace 导航进入空间,避免重复导航。加载状态使用可访问的 status 提示,终态失效使用 alert 提示。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。

#### Scenario: 剪贴板写入失败
- **WHEN** 创建成功但浏览器拒绝写剪贴板
- **THEN** 页面显示同一链接供手动复制,不再次生成邀请

#### Scenario: 网络错误
- **WHEN** 预览或接受遇到可重试网络错误
- **THEN** 页面保留 Token,用户可重试而无需新邀请

### Requirement: REQ-INV-07 API SHALL 使用 Issue #911 的路径与响应合同

API SHALL 使用 Issue #911 的路径与响应合同。

创建或替换使用 `POST /api/v1/namespaces/{slug}/invitations`,首次创建允许无请求体;有仍可兑换的当前邀请时须按已批准的确认流程传入 `replace=true`。状态查询和单独作废分别使用同一路径的 GET 与 DELETE。预览和接受分别使用 `POST /api/v1/namespace-invitations/preview` 与 `POST /api/v1/namespace-invitations/accept`,请求体均为 `{ "token": "..." }`;可保留 `/api/web` 别名。

创建数据仅包含 `token`、`expiresAt`、`replaced`。预览数据包含 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`(`CONFIRM_REQUIRED` 或 `ALREADY_MEMBER`)和 `expiresAt`。接受数据仅包含 `namespaceSlug` 和 `outcome`(`JOINED` 或 `ALREADY_MEMBER`)。无效邀请使用 `error.namespace.invitation.invalid` 本地化键,中文为“邀请链接无效或已失效”,响应为 HTTP 404、envelope `code=404`、`data=null`。

#### Scenario: 首次创建和预览
- **WHEN** OWNER/ADMIN 首次创建邀请且不传请求体,非成员预览链接
- **THEN** 创建返回三字段,预览返回五字段且 `outcome=CONFIRM_REQUIRED`

#### Scenario: 接受与已有成员
- **WHEN** 非成员确认加入,或已有成员访问当前邀请
- **THEN** 接受分别返回 `JOINED` 或 `ALREADY_MEMBER`,且仅包含 `namespaceSlug` 和 `outcome`
17 changes: 17 additions & 0 deletions openspec/changes/add-namespace-invite-links/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. 持久化与事务

- [x] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。
- [x] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。
- [x] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。

## 2. API 与页面

- [x] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。
- [x] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。
- [x] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。

## 3. 验证与交付

- [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。
Loading
Loading