Skip to content

Commit 035951f

Browse files
baozhoutaoclaude
andauthored
fix(devx): re-pin the publish smoke to the declared first-run contract (#14255)
The packed-tarball smoke asserted that the first POST /auth/sign-up/email after `objectstack dev --fresh` returns 200 — an expectation authored before #11739/#11767 made `invite_only` the default audience posture. It is not the first account that reaches that probe. `--fresh` seeds a dev admin in-process at boot through the real signUpEmail pipeline, and THAT creation is the zero-user one the bootstrap bypass admits. The smoke's own sign-up is the SECOND self-serve account, which invite_only refuses with SELF_REGISTRATION_CLOSED + 403 — correctly. The release candidate had therefore never once passed this gate with the posture default aboard. Per the maintainer-approved ruling (option A, director batch #23): - assert the seeded admin can SIGN IN, which is the packed install's proof that the bootstrap bypass really fired; - assert the uninvited second self-serve sign-up is REFUSED, pinning both the 403 and the envelope code so an accidental widening of the unauthenticated surface reds the RC instead of shipping; - obtain the non-admin probe user through the operator path (the invitation carve-out) before the existing session/CRUD probes. Behaviour only — no vendor-internal symbol is pinned, and no auth or audience runtime code is touched. The posture is READ from the artifact's own public /auth/config rather than hard-coded, because the two smoke modes install different products: `registry` smokes the last PUBLISHED release, and plugin-auth 17.2.0 (2026-08-23) predates #11767 (2026-08-25). In `pack` mode the advertised posture must BE invite_only; in both modes enforcement must match what the artifact advertises. Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV Co-authored-by: Claude <noreply@anthropic.com>
1 parent d18bedd commit 035951f

1 file changed

Lines changed: 201 additions & 15 deletions

File tree

scripts/publish-smoke.sh

Lines changed: 201 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,64 @@
2626
# Both modes then run the scaffolded project's own `build` script and assert it
2727
# exits 0 — the step section 1b explains, and the one this gate used to lack.
2828
#
29-
# Both modes then boot `objectstack dev --fresh` and assert:
30-
# - GET /api/v1/auth/get-session → 200 (anonymous)
31-
# - POST /api/v1/auth/sign-up/email → 200
32-
# - POST /api/v1/auth/sign-in/email → 200, session established
29+
# Both modes then boot `objectstack dev --fresh` and assert the DECLARED
30+
# first-run contract (see "the first run this asserts" below):
31+
# - GET /api/v1/auth/get-session → 200 (anonymous)
32+
# - GET /api/v1/auth/config → 200, and in `pack` mode the
33+
# advertised audience posture
34+
# IS the declared `invite_only`
35+
# - POST /api/v1/auth/sign-in/email → 200 (the SEEDED dev admin)
36+
# then, under a closed posture (always in `pack` mode — see the mode note at
37+
# the `/auth/config` probe for why `registry` mode reads it rather than
38+
# assuming it):
39+
# - POST /api/v1/auth/sign-up/email → 403 SELF_REGISTRATION_CLOSED
40+
# (an UNINVITED second account)
41+
# - POST /api/v1/auth/organization/invite-member → 200 (the operator path)
42+
# - POST /api/v1/auth/sign-up/email → 200 (the invitee, admitted
43+
# by the invitation carve-out)
44+
# - POST /api/v1/auth/sign-in/email → 200, session established
3345
# - REST CRUD on the scaffolded object (POST/GET/PATCH/DELETE /api/v1/data/…)
3446
# - zero error/fatal log lines (specifically the #3091 signature:
3547
# "Failed to register OIDC discovery routes")
3648
#
49+
# ── the first run this asserts (#14000) ─────────────────────────────────────
50+
#
51+
# Until #14000 this script asserted that the first `POST /auth/sign-up/email`
52+
# after `objectstack dev --fresh` returns 200 — an expectation authored before
53+
# #11767/#11739 made `invite_only` the platform's DEFAULT audience posture. It
54+
# is not the first account that reaches this probe: `objectstack dev --fresh`
55+
# seeds a dev admin IN-PROCESS at boot (`maybeSeedDevAdmin`, plugin-auth) via
56+
# the real `signUpEmail` pipeline, and THAT is the zero-user creation the
57+
# bootstrap bypass admits. The smoke's own sign-up is therefore the SECOND
58+
# self-serve account, which `invite_only` refuses — correctly — with
59+
# `SELF_REGISTRATION_CLOSED` + 403.
60+
#
61+
# So the gate had been red for ~7 days on a product that was behaving exactly
62+
# as declared, and the release candidate had NEVER once passed this smoke with
63+
# the posture default aboard. Ruling (issue #14000, maintainer-approved,
64+
# director batch #23) — re-pin the smoke TO the declared contract, and do not
65+
# touch auth/audience runtime code:
66+
#
67+
# 1. the seeded admin can SIGN IN ⇒ the bootstrap bypass really
68+
# fired inside the packed install
69+
# (it is the only thing that could have created that account);
70+
# 2. the second self-serve sign-up is REFUSED with the envelope `code`
71+
# SELF_REGISTRATION_CLOSED and status 403 ⇒ the closed-by-default posture
72+
# is now load-bearing in CI: an accidental widening of the unauthenticated
73+
# surface turns the RC red instead of shipping;
74+
# 3. the non-admin probe user arrives through the OPERATOR path (the
75+
# invitation carve-out) before the session/CRUD probes run as before.
76+
#
77+
# ⛔ Assert BEHAVIOUR, never a vendor-internal symbol. Wiring a release gate to
78+
# better-auth's own internals (dist line numbers, private helpers) is the shape
79+
# that made #11767's breakage possible; every assertion here is an HTTP status
80+
# plus a `code` this repo owns and publishes.
81+
#
82+
# ⛔ Do NOT "fix" a red here by relaxing an assertion back toward 200. The whole
83+
# reason this gate exists (#3091) is that 15.1.0 shipped with every fresh
84+
# project's auth endpoints broken because in-repo settings hid the user's real
85+
# first run. A refusal that this script asserts is a refusal a user gets.
86+
#
3787
# better-sqlite3 is an optionalDependency of @objectstack/driver-sql: if the
3888
# runner cannot build the native addon the install still succeeds and the
3989
# runtime falls back to the WASM sqlite driver (#2229) — the smoke must never
@@ -717,6 +767,19 @@ COOKIES_USER="$SMOKE_ROOT/cookies-user.txt"
717767
COOKIES_ADMIN="$SMOKE_ROOT/cookies-admin.txt"
718768
BODY="$SMOKE_ROOT/body.json"
719769

770+
# The credentials `objectstack dev --fresh` seeds in-process, spelled once.
771+
# Defaults live in plugin-auth (`maybeSeedDevAdmin`) and are echoed by the CLI
772+
# startup banner (packages/cli/src/utils/format.ts). This script deliberately
773+
# passes NO --admin-email/--admin-password: the values a user is handed on a
774+
# fresh install are what this gate must exercise.
775+
ADMIN_EMAIL="admin@objectos.ai"
776+
ADMIN_PASSWORD="admin123"
777+
# The non-admin probe user. Spelled once because the operator invitation and
778+
# the sign-up that redeems it MUST address the same mailbox — the carve-out is
779+
# a lookup for a pending `sys_invitation` row on exactly this address.
780+
USER_EMAIL="smoke@example.com"
781+
USER_PASSWORD="Sm0ke-Pass!42"
782+
720783
# probe <label> <expected-status> <curl args…> — body lands in $BODY.
721784
probe() {
722785
local label=$1 expect=$2; shift 2
@@ -729,30 +792,153 @@ probe() {
729792
echo " ok — $label$status"
730793
}
731794

795+
# assert_body <jq-filter> <what-failed> — reads the LAST probe's body.
796+
# Always paired with `probe` for a refusal: a status code on its own names no
797+
# reason, and two different defects can produce the same number.
798+
assert_body() {
799+
jq -e "$1" "$BODY" >/dev/null \
800+
|| fail "$2 (filter: $1; body: $(cat "$BODY"))"
801+
}
802+
732803
log "Auth probes (the #3091 failure surface)"
733804
probe "GET /auth/get-session (anonymous)" 200 "$BASE_URL/api/v1/auth/get-session"
734805

735-
probe "POST /auth/sign-up/email" 200 \
806+
# ── which contract THIS artifact declares ───────────────────────────────────
807+
# `GET /auth/config` is public and unauthenticated, and since #11739 it
808+
# advertises the audience posture in force (`features.audiencePosture`) so a
809+
# login surface can render honest messaging instead of a sign-up form the
810+
# server will refuse. This script reads it for the same reason: the two SMOKE
811+
# MODES install DIFFERENT products.
812+
#
813+
# pack — the release candidate, i.e. THIS repo's tree. Its declared
814+
# default is `invite_only`, and the assertion below is where that
815+
# stops being a claim about source and becomes a measurement of
816+
# the artifact.
817+
# registry — the LAST PUBLISHED release, whose contract this tree does not
818+
# define and may predate. `@objectstack/plugin-auth` 17.2.0
819+
# (published 2026-08-23) ships no audience posture at all; #11767
820+
# landed 2026-08-25. Hard-coding the RC's posture here would turn
821+
# the weekly canary red — and auto-file a "fresh install is
822+
# broken" issue — over a release that is behaving exactly as ITS
823+
# contract declares.
824+
#
825+
# So the posture is READ, then the enforcement is asserted to MATCH it. That is
826+
# the same declared-equals-enforced pin in both modes, and the legacy branch
827+
# retires itself: once a published release carries the posture, the canary
828+
# takes the closed-posture path too and the `else` below stops being reached.
829+
probe "GET /auth/config (anonymous)" 200 "$BASE_URL/api/v1/auth/config"
830+
AUDIENCE_POSTURE=$(jq -r '.data.features.audiencePosture // "unadvertised"' "$BODY")
831+
echo " advertised audience posture: $AUDIENCE_POSTURE"
832+
833+
# ⛔ The RC's own default is not negotiable and not read from the RC's opinion
834+
# of itself: `invite_only` is what #11739/#11767 declare, so anything else here
835+
# is a widening of the unauthenticated surface and the release candidate must
836+
# go red for it — BEFORE the behaviour probes, which would otherwise report the
837+
# widening as a cheerful 200.
838+
if [ "$SMOKE_MODE" = "pack" ] && [ "$AUDIENCE_POSTURE" != "invite_only" ]; then
839+
fail "the release candidate advertises audience posture '$AUDIENCE_POSTURE', but the declared default is invite_only (#11739/#11767). Either the default was widened — in which case this refusal is the point of the gate — or the posture is no longer advertised on /auth/config and this probe has stopped measuring anything."
840+
fi
841+
842+
# ── the seeded admin signs in ───────────────────────────────────────────────
843+
# This is the packed install's proof that the BOOTSTRAP BYPASS fired. Under the
844+
# default `invite_only` posture the only self-registration the audience gate
845+
# admits is the very first account on a zero-user database, and
846+
# `objectstack dev --fresh` spends exactly that carve-out on the in-process
847+
# dev-admin seed (`maybeSeedDevAdmin` → the real `signUpEmail` pipeline, which
848+
# passes through the same gate). An account that can sign in HERE is an account
849+
# that gate admitted — nothing else could have created it. When the bypass
850+
# genuinely fails to fire (the #11767 defect: the bootstrap probe threw and the
851+
# outer `catch` read the rejection as "not bootstrap"), the seed never lands and
852+
# this probe fails, naming the real defect instead of leaving it to be inferred
853+
# from a sign-up refusal further down.
854+
probe "POST /auth/sign-in/email (seeded dev admin — proves the bootstrap bypass fired)" 200 \
855+
-c "$COOKIES_ADMIN" \
736856
-X POST -H 'content-type: application/json' \
737-
-d '{"name":"Smoke User","email":"smoke@example.com","password":"Sm0ke-Pass!42"}' \
738-
"$BASE_URL/api/v1/auth/sign-up/email"
857+
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}" \
858+
"$BASE_URL/api/v1/auth/sign-in/email"
859+
assert_body ".user.email == \"$ADMIN_EMAIL\"" \
860+
"the seeded dev admin's sign-in returned a session for a different user"
861+
862+
if [ "$AUDIENCE_POSTURE" = "invite_only" ]; then
863+
# ── the second self-serve sign-up is REFUSED ──────────────────────────────
864+
# The closed-by-default posture, pinned into CI in the artifact a user
865+
# installs: if the unauthenticated surface is ever widened by accident, the
866+
# release candidate goes red here instead of shipping.
867+
#
868+
# The status alone is NOT the assertion. A 403 also comes out of the origin
869+
# check (INVALID_ORIGIN), out of a rate limiter, and out of any guard added
870+
# later — each would keep this probe green while saying nothing whatever
871+
# about the audience posture. The envelope `code` is the part that pins the
872+
# posture, and it is a code this repo owns and publishes
873+
# (`SELF_REGISTRATION_CLOSED`), ⛔ never a vendor-internal symbol.
874+
#
875+
# A DIFFERENT address than the invited one below, on purpose: this probe must
876+
# measure an uninvited self-registrant, and reusing the invitee's mailbox
877+
# would let the carve-out answer instead of the posture.
878+
probe "POST /auth/sign-up/email (SECOND self-serve account, uninvited — must be REFUSED)" 403 \
879+
-X POST -H 'content-type: application/json' \
880+
-d "{\"name\":\"Uninvited Smoke\",\"email\":\"uninvited@example.com\",\"password\":\"$USER_PASSWORD\"}" \
881+
"$BASE_URL/api/v1/auth/sign-up/email"
882+
assert_body '.code == "SELF_REGISTRATION_CLOSED"' \
883+
"the uninvited self-serve sign-up was refused, but NOT by the audience posture — a 403 whose code is not SELF_REGISTRATION_CLOSED means some other guard answered, and this probe has stopped measuring the closed-by-default posture"
884+
885+
# ── the operator path admits the probe user ───────────────────────────────
886+
# How a real operator adds a teammate on a fresh install: `invite_only` means
887+
# BY INVITATION, and a self-serve sign-up whose address holds a pending,
888+
# unexpired `sys_invitation` row is admitted under every posture (the
889+
# invitation carve-out). The route runs as the signed-in admin against the
890+
# default organization their session carries — ADR-0081 bootstraps that org
891+
# precisely so this path exists on a single-org install.
892+
#
893+
# Chosen over `POST /auth/admin/create-user` — the other operator path named
894+
# in the ruling — because that route needs better-auth's `admin` plugin,
895+
# which a scaffolded project does NOT get by default
896+
# (`admin: pluginConfig.admin ?? scimEffective`, auth-manager.ts), so it
897+
# would answer 501 NOT_IMPLEMENTED on the very artifact under test. The
898+
# organization plugin IS on by default
899+
# (`organization: pluginConfig.organization ?? true`), so the invitation
900+
# carve-out is the operator path a fresh install actually ships.
901+
log "Operator path (invitation carve-out) — how a fresh install adds its second user"
902+
probe "POST /auth/organization/invite-member (as the seeded admin)" 200 \
903+
-b "$COOKIES_ADMIN" \
904+
-X POST -H 'content-type: application/json' \
905+
-d "{\"email\":\"$USER_EMAIL\",\"role\":\"member\"}" \
906+
"$BASE_URL/api/v1/auth/organization/invite-member"
907+
assert_body '.status == "pending"' \
908+
"invite-member answered 200 but persisted no pending invitation — the carve-out below would have nothing to redeem"
909+
910+
probe "POST /auth/sign-up/email (invited user — admitted by the carve-out)" 200 \
911+
-X POST -H 'content-type: application/json' \
912+
-d "{\"name\":\"Smoke User\",\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\"}" \
913+
"$BASE_URL/api/v1/auth/sign-up/email"
914+
else
915+
# ── legacy: an artifact that declares no closed posture ───────────────────
916+
# Reached only by `registry` mode against a published release older than
917+
# #11739/#11767 (pack mode has already failed above if the RC lands here).
918+
# Such a release admits open self-registration, and asserting a refusal
919+
# against it would be this gate reporting a defect that its subject does not
920+
# have. Retires itself: the first published release carrying the posture
921+
# sends the canary down the closed branch above.
922+
log "Open-registration first run (this artifact advertises no closed audience posture)"
923+
probe "POST /auth/sign-up/email (self-serve)" 200 \
924+
-X POST -H 'content-type: application/json' \
925+
-d "{\"name\":\"Smoke User\",\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\"}" \
926+
"$BASE_URL/api/v1/auth/sign-up/email"
927+
fi
739928

740929
probe "POST /auth/sign-in/email" 200 \
741930
-c "$COOKIES_USER" \
742931
-X POST -H 'content-type: application/json' \
743-
-d '{"email":"smoke@example.com","password":"Sm0ke-Pass!42"}' \
932+
-d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\"}" \
744933
"$BASE_URL/api/v1/auth/sign-in/email"
745934

746935
probe "GET /auth/get-session (signed in)" 200 -b "$COOKIES_USER" "$BASE_URL/api/v1/auth/get-session"
747-
jq -e '.user.email == "smoke@example.com"' "$BODY" >/dev/null \
748-
|| fail "signed-in get-session did not return the smoke user (body: $(cat "$BODY"))"
936+
assert_body ".user.email == \"$USER_EMAIL\"" \
937+
"signed-in get-session did not return the smoke user"
749938

750939
log "REST CRUD probes (seeded dev admin)"
751-
probe "POST /auth/sign-in/email (admin)" 200 \
752-
-c "$COOKIES_ADMIN" \
753-
-X POST -H 'content-type: application/json' \
754-
-d '{"email":"admin@objectos.ai","password":"admin123"}' \
755-
"$BASE_URL/api/v1/auth/sign-in/email"
940+
# The admin session was established by the bootstrap-bypass probe above and is
941+
# reused here — one sign-in, not two.
756942

757943
# The scaffolder renames the template's `blank_note` object after the project
758944
# (e.g. smoke_app_note) — read the real name from the generated source.

0 commit comments

Comments
 (0)