fix: no commerce in the mobile apps (TASK-3293, PLAN-3291 U1, BUG-3290) (#1658)

* fix(server): plan-limit refusals to a mobile shell name no upgrade destination (TASK-3293)

planLimitDetails and planLimitMessage take the request, so every door that
renders a plan-limit refusal (single 403, bulk per-item failure, bundle
import) has to say who it is answering. A request carrying the PadShell/
user-agent marker gets no upgrade_url; everything else is unchanged.
PLAN-3291 DR-1/DR-3.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(web): no upgrade copy or billing links in the mobile apps (TASK-3293, BUG-3290)

authStore.nativeShell reads the PadShell/ user-agent marker both shells
append, and authStore.commerceAllowed (cloud mode and not a shell) is the one
gate every billing surface reads (PLAN-3291 DR-2/DR-3). All thirteen
plan-limit toasts go through showPlanLimitToast, which adds the upgrade copy
and link only where commerce is allowed; the client default is neutral; the
Billing menu, sheet and console-nav links and the settings card's buttons are
hidden in the app; /console/billing there shows the plan and its limits only.
A vitest guard pins the population to the gated files.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* test(e2e): shell-UA pass finds no commerce, browser control finds it (TASK-3293, PLAN-3291 DR-7)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* test: re-pin the gates this unit's edits moved (TASK-3293)

The BUG-2803 text-safe scan now counts fromNativeShell's requestUserAgent
call (it reads the UA to choose copy and binds nothing). The TASK-3097
identity gates for roles, settings and [collection] hash the top-level
import each unit references, so replacing planLimitMessage with
showPlanLimitToast in that import moved every unit that uses api. The only
other change in those pages is a synchronous toast call inside existing
catch arms: no await, write or check moved.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix: the app drops checkout-return states and the invitee's upgrade clause (TASK-3293, codex r1)

/console/billing?checkout=success|cancelled ran the web-checkout result
flow in the app, with payment banners and a support mailto; it now clears
the query unread there, and the e2e visits both returns. The invitee's
member-limit refusal (BUG-3098) is its own message and said "upgrade their
plan" to a shell; it now takes the request like the plan-limit helpers.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* test: pin the account menus' Billing link under commerceAllowed (TASK-3293, codex r2)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* test(e2e): install the session patch before login (TASK-3293, codex r3)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
This commit is contained in:
xarmian
2026-09-29 10:42:26 -04:00
committed by GitHub
parent 249e44d18e
commit 1ed52bdb4c
39 changed files with 974 additions and 144 deletions
+6 -2
View File
@@ -898,8 +898,12 @@ func TestTextSafeHelpersAreUsedAtEveryCallSite(t *testing.T) {
t.Errorf("truncateBindableText occurrences = %d, want 5 (1 declaration + 4 call sites); "+
"a site was added or removed — re-justify and re-pin", safeTruncateUses)
}
if safeUAUses != 5 {
t.Errorf("requestUserAgent occurrences = %d, want 5 (1 declaration + 4 call sites); "+
// The fifth requestUserAgent call site is fromNativeShell (TASK-3293),
// which only substring-matches the mobile shells' marker to choose
// plan-limit copy and binds nothing to a column. It goes through the
// helper because this scan admits no other raw reader.
if safeUAUses != 6 {
t.Errorf("requestUserAgent occurrences = %d, want 6 (1 declaration + 5 call sites); "+
"a site was added or removed — re-justify and re-pin", safeUAUses)
}
}
+2 -2
View File
@@ -197,7 +197,7 @@ func (s *Server) handleImportArtifact(w http.ResponseWriter, r *http.Request) {
// Enforce the workspace item-count limit (workspace-scoped), identical to
// handleCreateItem. Writes the 403 plan_limit_exceeded response itself when
// the cap is hit; no-op in self-hosted mode.
if !s.enforcePlanLimit(w, workspaceID, "items_per_workspace") {
if !s.enforcePlanLimit(w, r, workspaceID, "items_per_workspace") {
return
}
@@ -207,7 +207,7 @@ func (s *Server) handleImportArtifact(w http.ResponseWriter, r *http.Request) {
// the artifacts most likely to hold pre-validation junk.
item, cerr := s.createItemChecked(r, workspaceID, coll, schema, input, normalizedFields, "", relationsCarry)
if cerr != nil {
cerr.write(w)
cerr.write(w, r)
return
}
+2 -2
View File
@@ -677,7 +677,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
// keeps an over-cap signup from creating anything. Advisory: a member that
// lands after this check is refused by AddWorkspaceMember below, whose
// "absent" branch rolls the account back.
if invitation != nil && !s.checkMemberLimitForAccept(w, invitation.WorkspaceID) {
if invitation != nil && !s.checkMemberLimitForAccept(w, r, invitation.WorkspaceID) {
return
}
@@ -778,7 +778,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
// BUG-3098: a member cap reached after the pre-check is a
// refusal the invitee can act on, not a server fault.
if s.writeStoreMemberLimitError(w, invitation.WorkspaceID, addErr) {
if s.writeStoreMemberLimitError(w, r, invitation.WorkspaceID, addErr) {
return
}
writeInternalError(w, addErr)
+57 -25
View File
@@ -1115,7 +1115,7 @@ func (s *Server) handleGetPlanLimits(w http.ResponseWriter, r *http.Request) {
// The check is ADVISORY: a concurrent request can fill the cap after it. The
// door's insert must also pass workspaceLimitMintOpts(), which decides the
// limit authoritatively in the insert's transaction (BUG-2808).
func (s *Server) enforcePlanLimit(w http.ResponseWriter, workspaceID, feature string) bool {
func (s *Server) enforcePlanLimit(w http.ResponseWriter, r *http.Request, workspaceID, feature string) bool {
if !s.cloudMode {
return true // Self-hosted: no limits
}
@@ -1141,7 +1141,7 @@ func (s *Server) enforcePlanLimit(w http.ResponseWriter, workspaceID, feature st
return false
}
if !result.Allowed {
writePlanLimitError(w, result)
writePlanLimitError(w, r, result)
return false
}
if s.planLimitAdmittedHook != nil {
@@ -1153,7 +1153,7 @@ func (s *Server) enforcePlanLimit(w http.ResponseWriter, workspaceID, feature st
// enforceUserPlanLimit checks a user-scoped plan limit and writes a 403
// error if the limit is exceeded. Returns true if the operation is allowed.
// In non-cloud mode, always returns true (no limits enforced).
func (s *Server) enforceUserPlanLimit(w http.ResponseWriter, userID, feature string) bool {
func (s *Server) enforceUserPlanLimit(w http.ResponseWriter, r *http.Request, userID, feature string) bool {
if !s.cloudMode {
return true // Self-hosted: no limits
}
@@ -1173,7 +1173,7 @@ func (s *Server) enforceUserPlanLimit(w http.ResponseWriter, userID, feature str
return false
}
if !result.Allowed {
writePlanLimitError(w, result)
writePlanLimitError(w, r, result)
return false
}
if s.planLimitAdmittedHook != nil {
@@ -1207,12 +1207,12 @@ func (s *Server) workspaceLimitMintOpts() []store.MintOption {
// writeStorePlanLimitError answers a *store.PlanLimitError with the same 403
// the advisory pre-check writes, and reports whether err was one. note, when
// non-empty, is appended to the message.
func writeStorePlanLimitError(w http.ResponseWriter, err error, note string) bool {
func writeStorePlanLimitError(w http.ResponseWriter, r *http.Request, err error, note string) bool {
var ple *store.PlanLimitError
if !errors.As(err, &ple) {
return false
}
writePlanLimitErrorNote(w, &ple.Result, note)
writePlanLimitErrorNote(w, r, &ple.Result, note)
return true
}
@@ -1220,16 +1220,16 @@ func writeStorePlanLimitError(w http.ResponseWriter, err error, note string) boo
// The envelope follows the standard {"error":{"code":...,"message":...,"details":{...}}}
// shape so PadApiError (frontend), cli.APIError (CLI), and the MCP classifier can
// all parse it uniformly. TASK-788.
func writePlanLimitError(w http.ResponseWriter, result *store.LimitResult) {
writePlanLimitErrorNote(w, result, "")
func writePlanLimitError(w http.ResponseWriter, r *http.Request, result *store.LimitResult) {
writePlanLimitErrorNote(w, r, result, "")
}
func writePlanLimitErrorNote(w http.ResponseWriter, result *store.LimitResult, note string) {
msg := planLimitMessage(result)
func writePlanLimitErrorNote(w http.ResponseWriter, r *http.Request, result *store.LimitResult, note string) {
msg := planLimitMessage(r, result)
if note != "" {
msg += " " + note
}
writeError2(w, http.StatusForbidden, "plan_limit_exceeded", msg, planLimitDetails(result))
writeError2(w, http.StatusForbidden, "plan_limit_exceeded", msg, planLimitDetails(r, result))
}
// writeMemberLimitError answers an INVITEE whose accept would take a workspace
@@ -1244,14 +1244,21 @@ func writePlanLimitErrorNote(w http.ResponseWriter, result *store.LimitResult, n
// contact detail they were not given. With no display name the message says
// "the workspace owner". A failed lookup degrades to the same wording rather
// than failing the refusal.
func (s *Server) writeMemberLimitError(w http.ResponseWriter, workspaceID string, result *store.LimitResult) {
func (s *Server) writeMemberLimitError(w http.ResponseWriter, r *http.Request, workspaceID string, result *store.LimitResult) {
owner := "the workspace owner"
if ws, err := s.store.GetWorkspaceByID(workspaceID); err == nil && ws != nil && ws.OwnerID != "" {
if u, err := s.store.GetUser(ws.OwnerID); err == nil && u != nil && strings.TrimSpace(u.Name) != "" {
owner = "the workspace owner, " + strings.TrimSpace(u.Name) + ","
}
}
msg := fmt.Sprintf("This workspace has reached its %d-member limit. Ask %s to make room or upgrade their plan, then accept this invitation again.", result.Limit, owner)
// The mobile apps carry no call to action for a purchase, not even one
// addressed to someone else (PLAN-3291 DR-1), so a shell's invitee is told
// only to ask for room.
remedy := "make room or upgrade their plan"
if fromNativeShell(r) {
remedy = "make room"
}
msg := fmt.Sprintf("This workspace has reached its %d-member limit. Ask %s to %s, then accept this invitation again.", result.Limit, owner, remedy)
writeError2(w, http.StatusForbidden, "workspace_member_limit", msg, map[string]interface{}{
"feature": result.Feature,
"limit": result.Limit,
@@ -1264,7 +1271,7 @@ func (s *Server) writeMemberLimitError(w http.ResponseWriter, workspaceID string
// 403. Cloud mode only. Returns true if the accept may proceed. It fires
// planLimitAdmittedHook like enforcePlanLimit, so tests can land a competing
// member in the window before the authoritative insert.
func (s *Server) checkMemberLimitForAccept(w http.ResponseWriter, workspaceID string) bool {
func (s *Server) checkMemberLimitForAccept(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
if !s.cloudMode {
return true
}
@@ -1276,7 +1283,7 @@ func (s *Server) checkMemberLimitForAccept(w http.ResponseWriter, workspaceID st
return false
}
if !result.Allowed {
s.writeMemberLimitError(w, workspaceID, result)
s.writeMemberLimitError(w, r, workspaceID, result)
return false
}
if s.planLimitAdmittedHook != nil {
@@ -1288,24 +1295,45 @@ func (s *Server) checkMemberLimitForAccept(w http.ResponseWriter, workspaceID st
// writeStoreMemberLimitError is writeStorePlanLimitError for the accept
// doors: it answers a *store.PlanLimitError with writeMemberLimitError and
// reports whether err was one.
func (s *Server) writeStoreMemberLimitError(w http.ResponseWriter, workspaceID string, err error) bool {
func (s *Server) writeStoreMemberLimitError(w http.ResponseWriter, r *http.Request, workspaceID string, err error) bool {
var ple *store.PlanLimitError
if !errors.As(err, &ple) {
return false
}
s.writeMemberLimitError(w, workspaceID, &ple.Result)
s.writeMemberLimitError(w, r, workspaceID, &ple.Result)
return true
}
// nativeShellMarker is the user-agent token both mobile shells append to every
// web-view request (pad-mobile AppUserAgent.MARKER, UserAgent.marker; the
// shells send "PadShell/1"). PLAN-3291 DR-2 matches the prefix, so a future
// "PadShell/2" is still an app.
const nativeShellMarker = "PadShell/"
// fromNativeShell reports whether r came from a Pad mobile app's web view.
// PLAN-3291 DR-1: the apps carry no purchase path, so a plan-limit refusal
// addressed to one names no upgrade destination. The user agent is not an
// authorization boundary and nothing that matters for security reads this;
// it only decides whether commerce copy is offered. A nil request is not an
// app.
func fromNativeShell(r *http.Request) bool {
return r != nil && strings.Contains(requestUserAgent(r), nativeShellMarker)
}
// planLimitDetails is the structured half of a plan-limit refusal, shared by
// the 403 and by the bulk envelope's per-item failure.
func planLimitDetails(result *store.LimitResult) map[string]interface{} {
// the 403 and by the bulk envelope's per-item failure. It takes the request so
// every door has to say who it is answering (PLAN-3291 DR-3): a request from a
// mobile shell gets no upgrade_url, and every other request gets the same map
// as before.
func planLimitDetails(r *http.Request, result *store.LimitResult) map[string]interface{} {
d := map[string]interface{}{
"feature": result.Feature,
"limit": result.Limit,
"current": result.Current,
"plan": result.Plan,
"upgrade_url": "/console/billing",
"feature": result.Feature,
"limit": result.Limit,
"current": result.Current,
"plan": result.Plan,
}
if !fromNativeShell(r) {
d["upgrade_url"] = "/console/billing"
}
// `requested` appears only where an operation adds more than one at once
// (BUG-3103: ImportWorkspace). Every other door leaves Requested zero and
@@ -1329,8 +1357,12 @@ func (s *Server) restoreLimitOpts() []store.MutationOption {
// planLimitMessage returns a human-readable statement-of-fact sentence for a
// plan limit violation. Each surface (web toast, CLI, MCP hint) appends its
// own upgrade call-to-action so the message itself doesn't repeat it (B1 fix).
// That is also why a mobile shell's request gets the same sentence: it carries
// no upgrade wording to remove. It takes the request anyway so that any future
// wording must decide per caller (PLAN-3291 DR-3), and a test pins the
// shell's sentence free of upgrade words.
// Uses hyphenated adjective form ("3-member") per B2 fix. TASK-788.
func planLimitMessage(result *store.LimitResult) string {
func planLimitMessage(r *http.Request, result *store.LimitResult) string {
featureLabel := map[string]string{
"items_per_workspace": "item",
"members_per_workspace": "member",
+6 -5
View File
@@ -135,13 +135,13 @@ func (s *Server) handleImportWorkspaceBundle(w http.ResponseWriter, r *http.Requ
staleBodies := &staleBodyTally{}
var importReport store.ImportReport
ws, err := s.importBundle(r.Context(), gz, newName, mint, repair, staleBodies, &importReport)
ws, err := s.importBundle(r, gz, newName, mint, repair, staleBodies, &importReport)
if err != nil {
// A plan-limit refusal from the store (BUG-2808) is decided before the
// import's commit, so the transaction rolled back and there is no
// partial workspace to keep or remove: answer the same 403 the
// pre-check does and stop.
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
// Errors from importBundle are already shaped with status hints —
@@ -277,7 +277,8 @@ func (s *Server) handleImportWorkspaceBundle(w http.ResponseWriter, r *http.Requ
// Split out from the handler so tests can drive it with a tar.Reader
// over an in-memory bundle and assert on the resulting state without
// a live HTTP server.
func (s *Server) importBundle(ctx context.Context, r io.Reader, newName string, mint workspaceMintAuth, repair *nulRepairTally, staleBodies *staleBodyTally, report *store.ImportReport) (result *models.Workspace, retErr error) {
func (s *Server) importBundle(req *http.Request, r io.Reader, newName string, mint workspaceMintAuth, repair *nulRepairTally, staleBodies *staleBodyTally, report *store.ImportReport) (result *models.Workspace, retErr error) {
ctx := req.Context()
// The bundle door is the SECOND body shape behind the import route, and
// it mints through the same store call, so it takes the same mint
// context the JSON path does rather than re-deriving owner and source
@@ -468,8 +469,8 @@ func (s *Server) importBundle(ctx context.Context, r io.Reader, newName string,
return nil, &importStatusError{
status: http.StatusForbidden,
code: "plan_limit_exceeded",
message: planLimitMessage(&ple.Result),
details: planLimitDetails(&ple.Result),
message: planLimitMessage(req, &ple.Result),
details: planLimitDetails(req, &ple.Result),
}
}
return nil, fmt.Errorf("import workspace: %w", err)
+6 -6
View File
@@ -692,7 +692,7 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
}
// Enforce item count limit (workspace-scoped)
if !s.enforcePlanLimit(w, workspaceID, "items_per_workspace") {
if !s.enforcePlanLimit(w, r, workspaceID, "items_per_workspace") {
return
}
@@ -774,7 +774,7 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
item, cerr := s.createItemChecked(r, workspaceID, coll, schema, input, fieldMap, parentValue, relationsRefuse)
if cerr != nil {
cerr.write(w)
cerr.write(w, r)
return
}
@@ -803,8 +803,8 @@ type itemCreateError struct {
func (e *itemCreateError) Error() string { return e.message }
// write answers the request with this error.
func (e *itemCreateError) write(w http.ResponseWriter) {
if e.planLimit != nil && writeStorePlanLimitError(w, e.planLimit, "") {
func (e *itemCreateError) write(w http.ResponseWriter, r *http.Request) {
if e.planLimit != nil && writeStorePlanLimitError(w, r, e.planLimit, "") {
return
}
writeError(w, e.status, e.code, e.message)
@@ -2637,13 +2637,13 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
// BUG-3101: a restore raises items_per_workspace's live count. The
// advisory pre-check runs only for an archived row, so a live item still
// answers not-found below; RestoreItem decides authoritatively.
if item.DeletedAt != nil && !s.enforcePlanLimit(w, workspaceID, "items_per_workspace") {
if item.DeletedAt != nil && !s.enforcePlanLimit(w, r, workspaceID, "items_per_workspace") {
return
}
restored, err := s.store.RestoreItem(item.ID, s.restoreLimitOpts()...)
if err != nil {
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
if err == sql.ErrNoRows {
+2 -2
View File
@@ -470,8 +470,8 @@ func (s *Server) applyBulkOp(r *http.Request, workspaceID string, item *models.I
if err != nil {
var ple *store.PlanLimitError
if errors.As(err, &ple) {
details, _ := json.Marshal(planLimitDetails(&ple.Result))
return nil, &bulkOpError{message: planLimitMessage(&ple.Result), code: "plan_limit_exceeded", details: details}
details, _ := json.Marshal(planLimitDetails(r, &ple.Result))
return nil, &bulkOpError{message: planLimitMessage(r, &ple.Result), code: "plan_limit_exceeded", details: details}
}
if err == sql.ErrNoRows {
return nil, &bulkOpError{message: "item not found or not archived"}
+3 -3
View File
@@ -441,7 +441,7 @@ func (s *Server) handleCopyItem(w http.ResponseWriter, r *http.Request) {
}),
})
if err != nil {
s.writeCopyError(w, err)
s.writeCopyError(w, r, err)
return
}
@@ -766,7 +766,7 @@ func (s *Server) emitCopyFanout(r *http.Request, res *store.CrossWorkspaceCopyRe
// writeCopyError maps the store's typed errors onto the statuses the
// contract promises. Everything the store classifies as a caller-facing
// rejection has an entry; the default is the DR-13 ambiguity message.
func (s *Server) writeCopyError(w http.ResponseWriter, err error) {
func (s *Server) writeCopyError(w http.ResponseWriter, r *http.Request, err error) {
// The in-tx authorization re-check, first: it must produce the SAME
// response the same denial would have produced at the top of the
// request. A caller whose grant was revoked mid-copy learns exactly what
@@ -783,7 +783,7 @@ func (s *Server) writeCopyError(w http.ResponseWriter, err error) {
var limit *store.ItemLimitError
if errors.As(err, &limit) {
writePlanLimitError(w, limit.Result)
writePlanLimitError(w, r, limit.Result)
return
}
+3 -3
View File
@@ -111,7 +111,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
}
// Enforce member count limit (workspace-scoped)
if !s.enforcePlanLimit(w, workspaceID, "members_per_workspace") {
if !s.enforcePlanLimit(w, r, workspaceID, "members_per_workspace") {
return
}
@@ -132,7 +132,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.store.AddWorkspaceMember(workspaceID, existingUser.ID, input.Role, s.workspaceLimitMintOpts()...); err != nil {
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
writeInternalError(w, err)
@@ -427,7 +427,7 @@ func (s *Server) acceptInvitationCore(w http.ResponseWriter, r *http.Request, in
slog.Error("invitation accept: the accept failed and reading its outcome also failed",
"workspace_id", inv.WorkspaceID, "user_id", user.ID, "error", err, "check_error", cerr)
}
if s.writeStoreMemberLimitError(w, inv.WorkspaceID, err) {
if s.writeStoreMemberLimitError(w, r, inv.WorkspaceID, err) {
return "", false
}
writeInternalError(w, err)
+3 -3
View File
@@ -106,7 +106,7 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
// would otherwise read the refusal as a per-workspace limit.
token, err := s.store.CreateAPIToken(userID, input, defaultDays, maxDays, s.planLimitMintOpts(userID)...)
if err != nil {
if writeStorePlanLimitError(w, err, "This limit counts every API token you own, across all workspaces.") {
if writeStorePlanLimitError(w, r, err, "This limit counts every API token you own, across all workspaces.") {
return
}
writeInternalError(w, err)
@@ -215,7 +215,7 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
}
// Enforce API token count limit (user-scoped)
if !s.enforceUserPlanLimit(w, userID, "api_tokens") {
if !s.enforceUserPlanLimit(w, r, userID, "api_tokens") {
return
}
@@ -223,7 +223,7 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
token, err := s.store.CreateAPIToken(userID, input, defaultDays, maxDays, s.planLimitMintOpts(userID)...)
if err != nil {
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
writeInternalError(w, err)
+2 -2
View File
@@ -32,7 +32,7 @@ func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
}
// Enforce webhook count limit (workspace-scoped)
if !s.enforcePlanLimit(w, workspaceID, "webhooks") {
if !s.enforcePlanLimit(w, r, workspaceID, "webhooks") {
return
}
@@ -65,7 +65,7 @@ func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
hook, err := s.store.CreateWebhook(workspaceID, input, s.workspaceLimitMintOpts()...)
if err != nil {
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
writeInternalError(w, err)
+2 -2
View File
@@ -439,7 +439,7 @@ func (s *Server) handleCreateWorkspace(w http.ResponseWriter, r *http.Request) {
// beginWorkspaceMint makes the same decision.
ws, err := s.store.CreateWorkspace(input, s.planLimitMintOpts(mint.OwnerID)...)
if err != nil {
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
writeInternalError(w, err)
@@ -938,7 +938,7 @@ func (s *Server) handleImportWorkspace(w http.ResponseWriter, r *http.Request) {
ws, importReport, err := s.store.ImportWorkspaceWithReport(&data, newName, userID, mint.Source, s.planLimitMintOpts(userID)...)
if err != nil {
if writeStorePlanLimitError(w, err, "") {
if writeStorePlanLimitError(w, r, err, "") {
return
}
// A refusal about the EXPORT the caller supplied is a 400, not a 500
@@ -37,7 +37,7 @@ func TestPlanLimitMessage_UntouchedDoorsAreByteIdentical(t *testing.T) {
for _, c := range cases {
// Requested is left at zero — which is what every door but import
// produces — so this is the pre-BUG-3103 rendering.
got := planLimitMessage(&store.LimitResult{Feature: c.feature, Limit: c.limit, Current: c.limit, Plan: "free"})
got := planLimitMessage(nil, &store.LimitResult{Feature: c.feature, Limit: c.limit, Current: c.limit, Plan: "free"})
if got != c.want {
t.Errorf("planLimitMessage(%s) = %q, want %q", c.feature, got, c.want)
}
@@ -46,7 +46,7 @@ func TestPlanLimitMessage_UntouchedDoorsAreByteIdentical(t *testing.T) {
func TestPlanLimitDetails_UntouchedDoorsAreByteIdentical(t *testing.T) {
t.Parallel()
got := planLimitDetails(&store.LimitResult{
got := planLimitDetails(nil, &store.LimitResult{
Feature: "items_per_workspace", Limit: 100, Current: 100, Plan: "free",
})
want := map[string]interface{}{
@@ -68,7 +68,7 @@ func TestPlanLimitDetails_UntouchedDoorsAreByteIdentical(t *testing.T) {
// refusal names how many would land versus the limit.
func TestPlanLimitMessage_ImportNamesWhatWouldLand(t *testing.T) {
t.Parallel()
got := planLimitMessage(&store.LimitResult{
got := planLimitMessage(nil, &store.LimitResult{
Feature: "items_per_workspace", Limit: 100, Current: 0, Plan: "free", Requested: 150,
})
want := "This import would add 150 items, over the 100-item limit on the free plan."
@@ -84,7 +84,7 @@ func TestPlanLimitMessage_ImportNamesWhatWouldLand(t *testing.T) {
func TestPlanLimitDetails_ImportCarriesRequested(t *testing.T) {
t.Parallel()
got := planLimitDetails(&store.LimitResult{
got := planLimitDetails(nil, &store.LimitResult{
Feature: "items_per_workspace", Limit: 100, Current: 0, Plan: "free", Requested: 150,
})
want := map[string]interface{}{
@@ -0,0 +1,323 @@
package server
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// PLAN-3291 DR-1 / DR-3, server half (TASK-3293). A plan-limit refusal
// answered to a mobile shell's web view names no upgrade destination, at every
// door that renders one: the single 403, the bulk envelope's per-item failure,
// and the bundle import's own refusal. Each door is driven through ServeHTTP,
// so the tests vouch for the binding and not only for the helper (CONVE-19),
// and each shell leg is paired with the same request without the marker, which
// must still carry upgrade_url (CONVE-12: a fix that dropped the link for
// everyone would pass the shell leg alone).
// shellUA is what the Android shell sends: the platform UA with the marker
// appended (pad-mobile AppUserAgent.kt). The iOS shell's differs only before
// the marker.
const shellUA = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0 Mobile Safari/537.36 PadShell/1"
// upgradeWords are the commerce words a shell-addressed message must not
// carry. The server message is a statement of fact today; this pins that.
var upgradeWords = []string{"upgrade", " pro", "billing", "pricing", "plan →", "/console"}
func (e *planLimitEnv) doUA(method, path, contentType string, body []byte, ua string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Authorization", "Bearer "+e.pat)
if ua != "" {
req.Header.Set("User-Agent", ua)
}
req.RemoteAddr = "192.0.2.1:1234"
rr := httptest.NewRecorder()
e.srv.ServeHTTP(rr, req)
return rr
}
type planLimitEnvelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
}
func assertShellRefusal(t *testing.T, msg string, details map[string]any, shell bool) {
t.Helper()
_, has := details["upgrade_url"]
if shell && has {
t.Errorf("shell request: details carry upgrade_url %v, want absent", details["upgrade_url"])
}
if !shell && !has {
t.Errorf("browser request: details lack upgrade_url, want /console/billing (details=%v)", details)
}
if details["feature"] == nil || details["limit"] == nil {
t.Errorf("details lost the refusal's facts: %v", details)
}
if shell {
low := strings.ToLower(msg)
for _, w := range upgradeWords {
if strings.Contains(low, w) {
t.Errorf("shell message %q contains %q", msg, w)
}
}
}
}
func TestPlanLimit_NativeShell_SingleDoor(t *testing.T) {
for _, shell := range []bool{true, false} {
t.Run(fmt.Sprintf("shell=%v", shell), func(t *testing.T) {
e := newPlanLimitEnv(t)
current := e.countIn(t, `SELECT COUNT(*) FROM items WHERE workspace_id = ? AND deleted_at IS NULL`)
if err := e.srv.store.SetUserPlanOverrides(e.user.ID, fmt.Sprintf(`{"items_per_workspace":%d}`, current)); err != nil {
t.Fatalf("SetUserPlanOverrides: %v", err)
}
ua := ""
if shell {
ua = shellUA
}
body, _ := json.Marshal(map[string]any{"title": "over the cap"})
rr := e.doUA("POST", "/api/v1/workspaces/"+e.home.Slug+"/collections/tasks/items", "application/json", body, ua)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (body=%s)", rr.Code, rr.Body.String())
}
var env planLimitEnvelope
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Error.Code != "plan_limit_exceeded" {
t.Fatalf("code = %q, want plan_limit_exceeded", env.Error.Code)
}
assertShellRefusal(t, env.Error.Message, env.Error.Details, shell)
})
}
}
func TestPlanLimit_NativeShell_BulkDoor(t *testing.T) {
for _, shell := range []bool{true, false} {
t.Run(fmt.Sprintf("shell=%v", shell), func(t *testing.T) {
e := newRestoreLimitEnv(t)
gone := e.archived(t, 1)
e.setCap(t, e.liveItems(t))
ua := ""
if shell {
ua = shellUA
}
body, _ := json.Marshal(map[string]any{"ids": []string{gone[0].Ref}, "op": "restore"})
rr := e.doUA("POST", "/api/v1/workspaces/"+e.home.Slug+"/items/bulk", "application/json", body, ua)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String())
}
var env struct {
Failed []struct {
Code string `json:"code"`
Error string `json:"error"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"failed"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if len(env.Failed) != 1 || env.Failed[0].Code != "plan_limit_exceeded" {
t.Fatalf("failed = %+v, want one plan_limit_exceeded (body=%s)", env.Failed, rr.Body.String())
}
f := env.Failed[0]
assertShellRefusal(t, f.Error+" "+f.Message, f.Details, shell)
})
}
}
// The bundle import's own refusal (handlers_import_bundle.go, BUG-3103) is
// the one that names `requested`: a bundle carrying more items than the cap.
func TestPlanLimit_NativeShell_BundleImportDoor(t *testing.T) {
for _, shell := range []bool{true, false} {
t.Run(fmt.Sprintf("shell=%v", shell), func(t *testing.T) {
srv, _ := testServerWithAttachments(t)
srv.cloudMode = true
u := mintTestUser(t, srv, "bundle-shell@example.com")
tok := loginUser(t, srv, "bundle-shell@example.com", "correct-horse-battery-staple")
if err := srv.store.SetUserPlan(u.ID, "free", ""); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
src, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Bundle Source", OwnerID: u.ID})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := srv.store.SeedCollectionsFromTemplate(src.ID, "startup"); err != nil {
t.Fatalf("Seed: %v", err)
}
coll, err := srv.store.GetCollectionBySlug(src.ID, "tasks")
if err != nil || coll == nil {
t.Fatalf("tasks: %v, %v", coll, err)
}
for i := 0; i < 3; i++ {
if _, err := srv.store.CreateItem(src.ID, coll.ID, models.ItemCreate{Title: fmt.Sprintf("t%d", i)}); err != nil {
t.Fatalf("CreateItem: %v", err)
}
}
export, err := srv.store.ExportWorkspace(src.Slug)
if err != nil {
t.Fatalf("ExportWorkspace: %v", err)
}
if err := srv.store.SetUserPlanOverrides(u.ID, `{"items_per_workspace":1,"workspaces":10}`); err != nil {
t.Fatalf("SetUserPlanOverrides: %v", err)
}
payload, _ := json.Marshal(export)
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
if err := tw.WriteHeader(&tar.Header{Name: "pad-export.json", Mode: 0o644, Size: int64(len(payload))}); err != nil {
t.Fatalf("header: %v", err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatalf("write: %v", err)
}
tw.Close()
gzw.Close()
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=shell-bundle", bytes.NewReader(buf.Bytes()))
req.Header.Set("Content-Type", "application/gzip")
if shell {
req.Header.Set("User-Agent", shellUA)
}
req.RemoteAddr = "192.0.2.1:1234"
req.AddCookie(&http.Cookie{Name: "pad_session", Value: tok})
const testCSRF = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: testCSRF})
req.Header.Set("X-CSRF-Token", testCSRF)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (body=%s)", rr.Code, rr.Body.String())
}
var env planLimitEnvelope
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v", err)
}
// requested proves this is the in-import refusal, not the
// workspaces pre-check above it.
if env.Error.Code != "plan_limit_exceeded" || env.Error.Details["requested"] == nil {
t.Fatalf("want the in-import items refusal with `requested`, got %s", rr.Body.String())
}
assertShellRefusal(t, env.Error.Message, env.Error.Details, shell)
})
}
}
func TestFromNativeShell(t *testing.T) {
t.Parallel()
cases := []struct {
ua string
want bool
}{
{shellUA, true},
{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 PadShell/1", true},
{"… PadShell/2", true},
{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1", false},
{"pad-cli/0.16", false},
{"", false},
}
for _, c := range cases {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("User-Agent", c.ua)
if got := fromNativeShell(r); got != c.want {
t.Errorf("fromNativeShell(%q) = %v, want %v", c.ua, got, c.want)
}
}
if fromNativeShell(nil) {
t.Error("a nil request is not an app")
}
}
func TestPlanLimitDetails_NativeShellOmitsOnlyUpgradeURL(t *testing.T) {
t.Parallel()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("User-Agent", shellUA)
res := &store.LimitResult{Feature: "items_per_workspace", Limit: 100, Current: 100, Plan: "free"}
got := planLimitDetails(r, res)
want := map[string]interface{}{"feature": "items_per_workspace", "limit": 100, "current": 100, "plan": "free"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("shell details = %v, want %v", got, want)
}
if msg := planLimitMessage(r, res); msg != "You've reached the 100-item limit on the free plan." {
t.Errorf("shell message = %q", msg)
}
}
// The invitee's member-limit refusal (BUG-3098) is its own message, not the
// plan-limit helpers', and it says "upgrade their plan". Both accept doors,
// the existing-account accept and register-with-code, must drop that clause
// for a shell and keep it for a browser (codex r1 on TASK-3293).
func TestMemberLimitRefusal_NativeShell(t *testing.T) {
for _, door := range []string{"existing", "register"} {
for _, shell := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/shell=%v", door, shell), func(t *testing.T) {
e := newAcceptLimitEnv(t)
inv := e.invite(t, door+"@example.com")
e.setMemberCap(t, e.members(t))
var req *http.Request
if door == "existing" {
u, err := e.srv.store.CreateUser(models.UserCreate{Email: door + "@example.com", Name: "Invitee", Password: "pw-invitee-12345"})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
tok, err := e.srv.store.CreateSession(u.ID, "go-test", "192.0.2.1", "", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
req = httptest.NewRequest("POST", "/api/v1/invitations/"+inv.Code+"/accept", nil)
const testCSRF = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
req.AddCookie(&http.Cookie{Name: "pad_session", Value: tok})
req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: testCSRF})
req.Header.Set("X-CSRF-Token", testCSRF)
} else {
body, _ := json.Marshal(map[string]string{
"email": door + "@example.com", "name": "New Invitee",
"password": "correct-horse-battery-staple", "invitation_code": inv.Code,
})
req = httptest.NewRequest("POST", "/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.RemoteAddr = "192.0.2.1:1234"
if shell {
req.Header.Set("User-Agent", shellUA)
}
rr := httptest.NewRecorder()
e.srv.ServeHTTP(rr, req)
var env planLimitEnvelope
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil || rr.Code != http.StatusForbidden || env.Error.Code != "workspace_member_limit" {
t.Fatalf("want 403 workspace_member_limit, got %d %s", rr.Code, rr.Body.String())
}
hasUpgrade := strings.Contains(strings.ToLower(env.Error.Message), "upgrade")
if shell && hasUpgrade {
t.Errorf("shell message offers an upgrade: %q", env.Error.Message)
}
if !shell && !hasUpgrade {
t.Errorf("browser message lost its upgrade clause: %q", env.Error.Message)
}
if !strings.Contains(env.Error.Message, "make room") {
t.Errorf("message lost the remedy: %q", env.Error.Message)
}
})
}
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ func (s *Server) beginWorkspaceMint(w http.ResponseWriter, r *http.Request) (wor
// behaviour both doors already had rather than changing it under cover
// of a refactor.
if userID != "" {
if !s.enforceUserPlanLimit(w, userID, "workspaces") {
if !s.enforceUserPlanLimit(w, r, userID, "workspaces") {
return workspaceMintAuth{}, false
}
}
@@ -0,0 +1,161 @@
import { test, expect, request as playwrightRequest, type Page } from '@playwright/test';
import { suiteFixture } from './fixtures';
/**
* PLAN-3291 DR-7 / TASK-3293 (BUG-3290): with the mobile shells' user agent,
* no upgrade copy, pricing or billing link is reachable in the web app. The
* mobile apps render this web app, so any of it would be a purchase call to
* action inside them.
*
* This checks what RENDERS, not source strings (the vitest guard does that).
* Every assertion runs twice: once with the shell UA, where it must find
* nothing, and once with an ordinary browser UA on the same pages, where it
* must find the surface (CONVE-12: an absence proves nothing if the surface
* never rendered at all).
*
* The e2e server is self-hosted, so the session is patched to cloud_mode and
* billing_available (every commerce surface on); a plan-limit refusal is
* stubbed at the create-workspace door. Desktop only: UA, not viewport, is the
* variable, and each leg registers and logs in a user (IP rate-limited).
*/
const SHELL_UA =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0 Mobile Safari/537.36 PadShell/1';
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36';
const PASSWORD = 'Playwright-NoCommerce-2026!';
const COMMERCE_LINKS =
'a[href*="/console/billing"], a[href*="/billing/portal"], a[href*="stripe.com"], a[href*="/pricing"], a[href^="mailto:info@"]';
const COMMERCE_COPY = /Upgrade to Pro|View Plans|Manage Billing|Compare plans|coming soon/i;
async function register(baseURL: string, adminToken: string, tag: string): Promise<string> {
const suffix = `${tag}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const email = `e2e-nc-${suffix}@example.com`;
const api = await playwrightRequest.newContext({
baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${adminToken}` }
});
try {
const resp = await api.post('/api/v1/auth/register', {
data: { email, username: `e2enc${suffix}`, name: 'No Commerce', password: PASSWORD }
});
if (!resp.ok()) throw new Error(`register failed (${resp.status()}): ${await resp.text()}`);
} finally {
await api.dispose();
}
return email;
}
async function openAsCloudUser(page: Page, email: string): Promise<void> {
// Routes first: login reloads the session, and a patch installed after
// that fetch settled would leave the page self-hosted (codex r3).
await page.route('**/api/v1/auth/session', async (route) => {
const resp = await route.fetch();
const body = await resp.json();
body.cloud_mode = true;
body.billing_available = true;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
await page.route('**/api/v1/workspaces', async (route) => {
if (route.request().method() !== 'POST') return route.fallback();
await route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({
error: {
code: 'plan_limit_exceeded',
message: "You've reached the 1-workspace limit on the free plan.",
details: { feature: 'workspaces', limit: 1, current: 1, plan: 'free' }
}
})
});
});
await page.goto('/login');
await page.getByPlaceholder('Email').fill(email);
await page.getByPlaceholder('Password').fill(PASSWORD);
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/auth/login') && r.request().method() === 'POST'),
page.getByRole('button', { name: /^sign in$/i }).click()
]);
}
async function commerceOn(page: Page): Promise<{ links: number; copy: boolean }> {
const links = await page.locator(COMMERCE_LINKS).count();
const copy = COMMERCE_COPY.test(await page.locator('body').innerText());
return { links, copy };
}
for (const leg of [
{ name: 'app (PadShell UA) renders no commerce', ua: SHELL_UA, shell: true },
{ name: 'browser control renders the commerce surfaces', ua: BROWSER_UA, shell: false }
]) {
test(`TASK-3293: ${leg.name}`, async ({ browser }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'UA is the variable, not viewport; one project keeps auth rate-limit pressure down.');
const { baseURL, apiToken } = suiteFixture();
const email = await register(baseURL, apiToken, leg.shell ? 's' : 'b');
const context = await browser.newContext({ baseURL, userAgent: leg.ua });
try {
const page = await context.newPage();
await openAsCloudUser(page, email);
// Settings: the Plan card renders in both; its buttons only in the browser.
await page.goto('/console/settings');
await expect(page.getByRole('heading', { name: 'Plan', exact: true })).toBeVisible();
let seen = await commerceOn(page);
if (leg.shell) {
expect(seen).toEqual({ links: 0, copy: false });
} else {
// the settings card's Upgrade button and the console nav's Billing link
expect(seen.links).toBeGreaterThanOrEqual(2);
expect(seen.copy).toBe(true);
}
// Billing, by deep link: plan and limits only in the app.
await page.goto('/console/billing');
await expect(page.getByRole('heading', { name: 'Current Plan' })).toBeVisible();
seen = await commerceOn(page);
if (leg.shell) {
await expect(page.getByTestId('plan-limits-card')).toBeVisible();
expect(seen).toEqual({ links: 0, copy: false });
await expect(page.getByRole('button', { name: /upgrade/i })).toHaveCount(0);
} else {
await expect(page.getByRole('heading', { name: 'Compare plans' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Upgrade to Pro' }).first()).toBeVisible();
}
// The checkout return deep links: web-checkout states, dropped in the app.
for (const q of ['success', 'cancelled']) {
await page.goto(`/console/billing?checkout=${q}`);
await expect(page.getByRole('heading', { name: 'Current Plan' })).toBeVisible();
const banner = page.locator('.upgrade-banner');
if (leg.shell) {
await expect(banner).toHaveCount(0);
await expect(page).not.toHaveURL(/checkout=/);
} else {
await expect(banner).toBeVisible();
}
}
// A plan-limit refusal: the toast states the limit; only the browser adds the upgrade.
await page.goto('/console');
await page.getByRole('button', { name: 'Create Workspace', exact: true }).click();
await page.getByPlaceholder('Workspace name').fill('Over the limit');
await Promise.all([
page.waitForResponse((r) => r.url().endsWith('/api/v1/workspaces') && r.request().method() === 'POST'),
page.getByRole('button', { name: 'Create Workspace', exact: true }).last().click()
]);
const toast = page.locator('.toast-error').filter({ hasText: '1-workspace limit' });
await expect(toast).toBeVisible();
if (leg.shell) {
await expect(toast).not.toContainText(/upgrade/i);
await expect(toast).not.toHaveAttribute('role', 'button');
} else {
await expect(toast).toContainText('Upgrade to Pro');
await expect(toast).toHaveAttribute('role', 'button');
}
} finally {
await context.close();
}
});
}
+11 -4
View File
@@ -102,7 +102,10 @@ class PadApiError extends Error {
*
* For `plan_limit_exceeded` (TASK-788) the shape is:
* { feature: string, limit: number, current: number, plan: string,
* upgrade_url: string, requested?: number }
* upgrade_url?: string, requested?: number }
*
* `upgrade_url` is absent when the request came from a mobile shell
* (PLAN-3291 DR-3).
*
* `requested` appears only where one operation would add MORE THAN ONE of
* the feature at once — today workspace import (BUG-3103) — and is the
@@ -319,18 +322,22 @@ function isConflictOrNotFound(err: unknown): err is PadApiError {
}
/**
* Returns a human-readable upgrade-signal message for a plan limit error.
* Returns a human-readable statement of a plan limit error, with no upgrade
* copy (showPlanLimitToast adds that where commerce is allowed).
* Falls back to the server-supplied `err.message` if details are unavailable,
* so the function is always safe to call. TASK-788.
*
* Example output:
* "You've reached the 3-member limit on the free plan. Upgrade to Pro →"
* "You've reached the 3-member limit on the free plan."
*/
function planLimitMessage(err: PadApiError): string {
// The server already sends a good sentence in err.message (TASK-788).
// We use it directly here so there is a single source of truth for the
// wording; callers append the upgrade link separately in the UI.
return err.message || 'Plan limit reached. Upgrade to Pro to continue.';
// Neutral on purpose: this string reaches the mobile apps, which may carry
// no upgrade call to action (PLAN-3291 DR-3). showPlanLimitToast adds the
// upgrade copy where commerce is allowed.
return err.message || 'Plan limit reached.';
}
/**
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, relative } from 'node:path';
/**
* PLAN-3291 DR-3 acceptance (TASK-3293): no upgrade copy and no billing
* destination anywhere in the web app outside showPlanLimitToast and the
* components that gate on authStore.commerceAllowed. The mobile apps render
* this web app, so an ungated one is a purchase call to action inside them
* (BUG-3290).
*
* This is one layer of four, and the weakest: it defines the population by
* text, so wording it does not match, or a URL built at runtime, slips past.
* The shell-UA e2e (e2e/task-3293-no-commerce-in-app.spec.ts) checks what
* actually renders; the server and the shells guard the rest (DR-4, DR-5).
*
* A gated file is exempt WHOLE: the scan proves only that it reads
* commerceAllowed, not that every surface in it sits under that gate. Line
* scoping would need a template parser, and a hand one is its own review
* loop. What renders inside those files is the e2e's job; it visits settings,
* billing (bare and both checkout returns) and the toast with both UAs.
*/
const SRC = fileURLToPath(new URL('../..', import.meta.url));
// Each of these reads commerceAllowed; the test below checks that it does.
const GATED = new Set([
'lib/billing/planLimitToast.ts',
'lib/components/layout/TopBar.svelte',
'lib/components/layout/YouSheet.svelte',
'routes/console/+layout.svelte',
'routes/console/settings/+page.svelte',
'routes/console/billing/+page.svelte'
]);
const PATTERNS: [string, RegExp][] = [
['upgrade copy', /Upgrade to Pro/],
['billing page link', /["'`]\/console\/billing["'`?#]/],
['billing portal link', /\/billing\/portal/],
['view plans copy', /View Plans/],
['manage billing copy', /Manage Billing/]
];
function sources(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...sources(p));
else if (/\.(svelte|ts)$/.test(e.name) && !/\.(test|spec)\.ts$/.test(e.name)) out.push(p);
}
return out;
}
// A comment may name the page; only code can render it.
function isComment(line: string): boolean {
const t = line.trim();
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('<!--');
}
describe('no commerce outside the commerceAllowed gate', () => {
const files = sources(SRC).map((p) => ({ rel: relative(SRC, p), text: readFileSync(p, 'utf8') }));
it('scans a real tree (vacuity guard)', () => {
expect(files.length).toBeGreaterThan(200);
for (const g of GATED) expect(files.some((f) => f.rel === g), g).toBe(true);
});
it('finds upgrade copy and billing links only in gated files', () => {
const hits: string[] = [];
for (const f of files) {
if (GATED.has(f.rel)) continue;
f.text.split('\n').forEach((line, i) => {
if (isComment(line)) return;
for (const [what, re] of PATTERNS) {
if (re.test(line)) hits.push(`${f.rel}:${i + 1} ${what}: ${line.trim()}`);
}
});
}
expect(hits).toEqual([]);
});
// The two account menus are not opened by the e2e (codex r2), so their one
// Billing link is pinned structurally: its opening tag sits directly under
// the commerceAllowed block, comments aside.
it('the account menus open their Billing link directly under commerceAllowed', () => {
for (const rel of ['lib/components/layout/TopBar.svelte', 'lib/components/layout/YouSheet.svelte']) {
const lines = files.find((f) => f.rel === rel)!.text.split('\n');
const at = lines.findIndex((l) => /href="\/console\/billing"/.test(l));
expect(at, rel).toBeGreaterThan(0);
let i = at - 1;
while (i >= 0 && isComment(lines[i])) i--;
expect(lines[i].trim(), rel).toBe('{#if authStore.commerceAllowed}');
}
});
it('every gated file reads commerceAllowed', () => {
for (const f of files) {
if (GATED.has(f.rel)) expect(f.text, f.rel).toMatch(/commerceAllowed/);
}
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// PLAN-3291 DR-3 (TASK-3293): one toast for every plan-limit refusal, with the
// upgrade copy and link only where commerce is allowed.
const state = vi.hoisted(() => ({ commerce: true }));
const show = vi.hoisted(() => vi.fn());
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
get commerceAllowed() {
return state.commerce;
}
}
}));
vi.mock('$lib/stores/toast.svelte', () => ({ toastStore: { show } }));
import { showPlanLimitToast } from './planLimitToast';
import { PadApiError } from '$lib/api/client';
const err = () =>
new PadApiError({ code: 'plan_limit_exceeded', message: "You've reached the 3-member limit on the free plan." });
describe('showPlanLimitToast', () => {
beforeEach(() => show.mockReset());
it('offers the upgrade where commerce is allowed', () => {
state.commerce = true;
showPlanLimitToast(err());
expect(show).toHaveBeenCalledWith(
"You've reached the 3-member limit on the free plan. Upgrade to Pro",
'error',
6000,
'/console/billing'
);
});
it('states the limit and nothing else in the app', () => {
state.commerce = false;
showPlanLimitToast(err());
expect(show).toHaveBeenCalledTimes(1);
const args = show.mock.calls[0];
expect(args[0]).toBe("You've reached the 3-member limit on the free plan.");
expect(args[3]).toBeUndefined();
expect(args.join(' ')).not.toMatch(/upgrade|billing|pro\b/i);
});
});
+21
View File
@@ -0,0 +1,21 @@
// The one place a plan-limit refusal becomes a toast (PLAN-3291 DR-3,
// TASK-3293). Every create/restore/import door used to append its own
// " Upgrade to Pro" and link /console/billing, which put a purchase call to
// action inside the mobile apps (BUG-3290). A refusal still says what
// happened everywhere; only where commerce is allowed does it add the upgrade
// copy and link.
import { planLimitMessage, type PadApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
export const PLAN_LIMIT_UPGRADE_SUFFIX = ' Upgrade to Pro';
export const BILLING_PATH = '/console/billing';
export function showPlanLimitToast(err: PadApiError): void {
const message = planLimitMessage(err);
if (authStore.commerceAllowed) {
toastStore.show(message + PLAN_LIMIT_UPGRADE_SUFFIX, 'error', 6000, BILLING_PATH);
} else {
toastStore.show(message, 'error', 6000);
}
}
@@ -3,7 +3,8 @@
import type { Collection, Item } from '$lib/types';
import { isAgentCollection } from '$lib/types';
import { createDefaultFields } from '$lib/collections/createDefaults';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { authStore } from '$lib/stores/auth.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import { titleLimitError } from '$lib/items/titleLimit';
@@ -312,7 +313,7 @@
return;
}
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
errorMsg = err.message || 'Failed to create item';
}
@@ -27,7 +27,8 @@
import { tabsStore } from '$lib/stores/tabs.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { toastStore } from '$lib/stores/toast.svelte';
import type { Workspace, WorkspaceTemplate } from '$lib/types';
import { groupTemplatesByCategory } from '$lib/utils/templates';
@@ -240,7 +241,7 @@
if (!alive || myOp !== opSeq) return;
if (authStore.userId !== createUser) return;
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show('Failed to create workspace', 'error');
}
@@ -15,7 +15,8 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { collectionStore } from '$lib/stores/collections.svelte';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
@@ -96,12 +97,7 @@
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
} catch (err: any) {
if (isPlanLimitError(err)) {
toastStore.show(
planLimitMessage(err) + ' Upgrade to Pro',
'error',
6000,
'/console/billing'
);
showPlanLimitToast(err);
} else {
toastStore.show(err?.message || 'Failed to create item', 'error');
}
+3 -2
View File
@@ -9,7 +9,8 @@
import { GITHUB_REPO_URL } from '$lib/brand/links';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { parseSettings, itemUrlId, isAgentCollection } from '$lib/types';
import { createDefaultFields } from '$lib/collections/createDefaults';
import { getActiveKey } from '$lib/nav/destinations';
@@ -232,7 +233,7 @@
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
} catch (err: any) {
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else if (quickAddSession === session) {
// Still open on the text that was refused: say why, in place.
quickAddError = err?.message || 'Failed to create item';
+2 -1
View File
@@ -292,7 +292,8 @@
<a href="/console/settings" class="menu-link" role="menuitem" onclick={closeUserMenu}>
Settings
</a>
{#if authStore.cloudMode}
<!-- Hidden in the mobile apps: no purchase path there (PLAN-3291 DR-3). -->
{#if authStore.commerceAllowed}
<a href="/console/billing" class="menu-link" role="menuitem" onclick={closeUserMenu}>
Billing
</a>
@@ -102,7 +102,8 @@
<span class="row-label">Workspaces</span>
<span class="row-chev" aria-hidden="true">›</span>
</a>
{#if authStore.cloudMode}
<!-- Hidden in the mobile apps: no purchase path there (PLAN-3291 DR-3). -->
{#if authStore.commerceAllowed}
<a class="row" href="/console/billing" onclick={onclose}>
<span class="row-icon" aria-hidden="true">💳</span>
<span class="row-label">Billing</span>
+21
View File
@@ -163,6 +163,14 @@ function notifyIdentityChange() {
for (const fn of identityListeners) fn(previousUserId);
}
// The user-agent token both mobile shells append (PLAN-3291 DR-2). The server
// matches the same prefix (internal/server/handlers_cloud.go nativeShellMarker).
export const NATIVE_SHELL_MARKER = 'PadShell/';
function isNativeShell(): boolean {
return typeof navigator !== 'undefined' && (navigator.userAgent ?? '').includes(NATIVE_SHELL_MARKER);
}
export const authStore = {
get session() { return session; },
get user() { return session?.user ?? null; },
@@ -179,6 +187,19 @@ export const authStore = {
// this value — false means the CTA is hidden entirely, not just disabled.
// TASK-800.
get billingAvailable() { return session?.billing_available ?? false; },
// nativeShell is true inside a Pad mobile app's web view: both shells append
// the `PadShell/1` marker to every request's user agent (PLAN-3291 DR-2;
// pad-mobile AppUserAgent.MARKER / UserAgent.marker). The prefix is matched
// so a future `PadShell/2` is still an app. Read live, not cached: it costs a
// substring search and has no session to wait for.
get nativeShell() { return isNativeShell(); },
// commerceAllowed is the ONE choke point every billing surface reads
// (PLAN-3291 DR-3): an upgrade call to action, a pricing or billing link, a
// plans table. False in the mobile apps, which may carry no purchase path
// and no call to action for one (Apple 3.1.3(f), Play's anti-steering rule;
// BUG-3290), and false off Pad Cloud, which has no plans. The purchase
// buttons additionally need billingAvailable.
get commerceAllowed() { return (session?.cloud_mode ?? false) && !isNativeShell(); },
// emailConfigured is false on a self-host instance with no transactional
// email provider wired. Defaults to true when absent (older servers, or
// before the session loads) so reset/invite flows keep their normal copy
@@ -0,0 +1,55 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
/**
* PLAN-3291 DR-2 / DR-3 (TASK-3293): authStore.commerceAllowed is cloud mode
* AND not a mobile shell, the shell being named by the `PadShell/` marker the
* apps append to the user agent.
*/
const session = vi.hoisted(() => ({ value: null as unknown }));
vi.mock('$lib/api/client', () => ({
api: { auth: { session: vi.fn(async () => session.value) } }
}));
const BROWSER = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1';
const IOS_SHELL = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 PadShell/1';
function withUA(ua: string) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(ua);
}
async function store(cloud: boolean) {
session.value = { authenticated: true, cloud_mode: cloud, user: { id: 'u1', email: 'u1@example.com' } };
const { authStore } = await import('./auth.svelte');
await authStore.load();
return authStore;
}
describe('authStore.commerceAllowed', () => {
beforeEach(() => vi.resetModules());
afterEach(() => vi.restoreAllMocks());
it('is true for a browser on Pad Cloud (control)', async () => {
withUA(BROWSER);
const s = await store(true);
expect(s.nativeShell).toBe(false);
expect(s.commerceAllowed).toBe(true);
});
it('is false in the app on Pad Cloud', async () => {
withUA(IOS_SHELL);
const s = await store(true);
expect(s.nativeShell).toBe(true);
expect(s.commerceAllowed).toBe(false);
});
it('is false off Pad Cloud, browser or not', async () => {
withUA(BROWSER);
expect((await store(false)).commerceAllowed).toBe(false);
});
it('matches a later shell version by prefix', async () => {
withUA(BROWSER + ' PadShell/2');
expect((await store(true)).commerceAllowed).toBe(false);
});
});
@@ -4,7 +4,8 @@
import { page, navigating } from '$app/state';
import { browser } from '$app/environment';
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
import { api, PadApiError, isPlanLimitError, planLimitMessage, isConflictOrNotFound } from '$lib/api/client';
import { api, PadApiError, isPlanLimitError, isConflictOrNotFound } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import type { BulkItemFailure, BulkItemsRequest, Collection, Item, PaneTarget, QuickAction, View, ViewConfig } from '$lib/types';
import { parseSettings, parseFields, parseSchema, parseTags, getStatusOptions, itemUrlId, formatItemRef } from '$lib/types';
import { plansProgressToMap, fetchCollectionProgress } from '$lib/collections/progressMerge';
@@ -2414,7 +2415,7 @@
} catch (err: any) {
if (!identityHeld(epochAtEntry)) return;
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show(err?.message || 'Failed to create item', 'error');
}
@@ -2510,7 +2511,7 @@
// user's unsaved text (BUG-3084).
if (identityHeld(epochAtEntry)) {
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show(err?.message || 'Failed to create item', 'error');
}
@@ -2723,7 +2724,7 @@
} catch (err: any) {
if (!identityHeld(epochAtEntry)) return;
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show(err?.message || 'Failed to create item', 'error');
}
@@ -15,47 +15,47 @@ identityGateSuite({
table: {
asyncFunctions: {
reconcileRouteCollectionSlug: {
reviewed: '4f6fc2db87f6',
reviewed: '9fde860bfbe8',
why: 'the route (ws, slug), then identityHeld(epochAtEntry), then the collection id and navigating.to, before the retag, the sidebar refresh and the goto',
},
deltaSync: { reviewed: '411fdaffa22b', why: 'identityHeld(epochAtEntry) after the reconcile; the page commits nothing itself' },
refreshProgress: { reviewed: 'bed119231336', why: `${ENTRY}, on both arms` },
refreshProgress: { reviewed: '05d846acf2b8', why: `${ENTRY}, on both arms` },
loadCollection: {
reviewed: '4789daf5c1e8',
reviewed: '7131c5788aa4',
why: 'loadSeq then identityHeld(epochAtEntry) after every await, with collection writes also gated on collectionGen; re-stamps identityEpochAtLoad at entry; the finally clears metaLoading on loadSeq alone, deliberately',
},
handleStatusChange: {
reviewed: '79b8af40feea',
reviewed: '18f188e91d35',
why: `${ENTRY}; the open-children confirm gets identityHeld as its re-check, and the forced re-send checks again; the rethrows stay unfenced so BoardView can undo its optimistic move`,
},
handleReorder: { reviewed: '85e3a09f4c5a', why: 'optimistic upserts before any await (persistReorder applies them before its first send); identityHeld(epochAtEntry) after each update, before its settle, and before a refusal restores the original rows (BUG-3259)' },
handleReorder: { reviewed: '756e2196dc5e', why: 'optimistic upserts before any await (persistReorder applies them before its first send); identityHeld(epochAtEntry) after each update, before its settle, and before a refusal restores the original rows (BUG-3259)' },
handleGroupReorder: {
reviewed: '8a2d335085ec',
reviewed: 'f0862b229f3b',
why: 'collGen, the route, then identityHeld(epochAtEntry) before the collection write; the conflict path checks identity BEFORE its re-read request (BUG-3238) and after it, before the reseed and navigation; both toasts fenced',
},
createNewItem: { reviewed: '13b1aae0beef', why: `${ENTRY}, the navigation into the new item included; the finally clears creatingNew only under the identity` },
quickCreateInColumn: { reviewed: '8e02a3554bee', why: `${ENTRY}; answers null on a lost identity; the rethrow is unfenced, deliberately` },
createNewItem: { reviewed: '6f49e2c1b364', why: `${ENTRY}, the navigation into the new item included; the finally clears creatingNew only under the identity` },
quickCreateInColumn: { reviewed: 'ca8837f96e2b', why: `${ENTRY}; answers null on a lost identity; the rethrow is unfenced, deliberately` },
leaveSaveAll: {
reviewed: 'c947e1ecb845',
why: 'saveAllDrafts re-checks identityHeld after each create and answers identity_moved; runPendingNav is gated on its own identityHeld(epochAtEntry) return as well, so a no-draft Save all cannot run a pending navigation the NEW user set up (BUG-3238)',
},
quickCreate: { reviewed: 'ec5cb3f4bcfb', why: `${ENTRY}; the finally clears creatingNew only under the identity` },
runBulkOn: { reviewed: '72559cfacbf4', why: `${ENTRY}, per chunk and after the delta sync; the Undo action re-checks at click time` },
saveCurrentView: { reviewed: '5c6fd4f758b6', why: `${ENTRY}; the finally clears savingView only under the identity` },
deleteView: { reviewed: '9878213c7a88', why: ENTRY },
quickCreate: { reviewed: '423dc1aef030', why: `${ENTRY}; the finally clears creatingNew only under the identity` },
runBulkOn: { reviewed: '39d1f47793e6', why: `${ENTRY}, per chunk and after the delta sync; the Undo action re-checks at click time` },
saveCurrentView: { reviewed: '26689b3d22cc', why: `${ENTRY}; the finally clears savingView only under the identity` },
deleteView: { reviewed: '7eb2057eabec', why: ENTRY },
},
nested: [
{ body: /event\.type === 'collection_updated'/, why: "SSE item events: pageIdentityHeld() at entry; the refresh arm checks collGen, the slug, then identityHeld(epochAtEntry) before its write", reviewed: '50a24f9d1bde' },
{ body: /event\.type === 'collection_updated'/, why: "SSE item events: pageIdentityHeld() at entry; the refresh arm checks collGen, the slug, then identityHeld(epochAtEntry) before its write", reviewed: '7b0ae5504172' },
{ body: /result\.workspace !== wsSlug/, why: 'sync results: pageIdentityHeld() at entry, identityHeld(epochAtEntry) after the progress refresh', reviewed: '5ef6bd27766d' },
{ body: /resp\.results\.map/, why: 'search timer, as an async function: identityHeld(epochAtSchedule) at fire time and after the search, with the query and route snapshots', reviewed: 'dcdace0b47a4' },
{ body: /resp\.results\.map/, why: 'search timer, as an async function: identityHeld(epochAtSchedule) at fire time and after the search, with the query and route snapshots', reviewed: '3b5288de7298' },
],
markup: [],
continuations: [
{ call: /replaceState: true \}\)\.catch\($/, body: /./, in: 'reconcileRouteCollectionSlug', why: 'rename navigation failure: clears renameNav only if it still holds this target', reviewed: '404d3b33c729' },
{ call: /plansProgress\(ws\)\.catch\($/, body: /./, why: 'refreshProgress fetch: a failure reads as none; commits nothing', reviewed: '81b84e68a6e7' },
{ call: /api\.views\.list\(ws, coll\)\.catch\($/, body: /./, why: 'loadCollection views fetch: a failure reads as none; commits nothing', reviewed: 'a350e2770cc4' },
{ call: /api\.members\.list\(ws\)\.catch\($/, body: /./, why: 'loadCollection members fetch: a failure reads as none; commits nothing', reviewed: '03f9e593f050' },
{ call: /^setTimeout\($/, body: /resp\.results\.map/, why: 'search timer, as the deferred callback: the same unit as the nested row above', reviewed: 'dcdace0b47a4' },
{ call: /replaceState: true \}\)\.catch\($/, body: /./, in: 'reconcileRouteCollectionSlug', why: 'rename navigation failure: clears renameNav only if it still holds this target', reviewed: '182477bb5fcc' },
{ call: /plansProgress\(ws\)\.catch\($/, body: /./, why: 'refreshProgress fetch: a failure reads as none; commits nothing', reviewed: '75fd2e0701db' },
{ call: /api\.views\.list\(ws, coll\)\.catch\($/, body: /./, why: 'loadCollection views fetch: a failure reads as none; commits nothing', reviewed: '89870fb1c06b' },
{ call: /api\.members\.list\(ws\)\.catch\($/, body: /./, why: 'loadCollection members fetch: a failure reads as none; commits nothing', reviewed: '4f77fdc9eb81' },
{ call: /^setTimeout\($/, body: /resp\.results\.map/, why: 'search timer, as the deferred callback: the same unit as the nested row above', reviewed: '3b5288de7298' },
{ call: /^setTimeout\($/, body: /identityHeld\(epochAtSchedule\)\) return; bypassNavGuard/, why: 'leave-guard popstate reset: identityHeld(epochAtSchedule) before clearing bypassNavGuard', reviewed: '9d5cca6b3d52' },
{
call: /^goto\(url\)\.finally\($/,
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import type { Collection, Item, ItemConventionMetadata } from '$lib/types';
import { conventionCreatePayload } from '$lib/conventions/createPayload';
import { parseFields, parseSchema, itemUrlId, formatItemRef } from '$lib/types';
@@ -286,7 +287,7 @@
resetForm();
} catch (err: unknown) {
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show((err as Error)?.message || 'Failed to create convention', 'error');
}
@@ -3,7 +3,8 @@
import { page } from '$app/state';
import { ownValue } from '$lib/utils/ownValue';
import { goto } from '$app/navigation';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { parseFields, parseSchema, itemUrlId, formatItemRef, type Collection, type Item } from '$lib/types';
import { collectionStore } from '$lib/stores/collections.svelte';
import { canCreateIn } from '$lib/collections/canCreateIn';
@@ -287,7 +288,7 @@
await loadPlaybooks(wsSlug);
} catch (err: unknown) {
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show((err as Error)?.message || 'Failed to create playbook', 'error');
}
@@ -358,7 +359,7 @@
await loadPlaybooks(wsSlug);
} catch (err: unknown) {
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
toastStore.show((err as Error)?.message || 'Failed to duplicate playbook', 'error');
}
@@ -3,7 +3,8 @@
import { roleIcon } from '$lib/utils/roleIcon';
import { page } from '$app/state';
import { onDestroy, onMount } from 'svelte';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { collectionStore } from '$lib/stores/collections.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
@@ -200,7 +201,7 @@
// state entirely (BUG-3084).
if (!identityHeld(epochAtEntry)) return;
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
// BUG-3115: this used to be console-only, so a refusal looked
// like a button that did nothing.
@@ -13,23 +13,23 @@ identityGateSuite({
source: new URL('./+page.svelte', import.meta.url),
table: {
asyncFunctions: {
submitNewItem: { reviewed: 'd592b0b54568', why: `${PAGE_AND_ENTRY}; the finally clears newItemSaving only under the identity` },
handleLaneDrop: { reviewed: 'd268ae879c85', why: `${PAGE_AND_ENTRY}, including before the recovery reload` },
submitNewItem: { reviewed: 'e49c281eb86e', why: `${PAGE_AND_ENTRY}; the finally clears newItemSaving only under the identity` },
handleLaneDrop: { reviewed: '9f12cfcf7f38', why: `${PAGE_AND_ENTRY}, including before the recovery reload` },
handleDndFinalize: {
// Re-reviewed for BUG-3259: the lane renumber is planned around
// view-only cards (planLaneOrder), and its no-room toast and the
// empty-batch return both sit after the identity check that guards
// the optimistic commit. A refused batch reloads the board, after
// an identity check, as the role write's recovery does (codex r4).
reviewed: '9c9aadb2ef7c',
reviewed: 'aabdad24a8ac',
why: `${PAGE_AND_ENTRY}; the identity check sits BEFORE the role write, because that write carries currentUserId; a lost-identity exit writes no shared interaction state`,
},
loadData: {
reviewed: 'a0be40bdaf09',
reviewed: 'beee4ab0237c',
why: 'identityHeld(epochAtEntry) then loadGen on both arms before any commit; re-stamps identityEpochAtLoad only after the data it vouches for; the finally clears loading on loadGen alone, deliberately (#1378)',
},
saveRole: { reviewed: 'dad11e017451', why: `${PAGE_AND_ENTRY}, per branch` },
deleteRole: { reviewed: 'ca46c5c9056d', why: PAGE_AND_ENTRY },
saveRole: { reviewed: 'c27f82ace51a', why: `${PAGE_AND_ENTRY}, per branch` },
deleteRole: { reviewed: '6d78f8f48bd1', why: PAGE_AND_ENTRY },
},
nested: [],
markup: [],
@@ -2,7 +2,8 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount, onDestroy, untrack } from 'svelte';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { sseService } from '$lib/services/sse.svelte';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { authStore } from '$lib/stores/auth.svelte';
@@ -610,7 +611,7 @@
} catch (err: unknown) {
if (!identityHeld(epochAtEntry)) return;
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
inviteResult = { message: err instanceof Error ? err.message : 'Failed to invite', type: 'error' };
}
@@ -14,34 +14,34 @@ identityGateSuite({
source: new URL('./+page.svelte', import.meta.url),
table: {
asyncFunctions: {
refreshCollections: { reviewed: '99eebed18312', why: 'collectionsGen and the workspace, then identityHeld(epochAtEntry), before the commit; a failure commits nothing' },
refreshCollections: { reviewed: '8eae0856214c', why: 'collectionsGen and the workspace, then identityHeld(epochAtEntry), before the commit; a failure commits nothing' },
load: {
reviewed: 'fedd925ecc86',
reviewed: '49e07be52aa6',
why: 'loadGen after each await (collectionsGen too for the collections write). It has no identity check, and it re-stamps identityEpochAtLoad BEFORE its awaits, where roles and library re-stamp after the data lands. The advisory list on TASK-3097 carries that. Re-reviewed for BUG-3260: it no longer writes the name or context (an effect seeds them from the settled store), so it commits strictly less than before',
},
saveName: { reviewed: '9d02b62e22dd', why: `${ENTRY}; the finally clears the button's own busy flag unfenced, on purpose` },
saveContext: { reviewed: '74dda10addda', why: `${ENTRY}, including between the update and the shared-store setCurrent; the finally clears its own busy flag` },
saveName: { reviewed: '873c783bf6cb', why: `${ENTRY}; the finally clears the button's own busy flag unfenced, on purpose` },
saveContext: { reviewed: 'c7599b4b7ff4', why: `${ENTRY}, including between the update and the shared-store setCurrent; the finally clears its own busy flag` },
handleCollectionCreated: { reviewed: 'd79a036401d8', why: 'identityHeld(epochAtEntry) after refreshCollections, which fences its own commit' },
handleCollectionUpdated: { reviewed: '2f678b54cc5f', why: 'identityHeld(epochAtEntry) after refreshCollections, which fences its own commit' },
handleInvite: { reviewed: '7dd08c867284', why: `${ENTRY}, before the clipboard write as well as the page commits; the finally clears its own busy flag` },
handleRemoveMember: { reviewed: '5a2aaec78c94', why: ENTRY },
handleCancelInvitation: { reviewed: 'd6e4eff9e2f3', why: ENTRY },
handleChangeRole: { reviewed: 'a73cbf90d279', why: ENTRY },
toggleAccessPanel: { reviewed: '39c7fc7d5c5f', why: `${ENTRY}; the finally clears the panel's loading flag` },
saveCollectionAccess: { reviewed: '7334c6c5470d', why: `${ENTRY}, the revert included; the finally clears its own busy flag` },
handleInvite: { reviewed: '186a0b583148', why: `${ENTRY}, before the clipboard write as well as the page commits; the finally clears its own busy flag` },
handleRemoveMember: { reviewed: '2e4475081d64', why: ENTRY },
handleCancelInvitation: { reviewed: 'fba3090b1e99', why: ENTRY },
handleChangeRole: { reviewed: '259e70a8d250', why: ENTRY },
toggleAccessPanel: { reviewed: '5608048fa64f', why: `${ENTRY}; the finally clears the panel's loading flag` },
saveCollectionAccess: { reviewed: 'f81531fe3ad4', why: `${ENTRY}, the revert included; the finally clears its own busy flag` },
handleDeleteWorkspace: {
reviewed: 'e768649d2e9d',
reviewed: 'f9b3d9cf8e03',
why: 'identityHeld(epochAtDelete) after the delete on both arms; the Undo callback it hands the global toast compares the epoch at click time against the one captured at delete time',
},
undoDeleteWorkspace: { reviewed: '2352e31ccc6d', why: "identityHeld(epochAtDelete), the caller's delete-time epoch, after the restore on both arms" },
undoDeleteWorkspace: { reviewed: 'd9f84d9f668c', why: "identityHeld(epochAtDelete), the caller's delete-time epoch, after the restore on both arms" },
},
nested: [],
markup: [
{ body: /copyToClipboard\(url\)/, why: 'Copy invite link: pageIdentityHeld() before reading the invitation and again before the toast', reviewed: 'e9571b58f38c' },
],
continuations: [
{ call: /^setTimeout\($/, body: /nameStatus = 'idle'/, in: 'saveName', why: `saveName status timer: ${TIMER}`, reviewed: 'e72f33b72439' },
{ call: /^setTimeout\($/, body: /contextStatus = 'idle'/, in: 'saveContext', why: `saveContext status timer: ${TIMER}`, reviewed: 'e414902cb0ac' },
{ call: /^setTimeout\($/, body: /nameStatus = 'idle'/, in: 'saveName', why: `saveName status timer: ${TIMER}`, reviewed: '2a72a8c678c4' },
{ call: /^setTimeout\($/, body: /contextStatus = 'idle'/, in: 'saveContext', why: `saveContext status timer: ${TIMER}`, reviewed: '8b862e3ec852' },
],
helpers: {
captureIdentity: '7f6903e09e84',
+3
View File
@@ -166,6 +166,9 @@
>
Connected Apps
</a>
{/if}
<!-- Hidden in the mobile apps: no purchase path there (PLAN-3291 DR-3). -->
{#if authStore.commerceAllowed}
<a
href="/console/billing"
class="nav-link"
+45 -3
View File
@@ -42,6 +42,11 @@
// sidecar has Stripe keys loaded — no code change needed at deploy time.
// TASK-800.
let stripeAvailable = $derived(authStore.billingAvailable);
// In the mobile apps this page shows the plan and its limits only: no
// plans table, prices, checkout, portal or interest prompt (PLAN-3291
// DR-3; BUG-3290). The nav links here are hidden there too; this is the
// page's own guard for a deep link.
let commerceAllowed = $derived(authStore.commerceAllowed);
// Checkout-in-progress state. True while the POST /billing/checkout
// fetch is in flight so the button shows a spinner and prevents
@@ -218,8 +223,13 @@
// awaited here — onMount returns immediately so Svelte can run the
// onDestroy cleanup path synchronously if the user navigates away
// before any task resolves (the `destroyed` guard handles that).
// A checkout return is a web-checkout state. In the app it is dropped
// unread: its banners talk about a payment and link support about one
// (PLAN-3291 DR-3, codex r1 on TASK-3293).
const checkoutParam = page.url.searchParams.get('checkout');
if (checkoutParam === 'success') {
if (!authStore.commerceAllowed) {
if (checkoutParam) clearCheckoutQuery();
} else if (checkoutParam === 'success') {
startUpgradeConfirmation();
} else if (checkoutParam === 'cancelled') {
// User abandoned the Stripe Checkout page. Show a brief notice
@@ -243,7 +253,9 @@
<div class="billing-page">
<h1 class="page-title">Billing</h1>
{#if upgradeStatus === 'checking'}
{#if !commerceAllowed}
<!-- no checkout-result banners in the apps -->
{:else if upgradeStatus === 'checking'}
<div class="upgrade-banner checking" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<span>Confirming your upgrade…</span>
@@ -284,7 +296,9 @@
</p>
</div>
{#if isPro}
{#if !commerceAllowed}
<!-- no purchase path in the apps -->
{:else if isPro}
<a href="/billing/portal" class="secondary-btn">Manage Billing</a>
{:else if stripeAvailable}
<div class="cta-line">
@@ -300,6 +314,33 @@
</div>
</section>
{#if !commerceAllowed}
<section class="card" data-testid="plan-limits-card">
<h2 class="card-title">Plan limits</h2>
<div class="card-body compare">
<table class="compare-table">
<thead>
<tr>
<th scope="col" class="feature-col">Feature</th>
<th scope="col" class="plan-col current">
<div class="plan-col-inner">
<span class="plan-col-name">{isPro ? 'Pro' : 'Free'}</span>
</div>
</th>
</tr>
</thead>
<tbody>
{#each COMPARE_ROWS as row (row.key)}
<tr>
<th scope="row" class="feature-col">{row.label}</th>
<td class="plan-col current">{formatCompareCell((isPro ? limits?.pro : limits?.free)?.[row.key], row.kind)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{:else}
<section class="card">
<h2 class="card-title">Compare plans</h2>
<div class="card-body compare">
@@ -353,6 +394,7 @@
{/if}
</div>
</section>
{/if}
</div>
<style>
+13 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import { api, isPlanLimitError } from '$lib/api/client';
import { showPlanLimitToast } from '$lib/billing/planLimitToast';
import { authStore } from '$lib/stores/auth.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
@@ -357,7 +358,7 @@
newTokenName = '';
} catch (err) {
if (isPlanLimitError(err)) {
toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing');
showPlanLimitToast(err);
} else {
tokenError = err instanceof Error ? err.message : 'Failed to create token';
}
@@ -726,12 +727,16 @@
{/if}
</p>
</div>
{#if authStore.user?.plan !== 'pro'}
<a href="/console/billing" class="primary-btn">
{authStore.billingAvailable ? 'Upgrade to Pro' : 'View Plans'}
</a>
{:else}
<a href="/console/billing" class="secondary-btn">Manage Billing</a>
<!-- In the mobile apps the card keeps the plan name and description
only: no upgrade, plans or billing link (PLAN-3291 DR-3). -->
{#if authStore.commerceAllowed}
{#if authStore.user?.plan !== 'pro'}
<a href="/console/billing" class="primary-btn">
{authStore.billingAvailable ? 'Upgrade to Pro' : 'View Plans'}
</a>
{:else}
<a href="/console/billing" class="secondary-btn">Manage Billing</a>
{/if}
{/if}
</div>
</section>