forked from EduCraft/curriculum-project-hub
docs: resolve product surface audit
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
# Accepted product-surface code inventory
|
||||
|
||||
Audit date: 2026-07-10
|
||||
|
||||
Scope: source-level inventory for the accepted SaaS product boundary in ADR-0020,
|
||||
ADR-0021 and ADR-0022. This is not a claim that tests were executed during this
|
||||
inventory. “Test evidence” below means a repository test exercises the named
|
||||
path; “no direct test found” means a search of `hub/test/**/*.ts` found no request
|
||||
to the named route or corresponding service call.
|
||||
|
||||
## Contract baseline
|
||||
|
||||
The destination is not a shared-bot, single-credential Hub. The repository's
|
||||
current shared language says:
|
||||
|
||||
- an Organization owns its memberships, projects, teams, external connections
|
||||
and settings (`CONTEXT.md:7-9`);
|
||||
- a Platform Administrator creates and operates Organizations without becoming
|
||||
their member, while an Organization Administrator is only an OWNER/ADMIN of
|
||||
that Organization (`CONTEXT.md:11-17`);
|
||||
- the Admin Surface is a private web control plane requiring the corresponding
|
||||
authenticated administrator authority (`CONTEXT.md:19-21`);
|
||||
- each Organization has its own Feishu application for login, bot messaging and
|
||||
directory integration (`CONTEXT.md:23-25`);
|
||||
- provider connections are Organization-scoped BYOK or an Organization-distinct
|
||||
platform-managed connection (`CONTEXT.md:27-37`);
|
||||
- capacity uses non-optional platform ceilings, optional lower Organization
|
||||
limits, durable Organization-fair admission and audited emergency brakes
|
||||
(`CONTEXT.md:39-77`).
|
||||
|
||||
ADR-0021 makes `/admin/platform` and `/admin/org/:orgSlug` separate guarded web
|
||||
areas (`docs/adr/0021-org-admin-project-onboarding.md:23-40`), requires a
|
||||
customer-owned Feishu app with encrypted Organization-scoped secrets
|
||||
(`docs/adr/0021-org-admin-project-onboarding.md:42-47`), and pins both provider
|
||||
credential modes as Organization-scoped (`docs/adr/0021-org-admin-project-onboarding.md:49-60`).
|
||||
ADR-0022 makes its safety controls release requirements, not optional future UI
|
||||
(`docs/adr/0022-layered-capacity-admission.md:22-77`).
|
||||
|
||||
Status vocabulary in this inventory:
|
||||
|
||||
- **HTTP-backed** / **Feishu-backed**: a production route/event handler is wired.
|
||||
- **Service/test only**: code can be invoked directly, but there is no production
|
||||
product entry point.
|
||||
- **Partial**: part of the journey exists but the accepted end-to-end product
|
||||
surface cannot complete it.
|
||||
- **Missing**: no production entry and no suitable persistence/control model.
|
||||
- **Contract divergence**: reachable behavior actively uses the wrong tenancy or
|
||||
credential shape.
|
||||
|
||||
## Journey matrix
|
||||
|
||||
| Target journey | Reachable entry | Authorization | Persistence | Test evidence | Verdict and exact gap |
|
||||
|---|---|---|---|---|---|
|
||||
| Platform Administrator signs in | None. The admin plugin registers only Feishu auth and Organization routes (`hub/src/admin/plugin.ts:7-8`, `hub/src/admin/plugin.ts:31-46`). The only HTML is the Org Admin login page (`hub/src/admin/routes/authRoutes.ts:171-207`). | `PlatformRoleAssignment` and `PlatformRole.ADMIN` exist in the schema (`hub/prisma/schema.prisma:111-129`), but the HTTP guard explicitly says the platform control plane is not modeled (`hub/src/admin/auth/guards.ts:1-5`). | Role rows can be stored, but there is no platform session/identity binding, invite/bootstrap flow or platform-admin audit surface. | No platform-auth route test exists. | **Missing.** `/admin/platform` from ADR-0021 is not mounted, so no authenticated operator can reach any platform workflow. |
|
||||
| Platform Administrator creates/lists/updates/operates Organizations | None. No `/api/platform/*` or Organization CRUD route is registered; the route composition is auth + `/api/org/:orgSlug/*` only (`hub/src/admin/plugin.ts:31-46`, `hub/src/admin/routes/orgRoutes.ts:24-103`). | No `requirePlatformAdmin` implementation; only `requireSession` and `requireOrgRole` exist (`hub/src/admin/auth/guards.ts:43-113`). | `Organization` has slug, name and ACTIVE/SUSPENDED/ARCHIVED status (`hub/prisma/schema.prisma:28-52`). | Organizations are created directly only by test setup, e.g. `prisma.organization.upsert` (`hub/test/integration/helpers.ts:60-77`) and a direct cross-org fixture create (`hub/test/integration/admin-explorer.test.ts:198-210`). | **Service/test only at the database layer.** Manual pilot onboarding has no safe production command, HTTP API or UI; status changes are also unreachable. |
|
||||
| Platform Administrator configures an Organization's customer-owned Feishu app and checks readiness | None. Runtime accepts exactly one `feishuAppId`/`feishuAppSecret` pair in `AdminPluginConfig` (`hub/src/admin/plugin.ts:10-19`), and server startup reads one process-global app id, secret and bot open id (`hub/src/server.ts:47-52`). | No platform guard or Organization connection ownership check. | There is no Feishu application/secret model. `ExternalDirectoryConnection` stores provider/tenant/source metadata but no app id, bot id or secret reference (`hub/prisma/schema.prisma:196-214`). | No provisioning/readiness test exists. | **Missing + contract divergence.** There is no Organization-scoped encrypted Feishu connection or readiness workflow; production can operate only the one env-configured app. |
|
||||
| Platform Administrator configures an Organization-distinct platform-managed provider key/base URL | None. No platform provider route or UI is registered. | No platform guard. | The accepted Lean type exists only in the semantic contract (`spec/Spec/System/Organization.lean:52-66`); Prisma has no provider-connection or provider-mode model (`hub/prisma/schema.prisma:26-214`). | Runtime tests prove env values are returned, not Organization values (`hub/test/unit/runtime-settings.test.ts:18-55`). | **Missing + contract divergence.** `EnvRuntimeSettings.provider` ignores its project scope and reads one process-global `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (`hub/src/settings/runtime.ts:20-21`, `hub/src/settings/runtime.ts:46-67`). |
|
||||
| Platform Administrator applies Organization/platform `DRAIN` or `STOP_NOW` and later resumes | None. | No platform guard, reason capture or scope check. | No workload-brake model exists; only the Lean contract defines `open`/`drain`/`stopNow` semantics (`spec/Spec/System/Capacity.lean:71-87`). | No workload-brake test exists. | **Missing.** The required audited, reason-bearing, reversible emergency control from ADR-0022 (`docs/adr/0022-layered-capacity-admission.md:69-72`) has no product or persistence surface. |
|
||||
| Organization user logs in with that Organization's Feishu app | `GET /auth/feishu` and callback are HTTP-backed (`hub/src/admin/routes/authRoutes.ts:49-128`). The callback upserts a user and signs a cookie (`hub/src/admin/routes/authRoutes.ts:99-123`). | Login itself is public OAuth; Organization APIs later enforce session + active OWNER/ADMIN membership (`hub/src/admin/auth/guards.ts:43-113`). | `User.feishuOpenId` is one process-global unique identifier (`hub/prisma/schema.prisma:89-109`); the signed cookie carries user id/open id but no Organization or Feishu-connection identity (`hub/src/admin/auth/session.ts:18-23`). | Redirect and mocked callback are covered (`hub/test/integration/admin-auth.test.ts:164-230`); OAuth request construction/exchange is covered (`hub/test/unit/feishu-oauth.test.ts:8-81`). | **HTTP-backed but contract-divergent.** OAuth is built once from the process-global app credentials (`hub/src/admin/routes/authRoutes.ts:38-47`), and the implementation explicitly documents this deferral (`hub/src/admin/auth/feishuOAuth.ts:1-10`). There is no org-selecting login route, connection resolver or issuer/app identity in the user key. |
|
||||
| Each Organization operates its own Feishu Bot | One WebSocket listener is production-wired (`hub/src/server.ts:76-90`). | Message actions are gated by project grants once a binding resolves (`hub/src/feishu/trigger.ts:715-755`), but there is no initial Organization/connection ownership gate for the inbound app. | Binding stores only `chatId -> projectId`, with no Feishu connection/application id (`hub/prisma/schema.prisma:287-304`). Event receipts store globally unique `eventId`, likewise without connection id (`hub/src/feishu/trigger.ts:687-707`). | Trigger, onboarding and event dedup are integration-tested (`hub/test/integration/trigger.test.ts:211-330`, `hub/test/integration/trigger.test.ts:878-928`). | **Feishu-backed for one bot, contract-divergent for SaaS.** Startup constructs one client and one WS client from one config (`hub/src/feishu/client.ts:854-901`). It cannot operate or route multiple Organization-owned apps. |
|
||||
| Suspended/archived Organization is prevented from bot work | No platform status-change entry exists. Org HTTP APIs do reject non-ACTIVE Organizations (`hub/src/admin/auth/guards.ts:79-88`). | The Feishu trigger resolves an active chat binding directly and then project permissions; it does not load or check `Project.organization.status` (`hub/src/feishu/trigger.ts:715-755`). | Organization status exists (`hub/prisma/schema.prisma:30-52`). | No suspended-Organization trigger test exists. | **Partial and unsafe.** Even a database-side status change only blocks web admin; an already-bound chat remains triggerable through the bot path. |
|
||||
| Logged-in Organization OWNER/ADMIN reaches the management panel | OAuth success redirects to `/admin/org/:slug` (`hub/src/admin/routes/authRoutes.ts:237-267`). | All Organization APIs default to OWNER/ADMIN through `requireOrgRole` (`hub/src/admin/auth/guards.ts:66-113`), and tests prove MEMBER is denied while ADMIN succeeds (`hub/test/integration/admin-auth.test.ts:95-131`). | Session is an HMAC cookie; user/membership is re-read for each guard (`hub/src/admin/auth/session.ts:31-57`, `hub/src/admin/auth/guards.ts:43-63`). | Guard behavior is integration-tested (`hub/test/integration/admin-auth.test.ts:56-131`). | **API-backed, UI missing.** The plugin says the static SPA is “later” (`hub/src/admin/plugin.ts:1-3`); no route/static handler serves `/admin/org/:slug`. OAuth therefore redirects to an unmounted page. |
|
||||
| Organization OWNER/ADMIN manages members and roles | REST endpoints list/add/change/revoke members (`hub/src/admin/routes/membersRoutes.ts:20-98`). | OWNER/ADMIN route guard plus service rules: only OWNER may affect OWNER and the last OWNER cannot be removed (`hub/src/org/members.ts:1-8`, `hub/src/org/members.ts:97-205`). | `OrganizationMembership` persists org, user, role and revocation (`hub/prisma/schema.prisma:54-76`). | Add/list/last-OWNER protection is covered through HTTP (`hub/test/integration/admin-members-teams.test.ts:49-83`). | **HTTP-backed and persisted; management UI missing.** Addition requires a raw `feishuOpenId` and may create the `User` directly (`hub/src/org/members.ts:46-85`); there is no invite/accept or org-connection directory picker. Role-change/revoke variants have less direct HTTP coverage than add/list. |
|
||||
| Organization OWNER/ADMIN manages teams and team membership | REST endpoints cover list/create/update/archive and membership add/revoke (`hub/src/admin/routes/teamsRoutes.ts:21-139`). | All routes require Organization OWNER/ADMIN; service verifies the team and added user belong to the same Organization (`hub/src/org/teams.ts:177-215`, `hub/src/org/teams.ts:263-275`). | `Team` and `TeamMembership` are Organization/project-principal persistence (`hub/prisma/schema.prisma:143-177`). | Create/add-member/archive is covered through HTTP (`hub/test/integration/admin-members-teams.test.ts:85-160`). | **HTTP-backed and persisted; management UI missing.** No production route exposes `ExternalDirectoryConnection`/external team binding sync, so the Feishu directory portion remains service-only. |
|
||||
| Organization OWNER/ADMIN creates and manages Folders/Projects | REST endpoints cover explorer, folder create/update/archive, project create/detail/rename/move/archive (`hub/src/admin/routes/explorerRoutes.ts:33-212`). | All routes require Organization OWNER/ADMIN. Service operations scope folder/project ids to the Organization (`hub/src/org/explorer.ts:301-341`); HTTP cross-org lookup is tested. | Folder and Project carry `organizationId`; Folder is transparent and Project owns workspace/session/run relations (`hub/prisma/schema.prisma:238-285`). Project creation also creates a real workspace and default permission rows (`hub/src/projectOnboarding.ts:275-348`). | Create/list and move/archive are HTTP-tested (`hub/test/integration/admin-explorer.test.ts:66-196`); cross-org id access is tested (`hub/test/integration/admin-explorer.test.ts:198-223`). | **HTTP-backed and substantially tested; management UI missing.** This is the strongest accepted product slice. |
|
||||
| Organization OWNER/ADMIN manages Feishu chat binding | HTTP can archive a binding (`hub/src/admin/routes/explorerRoutes.ts:214-227`). New binding is exposed through the Feishu onboarding card, not web. | Archive requires OWNER/ADMIN at HTTP and then project manager authorization in the shared service (`hub/src/projectOnboarding.ts:231-272`, `hub/src/org/explorer.ts:301-314`). Bind existing requires project MANAGE (`hub/src/projectOnboarding.ts:179-229`). | `ProjectGroupBinding` and the FEISHU_CHAT EDIT grant are transactionally stored (`hub/src/projectOnboarding.ts:190-215`, `hub/prisma/schema.prisma:287-304`). | Bind/one-active/archive/rebind is service-tested (`hub/test/integration/project-onboarding.test.ts:114-160`); card bind is integration-tested (`hub/test/integration/trigger.test.ts:271-330`). No direct HTTP archive test was found. | **Feishu + partial HTTP-backed; UI missing.** Binding identity is not scoped by Feishu application, so the 1:1 uniqueness and lookup are process-global rather than Organization-connection-scoped. |
|
||||
| Organization OWNER/ADMIN manages project team access | REST list/grant/revoke endpoints exist (`hub/src/admin/routes/accessRoutes.ts:19-79`). | OWNER/ADMIN route guard; service refuses a team from another Organization (`hub/src/permissions/projectTeamAccess.ts:168-195`). | TEAM -> PROJECT `PermissionGrant` persists the role (`hub/prisma/schema.prisma:428-447`). | Grant/list/archive-induced revoke is HTTP-tested (`hub/test/integration/admin-members-teams.test.ts:123-156`). | **HTTP-backed and tested; UI missing.** Only team grants are surfaced. There is no HTTP management of direct USER/chat grants, `PermissionSettings`, or per-role `RoleTriggerGrant`. |
|
||||
| Organization OWNER/ADMIN manages general project settings, roles/models and agent policies | Only `membersCanCreateProjects` is reachable through GET/PATCH `/settings` (`hub/src/admin/routes/orgRoutes.ts:46-81`). | OWNER/ADMIN route guard. | `OrganizationProjectSettings` stores only that Boolean (`hub/prisma/schema.prisma:78-87`). `PermissionSettings` exists, but no admin route references it (`hub/prisma/schema.prisma:449-467`). Model/role registry is explicitly an in-memory skeleton awaiting DB/admin wiring (`hub/src/agent/models.ts:65-79`). | The Boolean setting is HTTP-tested (`hub/test/integration/admin-auth.test.ts:133-162`); model env behavior is unit-tested (`hub/test/unit/runtime-settings.test.ts:4-64`). | **Partial.** The creation policy works, but the accepted “projects and configuration” control plane lacks editable permission settings, model/role settings and run policies. |
|
||||
| Organization OWNER/ADMIN chooses BYOK vs platform-managed Provider mode; BYOK admin writes key/base URL | None. | No Organization provider guard because no route/service exists. | No Prisma provider connection, credential mode, encrypted secret handle or rotation metadata. The only persistent `provider` fields are descriptive strings on sessions/runs (`hub/prisma/schema.prisma:308-330`, `hub/prisma/schema.prisma:352-387`). | No provider-mode test. Existing tests assert the global env resolver (`hub/test/unit/runtime-settings.test.ts:18-55`). | **Missing + contract divergence.** Lean/ADR have the mode but production always calls `settings.provider("openrouter", { projectId })` (`hub/src/feishu/trigger.ts:195-199`), whose implementation discards the scope (`hub/src/settings/runtime.ts:46-67`). |
|
||||
| Organization OWNER/ADMIN views sessions and runs | HTTP list and detail endpoints exist (`hub/src/admin/routes/sessionsRoutes.ts:14-45`). | OWNER/ADMIN route guard; service scopes project/session through Organization id (`hub/src/org/sessions.ts:6-23`, `hub/src/org/sessions.ts:49-87`). | `AgentSession`/`AgentRun` persist provider/model/status/tokens/cost and relations (`hub/prisma/schema.prisma:308-387`). | No direct test for these HTTP routes or `org/sessions.ts` was found. Trigger tests do prove sessions/runs are written, e.g. separate role sessions (`hub/test/integration/trigger.test.ts:850-876`). | **HTTP-backed and persisted, but untested at the product boundary and UI missing.** |
|
||||
| Organization OWNER/ADMIN views Organization/Project/Folder usage | HTTP Organization and Project usage endpoints exist, with optional folder/date filtering (`hub/src/admin/routes/sessionsRoutes.ts:47-79`). | OWNER/ADMIN route guard. | Rollup derives Organization through Project and uses persisted token/cost facts (`hub/src/org/usage.ts:35-77`, `hub/src/org/usage.ts:106-151`). Missing cost increments `runsWithoutCost`, not known cost (`hub/src/org/usage.ts:106-118`). | No direct usage route/service test was found. `/cost` tests cover the Feishu session command, not these admin endpoints (`hub/test/integration/trigger.test.ts:423-480`). | **HTTP-backed and persisted, but incomplete relative to ADR-0022.** It does not attribute by Provider Connection because none exists, has no alert configuration/delivery, no UI, and no product-boundary tests. |
|
||||
| Ordinary MEMBER creates a Project from an unbound Feishu group | Mentioning the bot in an unbound chat sends an onboarding card (`hub/src/feishu/trigger.ts:850-887`); card action calls shared project creation (`hub/src/feishu/trigger.ts:554-614`). | User must already exist and have an active Organization membership; ordinary creation is gated by `membersCanCreateProjects` (`hub/src/projectOnboarding.ts:149-177`, `hub/src/projectOnboarding.ts:380-403`). Creator receives USER MANAGE and the chat receives EDIT (`hub/src/projectOnboarding.ts:302-340`). | Project, Folder, workspace, binding, default settings and grants are persisted (`hub/src/projectOnboarding.ts:275-348`). | Card display/create/bind is integration-tested (`hub/test/integration/trigger.test.ts:211-330`); policy denial and grants are service-tested (`hub/test/integration/project-onboarding.test.ts:70-112`). | **Feishu-backed and well-tested for the single-app case.** For a user with multiple active memberships, onboarding refuses and says the org selector does not exist (`hub/src/feishu/trigger.ts:889-929`). With a per-Org app, the inbound connection should supply tenant context; current global bot cannot. |
|
||||
| Ordinary MEMBER binds an existing manageable Project | Onboarding card lists unbound projects and card action binds one (`hub/src/feishu/trigger.ts:868-885`, `hub/src/feishu/trigger.ts:932-969`). | Non-admin candidate projects require `collaborator.manage`; bind service rechecks project MANAGE (`hub/src/feishu/trigger.ts:951-960`, `hub/src/projectOnboarding.ts:179-215`). | Binding + FEISHU_CHAT EDIT grant persist transactionally. | Card journey is integration-tested (`hub/test/integration/trigger.test.ts:271-330`). | **Feishu-backed for the single-app case; per-Organization connection routing is absent.** |
|
||||
| Ordinary authorized member triggers an Agent Run from Feishu | Bound message event resolves Project, evaluates `agent.trigger`, resolves role/model/provider, creates session/run and starts the runner (`hub/src/feishu/trigger.ts:684-848`, especially `hub/src/feishu/trigger.ts:715-755` and `hub/src/feishu/trigger.ts:140-256`). | Missing sender id is fail-closed; project EDIT+ and optional role grant are enforced (`hub/src/feishu/trigger.ts:171-194`, `hub/src/feishu/trigger.ts:726-755`). | Session/run, messages, file changes, event receipt and audit fragments persist in Prisma (`hub/prisma/schema.prisma:306-405`, `hub/prisma/schema.prisma:496-525`). | Lifecycle, permission denial, dedup, audit, messages and interrupt have integration coverage (`hub/test/integration/trigger.test.ts:116-154`, `hub/test/integration/trigger.test.ts:597-607`, `hub/test/integration/trigger.test.ts:878-928`, `hub/test/integration/trigger.test.ts:983-1100`). | **Feishu-backed and tested for one process-global provider/Feishu connection.** It is not an Organization-resolved run: provider scope is ignored and bot/identity connection is absent. |
|
||||
| Accepted work waits under Project/Organization/platform capacity and survives restart | Only a process-local queue per Project exists (`hub/src/feishu/triggerQueue.ts:18-45`). It is drained after the active Project run (`hub/src/feishu/trigger.ts:430-495`). | No Organization admission identity or capacity policy is evaluated. | Queue is a `Map` in memory (`hub/src/feishu/triggerQueue.ts:21-26`), not a Prisma model; accepted requests vanish on process exit. Expired entries are dropped and only logged (`hub/src/feishu/trigger.ts:475-483`). | FIFO/full/expiry behavior is unit-tested (`hub/test/unit/trigger-queue.test.ts:10-78`); locked-trigger enqueue/drain is integration-tested (`hub/test/integration/trigger.test.ts:609-673`). | **Contract divergence.** ADR-0022 requires durable bounded admission, Organization-fair scheduling, explicit EXPIRED/CANCELED states and notifications (`docs/adr/0022-layered-capacity-admission.md:33-46`); none are present. |
|
||||
| Platform ceiling + optional lower Organization policy protect request/run/file/storage/entity capacity | None beyond Fastify defaults, a five-item Project queue and global max turns. `Fastify({ logger: true })` has no capacity options/plugins (`hub/src/server.ts:55-55`); dependencies include no rate-limit plugin (`hub/package.json:9-17`). | No admin policy route. | No capacity/limit model. `OrganizationProjectSettings` has only `membersCanCreateProjects` (`hub/prisma/schema.prisma:78-87`). Run policy contains only global `maxTurns` (`hub/src/settings/runtime.ts:24-37`, `hub/src/settings/runtime.ts:74-78`). The systemd unit has no CPU/memory/process controls (`hub/deploy/cph-hub.service:6-26`). | Unit tests cover only global max turns and the local queue (`hub/test/unit/runtime-settings.test.ts:58-64`, `hub/test/unit/trigger-queue.test.ts:5-113`). | **Missing.** None of ADR-0022's mandatory platform/org request rate, body, concurrency, file/archive, storage, entity-count, wall-time/tool/output or process limits are product-reachable or persistently enforced (`spec/Spec/System/Capacity.lean:13-60`). |
|
||||
|
||||
## Reachability summary
|
||||
|
||||
### Production-wired HTTP surface
|
||||
|
||||
The actual production plugin mounts these capability groups:
|
||||
|
||||
- global Feishu OAuth/session: `/auth/feishu`, callback, logout, `/api/me`, and
|
||||
the minimal `/admin/login` HTML (`hub/src/admin/routes/authRoutes.ts:49-207`);
|
||||
- Organization summary and one onboarding Boolean
|
||||
(`hub/src/admin/routes/orgRoutes.ts:27-81`);
|
||||
- Folder/Project explorer and binding archive
|
||||
(`hub/src/admin/routes/explorerRoutes.ts:33-227`);
|
||||
- member roles (`hub/src/admin/routes/membersRoutes.ts:20-98`);
|
||||
- teams/team membership (`hub/src/admin/routes/teamsRoutes.ts:21-139`);
|
||||
- TEAM -> PROJECT grants (`hub/src/admin/routes/accessRoutes.ts:19-79`);
|
||||
- sessions and usage (`hub/src/admin/routes/sessionsRoutes.ts:14-79`).
|
||||
|
||||
Every `/api/org/:orgSlug/*` route uses the same OWNER/ADMIN guard. This satisfies
|
||||
the clarified requirement that a management API cannot be used by an anonymous
|
||||
or ordinary Organization member (`hub/src/admin/auth/guards.ts:43-113`). It does
|
||||
not provide the accepted management **panel**: only `/admin/login` is rendered,
|
||||
and the post-login `/admin/org/:slug` destination is not served.
|
||||
|
||||
### Production-wired Feishu surface
|
||||
|
||||
The trigger handler is a real end-to-end application path for one Feishu app:
|
||||
event receipt/dedup, chat binding, permission checks, unbound-chat onboarding,
|
||||
Project creation/binding, sessions/runs, streaming and interrupt. The service
|
||||
wires the handler to a single SDK client and WebSocket listener
|
||||
(`hub/src/server.ts:76-90`, `hub/src/feishu/client.ts:854-901`). The code and tests
|
||||
therefore establish a useful single-app vertical slice, not the required
|
||||
per-Organization connection architecture.
|
||||
|
||||
### Only database/service/test reachable
|
||||
|
||||
- Organization creation is direct Prisma fixture work
|
||||
(`hub/test/integration/helpers.ts:60-77`).
|
||||
- `PlatformRoleAssignment` is a stored type with no platform auth/HTTP surface
|
||||
(`hub/prisma/schema.prisma:111-129`).
|
||||
- external-directory sync has service code and can create membership rows
|
||||
(`hub/src/permissions/externalSync.ts:40-90`), but no registered production
|
||||
admin route.
|
||||
- project `PermissionSettings` and `RoleTriggerGrant` are stored/enforced in
|
||||
parts of the runtime, but have no Organization management API
|
||||
(`hub/prisma/schema.prisma:449-494`).
|
||||
|
||||
### Entirely absent from runtime persistence/control plane
|
||||
|
||||
- Organization-scoped Feishu application connection and encrypted secret
|
||||
handle;
|
||||
- Organization-scoped Provider Connection, BYOK/platform-managed mode, encrypted
|
||||
secret handle, rotation and runtime resolver;
|
||||
- platform administrator session/guard, platform Organization CRUD, platform
|
||||
admin invitation, platform-operation audit;
|
||||
- versioned platform capacity ceiling loader/validation;
|
||||
- Organization policy-limit rows and Organization admin endpoints;
|
||||
- durable run request/admission state, fair Organization scheduler, queue cancel
|
||||
and expiry notification;
|
||||
- Organization/platform workload brake state and audited actions;
|
||||
- usage/cost alert configuration and delivery;
|
||||
- the Organization and platform management UI.
|
||||
|
||||
## Critical production blockers
|
||||
|
||||
1. **No Organization connection control plane.** The running service has one
|
||||
Feishu app/bot and one provider token/base URL. This contradicts the defining
|
||||
SaaS requirement even though Project/Team data is otherwise Organization-
|
||||
scoped (`hub/src/server.ts:44-52`, `hub/src/settings/runtime.ts:46-67`).
|
||||
|
||||
2. **No platform control plane.** A Platform Administrator cannot authenticate,
|
||||
create or operate an Organization, provision its Feishu app, configure its
|
||||
platform-managed provider connection, or apply a workload brake. The only
|
||||
apparent Organization creation path is direct database/test code.
|
||||
|
||||
3. **No usable management panel.** The backend Organization APIs are meaningful
|
||||
and guarded, but OAuth redirects to an unmounted `/admin/org/:slug`; platform
|
||||
UI does not exist at all (`hub/src/admin/plugin.ts:1-8`,
|
||||
`hub/src/admin/routes/authRoutes.ts:237-267`).
|
||||
|
||||
4. **ADR-0022 is almost wholly unimplemented.** The in-memory Project queue is
|
||||
specifically the implementation divergence ADR-0022 rejects; there are no
|
||||
Organization-fair durable admission, mandatory layered limits, hard run/file/
|
||||
storage/process budgets or emergency brakes
|
||||
(`docs/adr/0022-layered-capacity-admission.md:79-88`).
|
||||
|
||||
5. **Tenant context is inferred too late and from global identifiers.** OAuth
|
||||
creates a globally keyed `User`, bot events bind globally by `chatId`, and an
|
||||
unbound-chat user with more than one Organization is rejected rather than
|
||||
routed by the customer-owned Feishu connection (`hub/prisma/schema.prisma:89-109`,
|
||||
`hub/src/feishu/trigger.ts:715-723`, `hub/src/feishu/trigger.ts:889-929`). This
|
||||
blocks a correct multi-app implementation and also leaves Organization status
|
||||
unenforced on the bot path.
|
||||
|
||||
## What is already worth retaining
|
||||
|
||||
The audit does not imply rewriting the working core. The following seams are
|
||||
concrete and have useful test evidence:
|
||||
|
||||
- Organization ownership on Project/Team/Folder and same-Organization access
|
||||
checks (`hub/prisma/schema.prisma:143-177`, `hub/prisma/schema.prisma:238-285`);
|
||||
- OWNER/ADMIN guards and last-OWNER protection
|
||||
(`hub/src/admin/auth/guards.ts:66-113`, `hub/src/org/members.ts:97-205`);
|
||||
- reusable service operations for project creation, chat binding and grants
|
||||
(`hub/src/projectOnboarding.ts:128-229`, `hub/src/projectOnboarding.ts:275-348`);
|
||||
- Organization-scoped explorer/member/team/team-access HTTP APIs and their
|
||||
integration tests;
|
||||
- Feishu onboarding, project permission checks, session/run persistence and
|
||||
dedup in the single-app vertical slice.
|
||||
|
||||
The shortest path to the accepted destination is therefore to add the missing
|
||||
platform/connection/capacity control planes and real admin UI around these seams,
|
||||
then change ingress and runtime resolution to carry an explicit Organization
|
||||
connection identity end to end. Treating the existing env credentials as an
|
||||
acceptable pilot fallback would preserve the wrong tenant boundary.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Accepted SaaS product-surface completeness audit
|
||||
|
||||
Audit date: 2026-07-10
|
||||
|
||||
## Verdict
|
||||
|
||||
The current Hub is **not a usable or production-complete SaaS product surface**
|
||||
for the accepted destination. It has a worthwhile Organization-scoped backend
|
||||
foundation and a well-tested single-Feishu-application Agent Run slice, but the
|
||||
running product still has one process-global Feishu app/bot and one global model
|
||||
provider credential. It has no platform-administrator control plane, no
|
||||
Organization connection/secret plane, and no Organization-scoped Provider
|
||||
Credential Mode. Its only HTML product page is a login form; successful OAuth
|
||||
redirects an administrator to an unmounted `/admin/org/:orgSlug` route.
|
||||
|
||||
ADR-0022's required production safety surface is also almost wholly absent:
|
||||
there is no durable Organization-fair admission, layered platform/Organization
|
||||
limits, hard file/storage/run/process budgets, usage-alert control plane, or
|
||||
audited workload brake. These are release blockers, not optional polish.
|
||||
|
||||
The strongest code should be retained rather than rewritten: all current
|
||||
`/api/org/:orgSlug/*` routes reauthorize an active OWNER/ADMIN membership;
|
||||
Folder/Project, member, Team, Team-to-Project access, session, and usage services
|
||||
are Organization-scoped; and the shared project-onboarding services correctly
|
||||
create Project/chat/grant relationships for the single-app case. The shortest
|
||||
route is to add explicit Organization connection identity and the two private
|
||||
admin control planes around those seams.
|
||||
|
||||
The claim-by-claim source inventory, including route, schema and test locations,
|
||||
is in [accepted product-surface code inventory](product-surface-code-inventory.md).
|
||||
Official Feishu identifier, OAuth, event and long-connection semantics are in
|
||||
[Feishu product-surface primary sources](product-surface-feishu-primary-sources.md).
|
||||
|
||||
## Accepted boundary
|
||||
|
||||
The audit treats these as required because they are already accepted by the
|
||||
repository's `CONTEXT.md`, ADR-0020, ADR-0021, ADR-0022, and Lean contract:
|
||||
|
||||
- a Platform Administrator is separate from Organization membership and uses a
|
||||
private `/admin/platform` area to create and operate Organizations;
|
||||
- an Organization OWNER/ADMIN uses a private `/admin/org/:orgSlug` area to
|
||||
manage members, projects, Teams, connections, settings, sessions and usage;
|
||||
- every Organization authenticates and exchanges bot/directory traffic through
|
||||
its own customer-owned Feishu Application;
|
||||
- every Organization chooses BYOK or an Organization-distinct platform-managed
|
||||
Provider Connection, with different administrators owning the respective
|
||||
plaintext inputs;
|
||||
- ordinary members can create/bind a Project from an unbound Feishu group only
|
||||
under the Organization policy, while all Project actions retain Project-level
|
||||
authorization;
|
||||
- production has mandatory platform ceilings, optional lower Organization
|
||||
limits, durable fair admission, bounded work and audited emergency brakes.
|
||||
|
||||
The audit does not add deferred self-service signup, payment collection,
|
||||
folder ACL inheritance, commercial billing, or a live editor for platform
|
||||
capacity ceilings. Guided Feishu app setup and manually initiated Organization
|
||||
creation by an authenticated Platform Administrator remain valid for the pilot.
|
||||
|
||||
## Journey result
|
||||
|
||||
| Accepted journey | Current product entry | Result | Owning frontier |
|
||||
| --- | --- | --- | --- |
|
||||
| Platform Administrator signs in | No platform login/session/guard; only a stored `PlatformRoleAssignment` type | **Missing** | tickets 42, 45 |
|
||||
| Platform Administrator creates and operates Organizations | Direct Prisma calls in test fixtures only; no `/api/platform/*` or UI | **Test/database only** | ticket 45 |
|
||||
| Platform Administrator configures each customer Feishu app | One env `FEISHU_APP_ID/SECRET/BOT_OPEN_ID`; no connection or secret model | **Missing and contract-divergent** | tickets 43–45 |
|
||||
| Platform Administrator supplies a distinct platform-managed provider key/base URL | One process-global `ANTHROPIC_AUTH_TOKEN/BASE_URL`; resolver ignores Project scope | **Missing and contract-divergent** | tickets 43, 45 |
|
||||
| Platform Administrator applies and reverses `DRAIN`/`STOP_NOW` | No persistence, guard, API or UI | **Missing** | tickets 37, 45 |
|
||||
| Organization user logs in through that Organization's Feishu app | OAuth exists, but is built once from the global app and has no Organization/application issuer | **Single-app only** | tickets 43, 44 |
|
||||
| Logged-in Organization OWNER/ADMIN opens the management panel | OAuth redirects to `/admin/org/:slug`; no handler/static application serves it | **Stable 404** | ticket 46 |
|
||||
| Organization OWNER/ADMIN manages members, Teams, Projects/Folders and Team access | Guarded Organization JSON APIs and useful integration coverage exist | **Backend present; browser product absent** | ticket 46 |
|
||||
| Organization OWNER/ADMIN manages permission/model/role/run policy | Only `membersCanCreateProjects` is exposed; other stored/runtime settings have no control plane | **Partial** | ticket 46 |
|
||||
| Organization selects Provider Credential Mode and manages BYOK | No mode/connection model, API or UI | **Missing** | tickets 43, 46 |
|
||||
| Organization views sessions and usage | Guarded JSON APIs exist, but have no direct product-boundary tests or UI and cannot attribute Provider Connection | **Partial** | tickets 36, 46 |
|
||||
| Ordinary member creates or binds a Project in Feishu | Card/service path is present and well tested | **Works for the one-global-app case** | ticket 44 for SaaS routing |
|
||||
| Authorized member runs the agent through its Organization bot | Full single-app trigger path exists; provider/app resolution is global | **Works only as a single-app deployment** | tickets 43, 44 plus lifecycle tickets |
|
||||
| Accepted work queues fairly and durably across Organizations | Five-item process-memory Project queue | **Contract-divergent** | tickets 23, 32–35 |
|
||||
| Organization sets lower policy limits and receives soft alerts | No policy/alert persistence or product surface | **Missing** | tickets 32, 34–36, 46 |
|
||||
|
||||
## Executed defect evidence
|
||||
|
||||
The probes used the real Fastify plugin and test PostgreSQL database through a
|
||||
temporary Vitest integration file. The file was removed after capture and was
|
||||
run twice to separate deterministic behavior from a transient test failure.
|
||||
|
||||
### 1. The successful-login destination is not served
|
||||
|
||||
The probe seeded an active Organization ADMIN, minted the normal signed session
|
||||
cookie, and requested the exact destination selected by
|
||||
`resolvePostLoginRedirect`:
|
||||
|
||||
```text
|
||||
GET /admin/org/test-default
|
||||
expected_status=200
|
||||
actual_status=404
|
||||
```
|
||||
|
||||
Both runs returned 404. The root cause is structural: `registerAdminPlugin`
|
||||
mounts cookie, auth and Organization API routes only; its own comment says the
|
||||
static SPA is “later”. No other repository HTML/TSX/Vue/Svelte/CSS product
|
||||
application or deployment-level static handler exists. Existing OAuth tests
|
||||
assert only the redirect `Location`, so they stop immediately before the broken
|
||||
journey.
|
||||
|
||||
This is not an authorization defect in the JSON APIs. Those APIs correctly
|
||||
return 401 without a session and 403 to a MEMBER. It is a reachability defect:
|
||||
there is no management panel for either an authorized or unauthorized request
|
||||
to reach. Ticket 46 must guard both the page shell and its data, so an anonymous
|
||||
user may reach only the login entry and never management content.
|
||||
|
||||
### 2. Different Organizations start OAuth with the same application
|
||||
|
||||
The probe seeded two Organizations and requested the current OAuth entry with
|
||||
two different Organization return paths:
|
||||
|
||||
```text
|
||||
GET /auth/feishu?returnTo=/admin/org/test-default
|
||||
client_id=cli_process_global
|
||||
|
||||
GET /auth/feishu?returnTo=/admin/org/second
|
||||
client_id=cli_process_global
|
||||
```
|
||||
|
||||
Both runs produced the same result. The root cause is that
|
||||
`registerAuthRoutes` constructs one immutable OAuth configuration from plugin
|
||||
startup arguments. The `returnTo` path is signed only as a destination; it does
|
||||
not resolve or bind an Organization Feishu Application. `server.ts` obtains the
|
||||
same app credentials from one environment tuple and also uses them for the only
|
||||
WebSocket listener.
|
||||
|
||||
Changing only the redirect page cannot fix this. The composition root and every
|
||||
identifier-bearing record/action need an explicit Organization connection
|
||||
identity, backed by the encrypted resolver in ticket 43.
|
||||
|
||||
## Feishu identifier and transport consequence
|
||||
|
||||
Feishu's official identifier contract makes the current global identifier
|
||||
shape unsafe for customer-owned applications:
|
||||
|
||||
- `open_id` identifies a user inside one application and differs across apps;
|
||||
- `user_id` is stable inside one tenant but differs across tenants;
|
||||
- `union_id` can link apps from the same application service provider, but the
|
||||
service cannot assume independently customer-owned apps share that provider;
|
||||
- OAuth authorization identifies the app with `client_id`; the callback brings
|
||||
`code` and `state`, so state and token exchange must remain bound to the
|
||||
selected Organization/application;
|
||||
- v2 HTTP event/card headers expose application and tenant identity, while an
|
||||
encrypted outer request must be routed to the correct connection before its
|
||||
verification/decryption key is known; a long connection is itself created by
|
||||
one app credential pair, so the local client/listener identity is the issuer;
|
||||
- Feishu rejects cross-tenant chat/operator combinations and does not provide a
|
||||
contract that permits `chat_id` to be treated as a global SaaS key.
|
||||
|
||||
The current `User.feishuOpenId @unique`, session `{userId, feishuOpenId}`,
|
||||
`ProjectGroupBinding.chatId`, `FeishuEventReceipt.eventId`, principal IDs,
|
||||
sender caches, and batching keys omit this issuer namespace. An `open_id`
|
||||
collision across customer apps could merge unrelated external identities;
|
||||
different `open_id` values for one person across apps cannot be resolved as one
|
||||
global identity automatically. A globally looked-up `chatId` or event ID has
|
||||
the analogous ambiguity.
|
||||
|
||||
The implementation must therefore persist provider-local external identities
|
||||
under a Feishu Application Connection, for example conceptually
|
||||
`(connectionId, externalIdType, externalId)`, and carry that connection through
|
||||
OAuth state/session, ingress, deduplication, chat binding, authorization,
|
||||
onboarding/card actions, rate-limit keys, directory sync, outbound delivery and
|
||||
audit. Whether two external identities represent the same global `User` is a
|
||||
separate explicit linking decision; it must not be inferred from a provider ID
|
||||
outside its documented scope.
|
||||
|
||||
## Root causes rather than symptoms
|
||||
|
||||
### 1. Tenant data was added without tenant-scoping external connections
|
||||
|
||||
Organization ownership is correctly present on Projects, Teams, Folders and
|
||||
directory-connection metadata, but the process composition still comes from
|
||||
the former single-deployment shape. The app/bot/provider credentials and the
|
||||
identifiers they issue never crossed an Organization connection boundary.
|
||||
`RuntimeScope.projectId` exists but is discarded by the env resolver, making
|
||||
the missing seam especially explicit.
|
||||
|
||||
### 2. The control-plane work stopped at guarded Organization APIs
|
||||
|
||||
The Organization routes provide a credible backend slice, but there is no web
|
||||
application, platform route family, platform guard, Organization provisioning
|
||||
service, connection configuration, or browser journey test. The comment
|
||||
“until admin SPA lands” and redirect-only OAuth assertion document the precise
|
||||
unfinished handoff. A small catch-all page would only hide the gap; the fix must
|
||||
deliver the authenticated, authorized workflows and their safe states.
|
||||
|
||||
### 3. Production capacity semantics exist only in the contract
|
||||
|
||||
ADR-0022 intentionally declares the in-memory queue and global-only settings to
|
||||
be implementation divergences. There is no common admission identity that can
|
||||
join Organization, Feishu connection, Project, user and request, so fair
|
||||
scheduling, workload brakes, rate limits, alerts and control-plane operations
|
||||
cannot currently compose. The previously created capacity tickets own this
|
||||
implementation; the admin tickets must expose their safe operator/customer
|
||||
surfaces rather than invent parallel controls.
|
||||
|
||||
## What should remain
|
||||
|
||||
- Keep `requireOrgRole`'s per-request session, active Organization, active
|
||||
membership and OWNER/ADMIN checks; extend the same principle to page data and
|
||||
add a separate `requirePlatformAdmin` rather than an Organization override.
|
||||
- Keep the Organization-scoped explorer/member/Team/access/session/usage APIs
|
||||
as the initial backend for the org panel, while filling their missing tests,
|
||||
pagination/limits, audit and typed-error contracts through existing tickets.
|
||||
- Keep shared Project creation and chat-binding services so web and Feishu
|
||||
entries do not drift into two rule sets.
|
||||
- Keep the single-app Feishu handler's authorization, onboarding, run/session
|
||||
and dedup behavior, but call it from a connection-bound listener context and
|
||||
move accepted work/delivery into the durable lifecycle tickets.
|
||||
- Keep missing provider cost unknown rather than fabricating zero; add Provider
|
||||
Connection attribution before presenting SaaS usage claims.
|
||||
|
||||
## Newly clear execution frontier
|
||||
|
||||
The product audit creates four implementation/decision slices after the
|
||||
existing secret and browser-boundary design work:
|
||||
|
||||
1. [Decide the platform-administrator identity and audit boundary](../issues/42-decide-platform-admin-identity-audit.md).
|
||||
2. [Implement the Organization connection and secret plane](../issues/43-implement-org-connection-secret-plane.md).
|
||||
3. [Route Feishu identity and traffic by Organization connection](../issues/44-route-feishu-by-org-connection.md).
|
||||
4. [Build the private platform-administrator control plane](../issues/45-build-private-platform-admin-control-plane.md).
|
||||
5. [Build the private Organization-administrator control plane](../issues/46-build-private-org-admin-control-plane.md).
|
||||
|
||||
The Feishu routing slice precedes durable inbound/action/outbound and signed
|
||||
traffic limiting so those mechanisms use the right issuer namespace from their
|
||||
first durable schema. The platform panel precedes the emergency-brake product
|
||||
entry so ticket 37 can reuse one authenticated/audited operator boundary.
|
||||
Existing tickets 32–38 continue to own capacity enforcement, attribution,
|
||||
alerts, brakes and calibration; the new UI tickets do not duplicate them.
|
||||
|
||||
## Required production journey evidence
|
||||
|
||||
The final release gate must run against at least two configured customer-owned
|
||||
Feishu applications and two Organizations, using deliberately colliding fake
|
||||
provider-local IDs where the test harness permits. It must prove:
|
||||
|
||||
1. an unauthenticated request and an authenticated wrong-role user cannot load
|
||||
either management page data or perform mutations;
|
||||
2. a Platform Administrator can create an Organization, bootstrap its OWNER,
|
||||
configure/test its Feishu app and distinct platform-managed Provider
|
||||
Connection, with redacted responses and complete audit history;
|
||||
3. an Organization OWNER/ADMIN logs in through that Organization's app, reaches
|
||||
the real org panel, manages the accepted settings/resources, configures BYOK
|
||||
when selected, and cannot read another Organization or platform secret;
|
||||
4. each bot receives, binds, authorizes, queues and replies only inside its
|
||||
connection's Organization even when `open_id`, `chat_id`, event or callback
|
||||
values collide; SUSPENDED/ARCHIVED Organizations fail closed;
|
||||
5. provider resolution for every run returns that Organization's selected
|
||||
connection and never a process-global fallback;
|
||||
6. current member onboarding, Project authorization and Agent Run behavior
|
||||
remain intact after connection namespacing;
|
||||
7. layered limits, fair admission, usage alerts and `DRAIN`/`STOP_NOW` are
|
||||
visible only to the corresponding authorized control plane and behave as
|
||||
proven by their dedicated fault/capacity suites;
|
||||
8. the complete browser + Feishu journey survives the production-shaped deploy,
|
||||
restart, backup/restore and rollback evidence owned by ticket 08.
|
||||
@@ -0,0 +1,333 @@
|
||||
# Feishu multi-app identity and event-routing primary sources
|
||||
|
||||
This note answers one narrow design question for the production SaaS audit:
|
||||
what identity and routing boundaries apply when **every Hub Organization uses
|
||||
its own Feishu App and bot**. External claims below use only first-party Feishu
|
||||
Open Platform documentation. Statements labelled **Derived requirement** are
|
||||
Hub design implications, not guarantees made by Feishu.
|
||||
|
||||
Research date: 2026-07-10.
|
||||
|
||||
## Executive answer
|
||||
|
||||
1. A customer-owned enterprise custom app is a single-tenant app: Feishu says
|
||||
it is developed, reviewed, published, and used inside one tenant and cannot
|
||||
be used by another organization. That fits “one Organization, one Feishu
|
||||
App,” but it means the Hub is operating many independent app security and
|
||||
identity domains, not one shared Feishu identity domain.
|
||||
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
|
||||
2. A bare `open_id` is **not** a SaaS-global user key. It identifies a user
|
||||
inside one app, and the same user has different Open IDs in different apps.
|
||||
`user_id` is tenant-scoped; it is stable across apps in one tenant but
|
||||
differs across tenants. `union_id` crosses apps only for apps from the same
|
||||
application provider/developer; it differs between providers.
|
||||
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN),
|
||||
[official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN),
|
||||
[Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
|
||||
3. OAuth must be routed to the Organization's app **before** authorization.
|
||||
The authorization request contains that app's App ID as `client_id`; the
|
||||
callback contains `code` and the echoed `state`, not an independently
|
||||
trustworthy Organization choice. Exchanging the code requires the matching
|
||||
app's `client_id` and `client_secret`, and the same `redirect_uri`.
|
||||
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
|
||||
[obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
|
||||
4. A version 2 event or callback carries `header.app_id` and
|
||||
`header.tenant_key` after parsing/decryption. An encrypted HTTP delivery,
|
||||
however, exposes only an `encrypt` envelope before decryption, so a shared
|
||||
endpoint cannot safely choose among many apps' Encrypt Keys from the body.
|
||||
([callback structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview),
|
||||
[encrypted Webhook delivery](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
|
||||
5. A WebSocket/long-connection client is authenticated with one app's App ID
|
||||
and App Secret. Therefore its local client instance is the pre-authenticated
|
||||
source-app route. It is not a process-global “all customer apps” listener.
|
||||
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
|
||||
6. Feishu documents `chat_id` as the conversation identifier, but the cited
|
||||
documentation does not promise that a raw `chat_id` is globally unique
|
||||
across every tenant and app. Chat APIs explicitly reject an operator and
|
||||
chat from different tenants and often require the app bot to be in the
|
||||
chat. A bot is represented by its application's `app_id`.
|
||||
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
|
||||
[get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN),
|
||||
[message resource](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
|
||||
|
||||
## 1. Application and tenant boundary
|
||||
|
||||
### Official facts
|
||||
|
||||
- `app_id` is the immutable identifier generated by Feishu for an application.
|
||||
`tenant_key` is the enterprise identifier; one `tenant_key` corresponds to
|
||||
one enterprise in actual use.
|
||||
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology))
|
||||
- An enterprise custom app is restricted to one tenant. Its developers,
|
||||
administrators, and users belong to that tenant, and another organization
|
||||
cannot use it. A store app is the contrasting model that can be installed by
|
||||
multiple tenants.
|
||||
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
|
||||
- Access tokens tell Feishu which app/user is calling and which tenant's data
|
||||
is being accessed. A `tenant_access_token` calls as the application and its
|
||||
data range is determined by that application's permissions.
|
||||
([access-token overview](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
|
||||
- A custom app obtains its `tenant_access_token` using that app's `app_id` and
|
||||
`app_secret`.
|
||||
([custom-app tenant access token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN))
|
||||
|
||||
### Derived requirements
|
||||
|
||||
- Model a durable `FeishuAppConnection` (or equivalent) owned by exactly one
|
||||
Hub Organization. At minimum it binds the internal connection ID,
|
||||
`organization_id`, `app_id`, expected `tenant_key`, encrypted `app_secret`,
|
||||
event/callback credentials, lifecycle state, and credential version.
|
||||
- Do not select Feishu credentials from a process-global default. Every OAuth
|
||||
exchange, token cache entry, OpenAPI call, Webhook verification, and
|
||||
long-connection client must start from the Organization's connection.
|
||||
- Pin the first verified `tenant_key` to the connection. Later login, event,
|
||||
and callback data must match both the expected `app_id` and pinned
|
||||
`tenant_key`; unknown or mismatched pairs must be rejected rather than
|
||||
auto-attached to another Organization.
|
||||
|
||||
## 2. User identifier scopes
|
||||
|
||||
### Official scope matrix
|
||||
|
||||
| Identifier | Feishu's documented scope | Cross-app/tenant behavior | Availability caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `open_id` | User identity inside one application | The same user has a different `open_id` in different apps | The normal app-relative user identifier |
|
||||
| `user_id` | User identity inside one tenant | Stable across all apps in the same tenant; different in tenant A and tenant B | Reading it is a sensitive field permission and is limited to custom apps in the cited user API |
|
||||
| `union_id` | User identity across apps from the same application provider/developer | Same across that provider's apps; different across different providers | It does not make unrelated customer-owned apps one identity domain |
|
||||
| `tenant_key` | Enterprise identity | One key corresponds to one enterprise | Returned by login and carried in events/callbacks |
|
||||
|
||||
Sources:
|
||||
|
||||
- Feishu's current user resource overview states that `user_id` is tenant
|
||||
identity, `open_id` is app identity, and `union_id` is identity across apps
|
||||
developed by the same application provider. It expressly says the same
|
||||
user's Open ID differs between apps.
|
||||
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN))
|
||||
- Feishu's API parameter text is more explicit: the same user's User ID differs
|
||||
between tenants but remains the same across apps within one tenant; the same
|
||||
user's Union ID is the same under one developer and different under another.
|
||||
([official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN))
|
||||
- Feishu describes Union ID as the identity linking one user across multiple
|
||||
applications supplied by the same application service provider.
|
||||
([Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
|
||||
- Feishu's common-error guidance says not to use an Open ID obtained from app A
|
||||
in app B, and even a test app and its production app produce different Open
|
||||
IDs for the same user. It also reports cross-tenant errors for both User ID
|
||||
and Union ID use.
|
||||
([common errors](https://open.feishu.cn/document/ukTMukTMukTM/ugjM14COyUjL4ITN?op_tracking=hc))
|
||||
- The current login user-info API returns `open_id`, `union_id`, optional
|
||||
sensitive `user_id`, and `tenant_key` from a `user_access_token`.
|
||||
([get login user information](https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?lang=zh-CN))
|
||||
|
||||
### Answer: can different customer Apps share one global `open_id` key?
|
||||
|
||||
**No.** This directly contradicts Feishu's documented Open ID semantics. Two
|
||||
customer-owned custom apps are different applications, so the same person has
|
||||
different Open IDs in them. Because those apps are independently owned custom
|
||||
apps, the Hub also has no documented basis to assume they are applications of
|
||||
one application provider and therefore no basis to auto-merge on `union_id`.
|
||||
`user_id` cannot solve cross-customer identity either because it changes across
|
||||
tenants.
|
||||
|
||||
Derived identity model:
|
||||
|
||||
- The safe external login key is `(feishu_app_connection_id, open_id)`; retain
|
||||
`tenant_key`, `user_id`, and `union_id` as scoped attributes, not as an
|
||||
unqualified SaaS-global primary key.
|
||||
- If the product needs one Hub human account to participate in multiple
|
||||
Organizations, represent each Feishu login as a separate external identity
|
||||
attached through an explicit, separately verified account-linking flow. Do
|
||||
not silently merge on Open ID, Union ID, email, phone, or display name.
|
||||
- Do not make `user_id` mandatory for login unless every customer app is
|
||||
required to obtain its sensitive field permission. `open_id` and
|
||||
`tenant_key` are returned by the user-info flow without using `user_id` as
|
||||
the login key.
|
||||
- Replacing an Organization's Feishu App changes the application identity
|
||||
domain and therefore its users' Open IDs. Treat app replacement as an
|
||||
explicit connection migration/re-link operation, not an in-place secret
|
||||
edit.
|
||||
|
||||
## 3. OAuth login must be app-routed
|
||||
|
||||
### Official facts
|
||||
|
||||
- The authorization URL requires `client_id`, which is the selected
|
||||
application's App ID. `redirect_uri` must already appear in that
|
||||
application's security settings. On success Feishu redirects with `code`
|
||||
and, when supplied, the original `state`.
|
||||
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code))
|
||||
- Feishu says `state` maintains request/callback context and must be compared
|
||||
to prevent CSRF. Its official sample rejects a callback whose state differs
|
||||
from the stored state.
|
||||
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
|
||||
[user-access-token sample](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
|
||||
- The code lasts five minutes and can be used once. Token exchange requires
|
||||
`client_id`, `client_secret`, `code`, and, when used during authorization,
|
||||
the same `redirect_uri`. Feishu defines error 20024 for a code/refresh token
|
||||
that does not match the supplied client ID and error 20071 for a mismatched
|
||||
redirect URI.
|
||||
([obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
|
||||
- Only redirect URLs configured for the selected application pass Feishu's
|
||||
security check; an app may configure multiple URLs.
|
||||
([access-token acquisition flow](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
|
||||
|
||||
### Derived request flow
|
||||
|
||||
1. Begin at an Organization-qualified route or invitation, resolve exactly one
|
||||
active `FeishuAppConnection`, and create a short-lived, one-time server-side
|
||||
login transaction bound to that Organization, connection, redirect URI,
|
||||
desired return path, and PKCE verifier if used.
|
||||
2. Put only a high-entropy opaque transaction nonce in OAuth `state`; generate
|
||||
the authorization URL with that connection's `app_id` as `client_id`.
|
||||
3. On callback, atomically consume and validate `state` before selecting
|
||||
credentials. The callback's untrusted query parameters must never be
|
||||
allowed to choose another Organization or App.
|
||||
4. Exchange `code` with the connection's `app_id`, decrypted `app_secret`, and
|
||||
the exact stored redirect URI. Then call user-info and require its
|
||||
`tenant_key` to equal the connection's pinned tenant.
|
||||
5. Resolve or create the membership through
|
||||
`(feishu_app_connection_id, open_id)`. Never search all Organizations for a
|
||||
bare Open ID and accept the first match.
|
||||
|
||||
A single physical callback URL may be configured independently in multiple
|
||||
apps, but a shared URL does not remove the need for state-bound app routing.
|
||||
|
||||
## 4. HTTP event and callback routing
|
||||
|
||||
### Official facts
|
||||
|
||||
- A version 2 callback header contains the application's Verification Token,
|
||||
`event_type`, `tenant_key` (the callback application's tenant), and `app_id`
|
||||
(the callback application's App ID).
|
||||
([callback overview and structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview))
|
||||
- Version 2 event examples likewise place `app_id` and `tenant_key` in the
|
||||
event header.
|
||||
([user-leaves-chat event](https://open.feishu.cn/document/server-docs/group/chat-member/event/deleted-2))
|
||||
- Verification Token is an application validation identifier. Feishu says the
|
||||
server can compare the event's token with that application's token to check
|
||||
that a request is for the specified app. With an Encrypt Key, the request
|
||||
signature is calculated from request timestamp, nonce, that app's Encrypt
|
||||
Key, and the original request body.
|
||||
([receive and verify events](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case))
|
||||
- When an Encrypt Key is configured, the HTTP request body visible before
|
||||
decryption is an envelope containing only `{"encrypt":"..."}`; `app_id`
|
||||
and `tenant_key` are inside the ciphertext.
|
||||
([send events to developer server](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
|
||||
|
||||
### Derived routing and verification requirements
|
||||
|
||||
- Give every Feishu connection a stable, unguessable callback route such as a
|
||||
connection-specific path token. Resolve the connection from that route,
|
||||
load exactly its Encrypt Key/Verification Token, verify and decrypt, then
|
||||
require the authenticated payload's `app_id` and `tenant_key` to match the
|
||||
route's connection.
|
||||
- For an unencrypted common endpoint, `header.app_id` may identify a candidate
|
||||
connection, but it is untrusted until that connection's signature/token has
|
||||
been verified. Never route the side effect solely from an unverified body.
|
||||
- A single encrypted URL shared by every customer app has no documented
|
||||
pre-decryption app selector. Trying every tenant's secret is not an identity
|
||||
protocol. Use connection-specific routing instead.
|
||||
- Namespace delivery deduplication by the authenticated connection (for
|
||||
example `(connection_id, event_id)`), and dispatch business work only after
|
||||
app/tenant verification. Unknown, disabled, or mismatched connections fail
|
||||
closed and produce an auditable security log.
|
||||
|
||||
## 5. Long-connection routing
|
||||
|
||||
### Official facts
|
||||
|
||||
- Feishu's SDK creates a WebSocket client with one app's required `APP_ID` and
|
||||
`APP_SECRET`. Events subscribed by that application are then delivered over
|
||||
that channel. Authentication is performed when the connection is
|
||||
established.
|
||||
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
|
||||
- Long-connection event subscription supports enterprise custom apps, not
|
||||
store apps. Each app may establish at most 50 connections. If one app has
|
||||
multiple clients, delivery is cluster-mode rather than broadcast: one random
|
||||
client receives a message.
|
||||
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
|
||||
- Long-connection callbacks use the same per-app App ID/App Secret client
|
||||
construction and have the same cluster-mode single-recipient behavior.
|
||||
([long-connection callback subscription](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/configure-callback-request-address?lang=zh-CN))
|
||||
|
||||
### Derived routing requirements
|
||||
|
||||
- Create one logical client binding per enabled `FeishuAppConnection`, even if
|
||||
an in-process supervisor manages many clients. Attach immutable
|
||||
`organization_id` and `connection_id` context to each handler; the client
|
||||
that received the frame is the pre-authenticated application route.
|
||||
- Never infer the receiving Organization by looking up a bare actor Open ID or
|
||||
chat ID. First use the authenticated client binding; then cross-check any
|
||||
delivered `app_id`/`tenant_key` fields available in the event structure.
|
||||
- Scaling several replicas for the same app does not create duplicate
|
||||
broadcast processing according to Feishu's contract, but delivery still
|
||||
requires application-level idempotency and health ownership. Capacity must
|
||||
count clients per app against the documented 50-connection ceiling.
|
||||
|
||||
## 6. Bot, chat, and message identifiers
|
||||
|
||||
### Official facts
|
||||
|
||||
- In the message resource, a sender can be a user or an application. For an
|
||||
application sender, `id_type` is `app_id`; the sender also carries a
|
||||
`tenant_key`. Thus the Feishu bot identity used by messaging is its
|
||||
application identity, not a Hub-global bot ID.
|
||||
([message management overview](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
|
||||
- `chat_id` identifies a conversation, including direct and group chats.
|
||||
Feishu's group resource also exposes the chat's `tenant_key`.
|
||||
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
|
||||
[group management overview](https://open.feishu.cn/document/server-docs/group/chat/intro?lang=zh-CN))
|
||||
- Chat APIs require the operator to be in the same tenant as an internal chat;
|
||||
common errors include `Operator and chat can NOT be in different tenants`.
|
||||
Many operations additionally require the calling app's bot to be in the
|
||||
group and reject an app that is unavailable in that tenant.
|
||||
([get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN))
|
||||
- The first bot direct-chat event calls `chat_id` the conversation ID between
|
||||
**that robot and user** and includes `app_id`, `tenant_key`, and app-scoped
|
||||
user Open IDs in the same event.
|
||||
([user and bot first conversation](https://open.feishu.cn/document/server-docs/group/chat-member/event/bot-events?lang=zh-CN))
|
||||
|
||||
### What the official sources do not establish
|
||||
|
||||
The cited documentation calls `chat_id` a conversation/group identifier but
|
||||
does not state that its value is globally unique across every Feishu tenant and
|
||||
every independently created app. It does establish tenant and bot-membership
|
||||
authorization boundaries. Under the repository rule that unspecified scope
|
||||
must not be assumed, raw global uniqueness would be an unsupported inference.
|
||||
|
||||
Derived storage/call rules:
|
||||
|
||||
- Store chats as `(feishu_app_connection_id, chat_id)` and retain observed
|
||||
`tenant_key` and chat type. Do the same for other resource identifiers unless
|
||||
Feishu explicitly contracts a wider scope.
|
||||
- Always call chat/message APIs with the token obtained from the chat's bound
|
||||
connection. Never retry an authorization failure with another Organization's
|
||||
token.
|
||||
- Treat bot direct chats as connection-specific. A `chat_id` for user X's
|
||||
conversation with Organization A's bot is not evidence of a conversation
|
||||
with Organization B's bot.
|
||||
- For group chats, two customer apps may both be members of a real Feishu
|
||||
group, but that does not authorize the Hub to move configuration, Projects,
|
||||
or memberships between Organizations. The authenticated connection remains
|
||||
the tenant route and Hub Organization remains the authorization root.
|
||||
|
||||
## Minimum invariants for implementation review
|
||||
|
||||
1. `FeishuIdentity` uniqueness is `(connection_id, open_id)`, never bare
|
||||
`open_id`.
|
||||
2. OAuth `state` resolves one server-side transaction bound to one Organization
|
||||
and connection; code exchange uses only that connection's credentials and
|
||||
stored redirect URI.
|
||||
3. Login accepts the returned identity only when `tenant_key` matches the
|
||||
connection's pinned tenant.
|
||||
4. HTTP Webhooks choose a connection-specific verification/decryption context
|
||||
before trusting payload routing fields; authenticated `app_id` and
|
||||
`tenant_key` must match it.
|
||||
5. Every long-connection handler carries the local connection/Organization
|
||||
binding of the App ID/App Secret client that received the frame.
|
||||
6. Bot, chat, event-deduplication, token-cache, and user identifiers are all
|
||||
namespaced by the Feishu connection unless Feishu explicitly guarantees a
|
||||
wider scope.
|
||||
7. A credential, app, or tenant mismatch is a hard failure with traceable
|
||||
logging; there is no fallback to a default app or search across tenant
|
||||
secrets.
|
||||
Reference in New Issue
Block a user