Skip to content

[Decision] 应用受众姿态 — 业务用户如何进入 ObjectStack 建的应用(平台级默认,今天=全开+不验证) #11723

Closed
2 of 2 issues completed

Description

@os-zhuang

维护者 2026-08-24 指示(原话):「你不能只考虑这个环境,我们是一套开发工具,帮我使用项目经理技能四维综合分析,给我建议。」

问题(平台级,不是单环境)

「用 ObjectStack 建的应用,业务用户怎么进来」今天没有一个可声明的答案。它是 4-5 个互不相干的开关叠加出来的涌现属性:emailAndPassword.disableSignUp、emailVerification、ssoOnlyMode(spec AuthConfig)、plugin-auth 的 membershipPolicy,外加自助注册者落到的隐式 fallback 权限集。没有名字,没有单一强制点,没人「选择」过这个组合。

实测事实(2026-08-24)

  1. 全平台默认 = 全开。租户 kernel(cloud/packages/objectos-runtime/src/artifact-kernel-factory.ts)向 AuthPlugin 传 secret/baseUrl/trustedOrigins/oidcProviders/ssoOnlyMode(仅 enforced)/scim(仅够格套餐)/autoDefaultOrganization(仅镜像组织时),从不传 disableSignUp 或 requireEmailVerification → 双双落框架默认。实测某生产环境 /api/v1/auth/config:disableSignUp:false、requireEmailVerification:false、ssoEnforced:false。这是舰队默认,不是某个环境的配置失误。
  2. 注册即入组织。reconcileMembership 挂在 better-auth user.create.after(每条建用户路径都过),策略默认 'auto'(auth-manager getMembershipPolicy()),单组织环境的目标 = 该环境唯一组织 → 自助注册者自动成为 member。
  3. 没有域名白名单。全仓零命中(邻近词 disableSignUp 在同文件命中,反查成立)。
  4. AI 建的应用无法声明受众。AI blueprint 无任何 auth/受众面 → AI 生成的每一个应用都继承「全开+不验证」,且 AI 结构上无从做对。
  5. 事后补救能力也缺:该环境 features.admin:false(better-auth admin 插件未挂)→ 无停用/封禁用户开关。开着自助注册 + 无法踢人。

选项

  • A(推荐)——受众姿态词表:把「谁可以成为这个应用的用户」收成一个声明,闭合词表 invite_only | email_domain | open,一处强制;不变量:非 invite_only 时强制邮箱验证;自助注册者获得的权限集必须显式声明;新环境默认 invite_only,存量环境保持现状 + 控制台醒目提示 + 一键切换。
  • B——只加一个域名白名单字段:最省事,但「受众」继续是涌现属性,AI 依然不会设,默认依然全开。
  • C——不改代码,写文档教客户设 disableSignUp:零成本,默认仍全开。
  • D——推 SSO/企业 IdP:对内部业务应用过重,且 SSO 是 Business+ 付费面,免费/低价客户无解。

四维分析

实际业务需求 — 真实且是舰队级,不是投机面。每个客户建的每个应用,第一天就撞这堵墙:要么放业务用户进来(今天=谁都能进),要么进不来。维护者本人作为平台第一个客户已经撞上。反向证据也成立:确有「公开门户」类应用需要 open,所以答案不是「一律关死」,而是让应用声明自己的受众。

项目长远合理性 — 现状正是仓里明令拒绝的 workaround 形状:一个语义(受众)散在五处、无单一强制点。北极星是元数据声明 + declared=enforced,受众理应是一个声明,和 membershipPolicy 同款(闭合词表、越界值响亮拒绝、单一访问器)。开源边界也对得上:机制(词表+强制)属开放 framework,付费情报(企业 IdP/SCIM/按套餐 enforce)留 cloud。不做的长期代价:所有客户应用默认裸奔,第一次真实事故是平台级声誉事件——因为默认是我们给的,不是客户配错的。

