Covers PUT-1704 and PUT-1707: creating a workspace, admitting the master
account, and the whole of offboarding.
`createWorkspace` admits the creator with org_owned = 0, which is what makes
the master pay for itself and stay an invalid target of every member route.
`checkOwnerInvariant` asserts the rule no dialect can express -- the owner is
a member with org_owned = 0 and the only such member -- and a test breaks it
deliberately, since the schema cannot refuse a second one.
Three authority checks: 404 to a stranger so the endpoint is not an existence
oracle, 403 to a member who is not the master, and the master refused as a
target of member routes.
Handle problems surface as 400 (unusable) or 409 (taken), including the
unique-index race. `TeamStore` throws a bare Error, which the server would turn
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.
Disable writes `user.suspended` as well as suspended_at and suspended_reason.
PUT-1707 named only the latter two, but those are siblings added by 0061 and
0063 -- `userProtected` rejects on `if (user.suspended)` and reads neither.
Setting only the timestamp and reason would have recorded a disable that never
took effect, and disable is the whole of offboarding here.
Sessions are dropped through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key with its double-delete
pattern; without that a disabled member keeps authenticating from cache for the
session TTL, which is exactly the "next request, not after a cache TTL"
property disable is supposed to have. Revoking also preserves last_ip and
last_user_agent, which the member-facing audit view reads.
Files are untouched and re-enable restores the account.
Adds team_not_found, not_the_master_account and not_an_org_account to the
HttpError legacy codes, which the controller also needs.
Billing events, invalidateActorSubscription, audit rows and the GUI push are
deliberately not here -- they belong to phase 3 and PUT-1708.
Membership management for workspaces: addMember, removeMember,
getMembership, isMember, listMembers and listTeamsForUser. The permission
scan is untouched -- readUserGroupPerms already joins jct_user_group and
resolves group grants; this is the management side.
Resolves the ticket's "do not leave two writers" by splitting domains and
enforcing the split in SQL rather than by convention. Every existing caller
of GroupStore targets a seeded system group -- ADMIN_GROUP_UID,
default_user_group, default_temp_group -- never a team, so the two stores
were already disjoint in practice. GroupStore.addUsers/removeUsers now carry
`AND kind IS NULL`, making a team uid a no-op there, which costs no extra
query because it folds into the existing subquery and matches how addUsers
already treats an unknown username. TeamStore's writes select group_id from
a kind-filtered subquery, so neither store can reach the other's rows.
org_owned is written here but never accepted from a request; TeamService
sets it at provisioning and workspace creation only.
listMembers is keyset-paginated on id per doc/pagination.md, using the
shared cursor and limit helpers and fetching one row past the limit to
decide whether a cursor is warranted.
Passes 1/0 for org_owned rather than db.booleanValue, which yields a real
boolean on postgres and is rejected by the smallint column there -- sqlite
accepted it silently.
`GroupStore` has only addUsers/removeUsers; nothing creates, reads back or
lists a group at runtime. `TeamStore` is that missing half, scoped to rows
with `kind = 'team'`.
A workspace is addressed by `uid`, which `group` has carried as NOT NULL
UNIQUE since 0015. `handle` is a mutable display label with no addressing
role, so a rename invalidates nothing and a stale reference can never
resolve to a different workspace.
Soft delete releases the handle and keeps `name`. Nothing points at a
handle, so the name returns to the pool instead of being reserved forever
by a global unique index that cannot exclude dead rows -- mysql has no
partial indexes, so that exclusion was never available.
Handles validate to ^[a-z0-9]+(-[a-z0-9]+)*$, 3-64 chars, against a
reserved list. The charset is deliberately narrower than the column so the
engines' collations cannot disagree: mysql's utf8mb4_unicode_ci also folds
accents and eszett, which sqlite's NOCASE and postgres's lower() do not.
Every read filters `kind = 'team' AND deleted_at IS NULL`, which is what
makes the seeded admin/system groups unreachable rather than merely absent.
Handle lookups compare lower(handle) on postgres, where the index is on
that expression rather than the column.
Insert-only record of what a workspace administrator did to an account,
shaped like \`audit_user_to_group_permissions\` after 0019: nullable FK
beside a NOT NULL \`_keep\` column. The FKs are ON DELETE SET NULL, never
CASCADE, so hard-deleting an account cannot erase the record of the
resets performed on it.
Two indexes rather than one. The member's own view is the only place a
reset becomes visible to the account it was performed on, so
(user_id_keep, id) is a read path, not an optimisation.
\`share.holder_group_id\` mirrors \`holder_user_id\` from 0067. The existing
unique index does not constrain team shares at all -- it leads with
\`holder_user_id\`, which is NULL on every team share, and NULLs are
distinct -- so the group-scoped unique index is what prevents duplicates.
Drops the \`role\` column from the specified DDL: it contradicted the
settled single-administrator model.
Review follow-ups on the dedup migration.
mysql and postgres track no per-file applied state and re-execute every
migration on each boot, so the unguarded DELETE self-joined the whole table at
every process start, forever. Both now sit behind the same index-existence
check that guards the ALTER, which also stops a rolling deploy deleting on one
instance while another adds the index.
The dedup test was a false green. `targetVersion: 67` never applies 0071 -- the
loop breaks on `threshold + 1 >= targetVersion` but stamps the target anyway --
so the fixture asserted the current schema version on a database missing a
migration. It now replays the real 0072 file against a fully migrated database,
and a second test pins the off-by-one so nobody builds a fixture on it again.
\`jct_user_group\` had no unique constraint on (user_id, group_id) and
\`GroupStore.addUsers\` had no conflict clause, so re-adding a member
inserted a second row. \`readUserGroupPerms\` joins the junction table on
group_id alone, so each duplicate returned another copy of every group
permission the user holds.
Deduplicate keeping the lowest id, add the unique pair index, and make
\`addUsers\` ignore conflicts via the existing \`insertIgnoreInto\` helpers --
without that last part the index turns a re-add into a raised error, which
five call sites would log as a failed signup step.
mysql cannot delete from a table it reads in a subquery (error 1093), so
it uses a self-join with the same lowest-id-wins semantics.
Review follow-ups on the team columns.
NOCASE moves onto the `handle` column itself, not just the index. Index-only
NOCASE makes uniqueness case-insensitive while leaving `WHERE handle = ?`
case-sensitive, so the same lookup would match on mysql (utf8mb4_unicode_ci)
and miss on sqlite. Postgres still needs lower(handle) at the call site.
`requires_password_change` becomes NOT NULL DEFAULT 0 on all three dialects,
matching the three sibling `requires_*` flags. Left nullable, any query written
as `= 0` would silently exclude every pre-existing user.
Schema foundations for teams. Ships dark — nothing reads these columns yet.
group: kind, name, handle, plan_id, deleted_at
jct_user_group: org_owned
user: requires_password_change
`kind = 'team'` marks a workspace; the seeded system groups keep it NULL so a
team query can never surface them. `org_owned` distinguishes accounts the
workspace created from the master account, deciding who pays rather than who
may read. `requires_password_change` is a fourth `requires_*` flag for
assertVerifiedAccount, needed by the phase 5 reset flow but shipped here to
avoid a second three-dialect migration.
`handle` uniqueness is case-insensitive, and the dialects disagree by default:
mysql gets it from utf8mb4_unicode_ci, sqlite needs COLLATE NOCASE (as
0055 does for usernames), and postgres indexes lower(handle). Without this the
same migration would accept `Design-Team` next to `design-team` on sqlite and
postgres while mysql rejected it. Postgres handle lookups must therefore
compare lower(handle) to use the index.
idx_group_owner is sqlite-only: mysql and postgres already index that column.
idx_jct_user_group_group is composite, unlike the existing single-column keys.
* feat: events metering and quotas (PUT-1683)
* fix: drop the standing subscription charge and price single deliveries at 100 µ¢
An idle durable row costs nothing worth billing; the plan quotas bound how
many an account holds. Removing the daily line also removes the global
day-claim, the whole-table scan and the timer it rode on.
* test: stop asserting on documentation pages
The limits test read rate-limits-and-quotas.md and grepped it for numbers,
so every rewording of the page failed the backend suite. Docs are kept in
step by the PR and checked in review; AGENTS.md now says so.
* feat: delivery re-check cache, revocation and anchor settle (PUT-1677)
* fix: authorize re-anchors, settle each row once, purge revoked backlog (PUT-1677)
- A path-form row whose anchor is deleted only climbs to an ancestor its
holder may still watch under the mode it subscribed with; otherwise it ends
with `anchor_deleted`. It used to land on any surviving ancestor (a guest's
row on the owner's home), where the re-check denied every delivery but the
row still held an anchor slot and a filter evaluation there.
- After a climb the new anchor is re-verified and the climb repeated if a
recursive delete took that level too, instead of leaving the row on a dead uid.
- suspend() is one conditional write per row and reports which rows it was the
one to suspend; concurrent settles of the same grant (an unshare revokes
several strings) no longer each purge, forget and notify the same rows.
- One "subscriptions ended" notification per holder and app, carrying the count
and subjects, instead of one per row.
- A revoke that removed nothing no longer announces; the sweeper purges (not
defers) the backlog of a permission_revoked row; the reap purges pending
entries with the row.
- The delivery auth cache indexes entries by subscription so forget() is not a
scan of the whole cache.
* feat: pending event deliveries and delivery-class invariants (PUT-1676)
* fix: make pending delivery claims and drains atomic, keep the region under its ceiling (PUT-1676)
- claim() and the drain-time reindex run as Lua over the subscription's own
{subId}-tagged keys. Two claimers can no longer both lease the head, and a
drain that finds the queue empty deletes it in the same step it checks, so a
concurrent enqueue is never wiped between the two.
- An append writes the entry and its queue position in one MULTI (same slot),
with the index seeded before it and corrected after, so an entry is never
visible without its position and never left out of the sweeper's index.
- Pipelines no longer mix slots (index/counter vs. per-subscription keys), so
the store works on a multi-shard cluster, not only a single-shard one.
- Region shedding counts the marker it leaves behind; it used to stop one over
the ceiling and convert a real event into a marker on every enqueue after.
- A claimed or suspended subscription moves to the back of the sweeper's index,
so a delivery nobody settles cannot hold the head against every other backlog.
- `single` rows must carry a `worker` target: with sockets exhausted and no
handler, an unacknowledged delivery would sit at the head forever.
- A gap marker for a row with no socket target is dropped rather than counted
as a delivery of nothing.
- Backlog keys carry a 7-day TTL, refreshed by every claim, as a backstop for
keys a purge/enqueue race left unindexed.
* feat: durable event subscriptions store, cache, and routes (PUT-1673)
* fix: durable subscription hardening (PUT-1673)
- Expired rows stop delivering at dispatch time and no longer count toward
the per-account cap, instead of waiting for the sweep.
- The expiry sweep runs hourly with a jittered first pass shortly after boot;
a 24 h interval never fired on a fleet that redeploys more often than that.
- Only a durable generation bump marks peer regions cold. A session
subscribe/unsubscribe in one region used to force a primary read in every
other region on its next dispatch.
- `subject`/`anchor_path` widen to varchar(4096) to match `fsentries.path`,
and subjects longer than that are refused with `invalid_subject` rather than
failing the insert on MySQL/Postgres.
- The dispatch and durable integration suites wait for the specific delivery
they expect and assert only within their own folder; the old any-delivery
`settle()` let a late event from a previous test satisfy or pollute the
next one under CI load.
* feat: puter.events SDK module (PUT-1675)
* fix: end event subscriptions on a server-side disconnect and retry budget refusals (PUT-1675)
- A disconnect socket.io will not retry (`io server disconnect`) now fails
every subscription with `events_connection_failed` and drops the socket, so
the next onLocal() starts a fresh connection instead of reusing a dead one.
- The connection closes when the last subscription lapses on re-subscribe.
- A re-subscribe refused with `too_many_requests` is retried after 10 s
rather than ended for good.
- The API suite no longer passes when it cannot connect; every runtime it
runs on (node, browser, workerd) carries the socket.
OpenRouter and Together reject a request whose prompt plus max_tokens
overflows the model's context window. Both providers retried by deleting
max_tokens, which threw away the output cap the credit gate had sized to
the caller's remaining balance and let the retry run to the model's full
output limit with no second gate and no new hold.
The retry now goes through a shared helper that sizes a new cap from the
window and input count the rejection reports, falling back to the model's
declared context and a doubled prompt estimate, and never exceeds the cap
the gate set. When no window can be determined or no output fits, the
original rejection is rethrown instead of retrying uncapped. The rejected
params are copied rather than mutated, so the first attempt's record is
not rewritten after the fact.
Also corrects the estimator's own comment, which described the mean of
two approximations as a deliberate halving.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A Replicate prediction that ran and ended `failed` reaches the provider as a
plain Error with no HTTP status, so the driver-boundary translator could not
classify it and it surfaced as an unhandled 500, a critical alarm, and an
on-call page. Most of these are the model's content filter refusing the
user's prompt.
Wrap the run call and classify the failure: content-filter refusals become
a 400 with `errorCode: moderation_flagged` (the code chat refusals already
use); anything else becomes a 502 `upstream_failed`, which the alarm gate
skips. Status-bearing SDK errors pass through untouched so the boundary
translator keeps handling them. Upstream messages are stripped of markup and
bounded so an HTML error page can no longer ride into a response body or an
alarm signature.
Documents the codes callers can now act on in the txt2img reference.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat: app-scoped share listing and revoke surface (PUT-1670)
* fix: address review on app-scoped share surface
- Scope the uid-addressed revoke to the named row: only that row's
issuer's grant is withdrawn, and only that one invite cancelled —
an app or owner addressing one row no longer takes another issuer's
grant on the same (item, recipient) pair with it.
- Delete a pending row directly on uid-addressed revoke, so an invite
whose address registered but never claimed can still be withdrawn.
- Read the legacy `issuerAppUid` data key in the SQL app filter and
grouping, alongside the unified `issuedByApp`.
- Refuse malformed `appUid` input (duplicated param, empty string)
instead of silently listing everything, and refuse app-listing
cursors that decode but name no appUid.
- Derive the acting app from `effectiveApp` alone, per the actor
contract; drop the second derivation site.
- Pin the attribution semantics with tests: one row records one
issuance, so re-sharing the same pair re-attributes it to whoever
issued last, in both directions.
- Soften the uniform-404 docblocks to what the gates actually answer.
* feat: readable grant audit trail (PUT-1674)
* fix: cover the apps summary's no-app-group first page (PUT-1670)
listOutboundApps sorts the no-app group first via an empty-string
sentinel. Add a regression test pinning that a first page (no cursor)
actually returns it, and that the cursor it hands back resumes past it
into the app-keyed groups rather than skipping or repeating.
* feat: global outbound share listing (PUT-1664)
* fix: address review on outbound share listing
- Check share-row liveness per (holder, entry, issuer) so a grant
withdrawn outside unshare doesn't stay listed while another issuer
still reaches the same holder; batch the permission reads across the
whole page instead of per holder.
- Retire a revoked issuer's unclaimed invites in the revoke cascade,
and hide invites whose issuer lost their authority at read time.
- Unify the pending/active app-attribution key on `issuedByApp` and
dual-read the legacy `issuerAppUid` spelling.
- Add the missing share issuer index (sqlite, postgres) and correct
the listOutbound plan comment.
- Refuse cursors that decode but name no id instead of silently
restarting from page one.
- Consolidate the five hand-built ResolvedShare literals and the two
listing endpoints' parse/shape code.
- Ship the SDK surface: puter.fs.listSharedByMe() with docs, types,
suite coverage, and the rate-limit page entry.
Register claude-fable-5-1 in the Claude catalog and gate it in the
provider the same way as Fable 5: no sampling params, effort via
output_config, adaptive thinking with summarized display. The bare
claude-fable / claude-fable-latest aliases move to 5.1, matching how
the Opus aliases moved when Opus 5 landed.
Fable 5.1 keeps Fable 5's $10/$50 per MTok, 1M context and 128K output,
but bills cache reads at 0.025x input instead of the 0.1x every other
Claude model uses, so the catalog row carries its own rate and a test
pins it.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The shell renders its anonymous markup — the marketing homepage, an
`/app/<name>` landing — off the session cookie alone, and that cookie is
set with no maxAge, so a browser drops it on quit while the GUI's
localStorage token lives on. A returning user is served the anonymous
page and the GUI only tears it down once `whoami` answers, a network
round-trip after first paint. That teardown is the flash.
Gate it before the paint instead. The shell now emits, as the first thing
in <head>, a rule hiding `.hide-if-logged-in` under an <html> class that
an inline script adds iff `auth_token_v2` is in localStorage. The rule is
already in the cascade when the markup is parsed, so a browser holding a
token never paints it at all.
`initgui` settles the guess the token represents: `whoami` confirming the
session removes the nodes outright (replacing the old `#appLanding`
removal), and no session — none stored, or one `whoami` rejected — drops
the class so the markup comes back. The gate carries its own 12s failsafe
so a bundle that never boots can't strand a blank page.
SEO is unaffected: the HTML is byte-identical for every client, nothing
branches on user-agent, and a crawler has no stored token so it never
adds the class. Unreadable storage fails open the same way.
Anonymous markup opts in with `class="hide-if-logged-in"`, which
`home.html` already carried.
Drop the delayed fade-out when a logged-in user reaches the GUI and remove the landing overlay immediately instead. This avoids the overlay sitting above the email/phone/card verification gates because of its z-index.
Remove the /app landing overlay once `whoami` confirms the user is authenticated, even if the server rendered the page as anonymous because the session cookie was missing. This prevents the overlay from covering the email, phone, and card verification gates during localStorage-based session restores.
On device-phone/device-tablet, style.css pins every .window to
z-index 9999999 !important — including the fullpage dashboard window.
The Add Existing Account login dialog opens with backdrop: true, and
the .window-backdrop wrapper is not a .window, so it kept its small
inline z-index and rendered entirely behind the dashboard.
Pass stay_on_top: true like every other backdropped dashboard dialog,
and make the Forgot-password child dialog inherit the flag so it does
not open buried under the now stay-on-top login window's backdrop.
Verified with Playwright on iPhone 13 emulation and a desktop
viewport: login and recover-password dialogs stack above the
dashboard in both.
The app shell fed the row's full description into <meta name=
"description">, og:description and twitter:description. Long
descriptions now collapse whitespace and cut at a word boundary with an
ellipsis, never exceeding 150 characters.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The JSDoc claimed non-app actors are rejected; they are passed through, and
the check reads `actor.app` rather than `effectiveApp`, so an app in the
token chain is invisible to it. Callers depend on the pass-through — routes
combining `adminOnly` + `allowedAppIds` are reached with an admin's browser
session, and the dev-account surface is called from the desktop's own
session — so the docs are corrected instead of the behavior, on the gate and
on the `allowedAppIds` route option. Tests cover the gate per actor shape
(user session, worker, full-access token, app-issued access token,
app-under-user allowed/disallowed) and the admin composition.
A live audit of every keyed provider's /models endpoint against the hardcoded
catalogs found no stale entries but large gaps. This backfills them under four
rules: nothing vendor-deprecated, nothing without a price confirmed on the
vendor's official pricing page (each entry's source was recorded during
review), nothing absent from the live /models listing, and nothing that fails
a live routing probe.
Added: 46 Alibaba entries (qwen3/3.5/3.7/3.8 families, VL/omni/MT lines, and
Model Studio's hosted GLM/DeepSeek/Kimi third-party models) plus 9 dated
aliases; OpenAI chat-latest and gpt-4o-2024-11-20 plus 16 snapshot aliases;
Gemini gemma-4-31b-it and gemma-4-26b-a4b-it (vendor-documented free tier)
plus rolling -latest aliases; Mistral-hosted zai-glm-5-2 and a
mistral-medium-3.5 alias; deepseek-v4-flash-vision-exp; glm-5.3-flash.
Culled by the rules: 15 vendor-deprecated OpenAI entries (the 3.5/4/4-turbo
legacy line, gpt-4o-2024-05-13, o1-pro, four chat-latest predecessors, the
5.x codex line — deprecations page, most shut down 2026-10-23) and dated
aliases onto the deprecated o1/o3-mini/o4-mini; qwen3-vl-flash-2025-10-15
(live routing probe returned upstream 400 twice). Tiered Alibaba prices are
encoded at the base tier and busy-hour rates where time-of-day priced, noted
in comments.
Every surviving addition was verified end-to-end through a local deployment:
58/58 answered a live prompt, including all 46 Alibaba entries and every
spot-checked alias.
Not changed, flagged for maintainers: pre-existing o1, o3-mini and o4-mini
entries are now vendor-deprecated (shutdown 2026-10-23); the pre-existing
deepseek-v4-flash/-pro prices no longer match DeepSeek's current pricing
page; gemma-4 emits its own <thought> markup inline in content.
Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
Live-probing magistral-small-latest showed the model inlines its reasoning as
answer prose in a flat string — no ThinkChunk content, no markers, nothing a
client can separate. Mistral's chunked thinking shape is requested via
`prompt_mode: 'reasoning'`, which the provider previously dropped on the
floor: there was no way to even ask for it.
`custom.prompt_mode` now forwards to the SDK's `promptMode`, following the
BytePlus custom-params precedent. Opt-in rather than a default because the
API rejects the mode where the account/model lacks it ('Reasoning prompt
mode is not enabled for this model', code 3051) — verified end-to-end: the
3051 travels back through the stack, which also proves the parameter is
delivered. The moment Mistral enables the mode, the ThinkChunk content flows
into the existing splitter and comes out as `message.reasoning` and
`reasoning` stream chunks with no further changes.
Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
The SDK-wide flag now mirrors the per-call option's tri-state. Left unset
(the default), the release-date policy applies: models released on or after
2026-09-01 are normalized to the OpenAI shape, older models keep their
vendor-native shape, and nothing rides the wire — the server's policy
resolution decides. Setting `true` force-normalizes every chat() call
regardless of release date; `false` disables normalization for every call;
either explicit value is sent on each call that doesn't set its own.
A per-call `normalize` overrides the flag in both directions, and assigning
`undefined` restores the policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>