防 AI 写代码犯错(尤其防 AI 写元数据 app) — 本案最强的一轴。AI blueprint 完全没有受众面,所以 AI 生成的应用必然继承全开默认,且 AI 无从声明、无从做对。若按 B 加一个可选字段,AI 只会永远不设它——同一个失效模式换了层皮。正确形状是契约收紧:受众是带安全默认的声明,配 publish 时的完备性谓词(ADR-0078 家族:「合法但失效的元数据」正好覆盖「发布了一个没人选过受众的应用」),而不是消费端宽容。

创业阶段不扩散需求 — 最小闭合:①闭合词表姿态(环境级,不做 per-app);②非 invite_only 强制邮箱验证;③显式声明自助注册者的权限集;④新环境安全默认。明确不做:token join link、审批队列、per-app 姿态、按链接分角色、SCIM 交互、自定义 onboarding 流。值得注意的是:A 的实现量小于已冻结的 cloud-org join link epic,更便宜的选项恰好在另外三轴上也更优。

推荐:A。四轴同向。

需要维护者拍板的子决策(存量迁移形状 = 人工地板)

存量环境今天全开。新环境默认 invite_only + 存量保持现状(醒目提示 + 一键切换)(推荐,不静默翻转已部署行为),还是设一个日期强制翻转?后者会在客户毫无准备时打断其真实用户的注册,前者留下一段「已知不安全但已告知」的窗口。

关联

冻结中的 cloud-org join link epic #11586 是协作者层(控制面组织成员),与本卡的业务用户层(租户环境用户)是两层;维护者已裁定协作者层手工加入即可。本卡若通过,epic 的 token 形状(过期/撤销/限次)可在未来 link 姿态复用。

RULED (maintainer, 2026-08-24, chat — quoted verbatim)

「新环境默认 invite_only,存量也默认切换到 invite_only,按这个拆卡派发」
「目前云还没正式上线」

Option A is adopted, and the migration sub-decision is settled MORE strictly than this card's own recommendation: existing environments are switched to invite_only too, not grandfathered.

PUBLIC CORRECTION. An earlier revision of this body (posted minutes before, same session) recorded the opposite migration shape — "新环境默认 invite_only,存量保持现状加提示" — as ruled. That was superseded within the same conversation once the maintainer supplied the decisive fact: the cloud has not launched yet. With no production tenants to interrupt, the grandfathering window this card recommended buys nothing and costs the platform a permanently unsafe default. The warning-banner remediation surface and the "undeclared = legacy, behave as open" limbo are dropped from scope — do not implement them.

Design consequences of the ruling (not new decisions)

  1. The framework's own default becomes invite_only. No legacy/undeclared limbo: an undeclared posture IS invite_only. This is the safe-by-default shape axis 3 (防 AI 写元数据 app 犯错) asks for — an AI-generated app that declares no audience cannot ship open.
  2. Breaking-change posture: minor, not major. Verified against scripts/check-changeset-no-major.mjs: the repo is inside its launch window and ships breaking changes as minor under pre-1.0 semantics (every publishable package is in the Changesets fixed group, so one major would promote the whole monorepo). A minor changeset with a loud release note is the correct tier; ⛔ do not introduce a major.
  3. Cloud backfills existing environments to invite_only rather than leaving them undeclared, so the fleet is uniform and the change is auditable.
  4. Self-registration does not disappear — it becomes something an operator declares (email_domain or open), which is the whole point of the vocabulary.

Epic

Coordination node. Never dispatched. Epic PM session: session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6 (maintainer direct-dispatch channel; the spec-touching sub-issue is dispatched by this seat under the same instruction rather than transferred to the spec seat — deviation recorded here).

Territory: objectstack packages/spec/src/system/**, packages/plugins/plugin-auth/src/**, packages/platform-objects/src/**; cloud packages/service-tenant/**, packages/service-cloud/**, packages/objectos-runtime/**.

Sub-issues (contract-first, one per repo):

  • A — objectstack: posture vocabulary in spec + single enforcement point + forced email verification + declared self-service permission set + completeness predicate + default invite_only (Clause-② YES)
  • B — cloud: new-env provisioning + backfill of existing envs to invite_only, posture switch surface, pin bump, E2E — Blocked-by A

Activity

  1. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling recorded

    Ruled 2026-08-24, live PM chat, verbatim: 「其他按照你的建议继续」 (adopting the skills seat's four-facet digest, which recommended A with the no-forced-flip stock treatment).

    Ruled: A — the audience-posture vocabulary, with the stock sub-decision settled as recommended:

    1. One declaration answers "who can become this app's user": closed vocabulary invite_only | email_domain | open, one enforcement point, out-of-vocabulary values refused loudly (the membershipPolicy shape).
    2. Invariant: any posture other than invite_only forces email verification.
    3. The permission set a self-registrant receives must be explicitly declared — the implicit fallback retires.
    4. New environments default invite_only; existing environments keep current behavior + a prominent console notice + one-click switch. ⛔ No forced-flip date — silently breaking live user registration on deployed stacks is rejected.
    5. ⛔ Not in scope (recorded so it stays out): token join links, approval queues, per-app posture, per-link roles, SCIM interplay, custom onboarding flows. The frozen cloud-org join-link epic [Epic] Universal org invite link — shareable self-serve join (V1) #11586 stays frozen; a future link posture may reuse its token shape.

    Implementation is Clause-② (spec AuthConfig surface + enforcement + publish-time completeness predicate per the ADR-0078 family) — contract-review tier on the PR; spec/services surfaces to be split by the owning seats at claim time. needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  2. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Progress — sub-issue A is MERGED.

    What actually shipped, against what this epic ruled: the closed vocabulary invite_only | email_domain | open with invite_only as the undeclared default, one decision owner on the creation path, forced email verification for every self-registration-permitting posture, a declared self-registrant permission set that is actually granted, and the ADR-0087 semantic migration audience-posture-default-invite-only registered for the secure-default flip. Shipped minor per the launch-window rule, as ruled.

    Two things learned in flight that the epic did not anticipate, both now closed in the merged work:

    1. better-auth 1.7.1's sign-up route converts a 403 from the validateUserInfo admission seam into a synthetic 200 whenever requireEmailVerification is on — and this epic's own invariant forces verification on for exactly the postures that gate self-registration. The wall would have been inert precisely where it matters, with nothing red. Caught by measurement, fixed by raising the same decision in the /sign-up/email before-hook.
    2. The framework's pre-existing "a fresh install must never lock its operator out" bootstrap bypass never fired on any real deployment: its probe called findOne with no predicate, which the real ObjectQL engine refuses (ObjectQL silently drops unsupported predicate keys; findOne then returns the first row #4419), and the surrounding catch read that refusal as "users exist". The audience gate inherited the same dead probe and turned it into red CI. Both now route through one answerable, fail-closed helper, and isHumanUserRow is the single owner of the population predicate.

    Findings filed along the way: objectstack#11957 (engine doubles answer a predicate-less findOne the real engine refuses — why a 641-line green matrix was blind to #2), objectstack#11959 (a third hand-spelled copy of the human-user predicate in plugin-security), objectstack#11770 (the invitation carve-out stops admitting past 200 pending invitations).

    Adjacent P0 opened while verifying this epic's outcome locally: objectstack-ai/cloud#1653 — a new user can sign up on production and then cannot create their workspace, because the control plane's degraded tenancy posture makes organization/create refuse. It is not part of this epic, but it gates whether any of this reaches a new customer at all.

  3. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    ContributorAuthor

    Epic complete — closing. Both sub-issues landed and verified:

    The business outcome the epic was opened for: an operator can flip an environment to email_domain with their company domains and post a registration link in any group chat — the gate is on the domain, not on link secrecy — or leave it invite_only (the fleet default) where nobody self-registers at all. One residual verification is scheduled, recorded on cloud#1649's ACCEPT: this seat reads a freshly provisioned staging env's /api/v1/auth/config after the next timer deploy to confirm audiencePosture: 'invite_only' lands on a real tenant kernel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions