Skip to content

πŸ—οΈ PUT-1702: TeamStore β€” workspace CRUD, handle uniqueness, reserved words - #3709

Merged
jfcastro92 merged 1 commit into
mainfrom
juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words
Sep 3, 2026
Merged

πŸ—οΈ PUT-1702: TeamStore β€” workspace CRUD, handle uniqueness, reserved words#3709
jfcastro92 merged 1 commit into
mainfrom
juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words

Conversation

@jfcastro92

Copy link
Copy Markdown
Collaborator

Stacked on #3708 (PUT-1701) β†’ #3705 β†’ #3704. Review the schema stack first; the diff here is only TeamStore.

First code of phase 2. 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'.

getByUid(uid)            create({ ownerUserId, name, handle?, planId? })
getByHandle(handle)      update(uid, { name?, handle? })
isHandleAvailable(h)     softDelete(uid)
listByOwner(userId)

A workspace is addressed by uid, not by handle

group.uid has been NOT NULL UNIQUE since 0015, so the identity already existed β€” this just uses it. handle is a mutable display label with no addressing role.

That matters because the alternative fails in a way worse than an error. If routes keyed on handle, a rename would invalidate every stored reference β€” and once the freed handle was claimed by someone else, an old reference would not 404, it would resolve to a different workspace. There is a test (keeps the uid valid after the handle changes) pinning the property.

⚠ This changes the API surface already written up in the design doc and in PUT-1708 / PUT-1735 / PUT-1736 / PUT-1746: every route and SDK method now takes :uid instead of :handle. Those are updated. One consequence is worth a second opinion β€” see the open question at the bottom.

Soft delete releases the handle

softDelete sets deleted_at and nulls handle, keeping name.

Without this, a global unique index with no dead-row exclusion reserves a deleted workspace's handle permanently. MySQL has no partial indexes, so "exclude soft-deleted rows from the index" was never available as a fix β€” it had to be handled in the write path. Since nothing addresses a workspace by handle, releasing it dangles nothing; name survives so the row still reads sensibly in history.

Handle validation

^[a-z0-9]+(-[a-z0-9]+)*$, 3–64 characters, plus a 42-entry reserved list (admin, puter-support, security, billing, …) because a handle appears in activation email and share dialogs, where one that reads like Puter itself makes impersonation more convincing.

The charset is deliberately narrower than the column accepts, and that is the interesting part. Case-insensitive uniqueness is spelled three different ways across our engines, and they do not mean the same thing:

Mechanism Also folds
mysql utf8mb4_unicode_ci accents, ΓŸβ†’ss
sqlite COLLATE NOCASE ASCII only
postgres lower(handle) full Unicode, accents preserved

So cafΓ© / cafe collide on mysql alone, and ÄÖ / Àâ on mysql and postgres but not sqlite β€” a handle accepted on self-hosted sqlite could be refused on prod mysql. Restricting to [a-z0-9-] makes all three differences unreachable rather than relying on the engines to agree.

Two details a reviewer should check

Every read filters kind = 'team' AND deleted_at IS NULL. That predicate is what makes the seeded admin / system groups unreachable rather than merely absent β€” the criterion inherited from PUT-1699. never returns a seeded system group from any method walks every kind IS NULL row and asserts the store returns null for each. A team admin is not a platform admin.

Handle lookups are dialect-aware. Postgres indexes lower(handle), so #handleMatch() compares lower(handle) = lower(?) there and a plain = elsewhere, where the column collation already handles it.


Verification

sqlite and postgres β€” both verified

$ npx vitest run --config src/backend/vitest.config.ts src/backend/stores/team/
 Test Files  1 passed (1)
      Tests  17 passed (17)

$ PUTER_TEST_DB_ENGINE=postgres npx vitest run --config src/backend/vitest.config.ts src/backend/stores/team/
 Test Files  1 passed (1)
      Tests  17 passed (17)
Confirming the postgres branch is load-bearing, not defensive

Removing the dialect branch so #handleMatch() always returns `handle` = ?, then re-running on postgres:

 ❯ TeamStore.test.ts (17 tests | 1 failed)
     Γ— resolves a handle case-insensitively
 Tests  1 failed | 16 passed (17)

Note which test survives: refuses a duplicate handle, including one differing only in case still passes, because the unique index catches that regardless. It is the lookup that breaks. That is the same distinction flagged in #3704 β€” uniqueness and lookup are separately case-sensitive, and getting one right does not give you the other.

Full suite and typecheck

$ npm run test:backend
 Test Files  251 passed | 24 skipped (275)
      Tests  6718 passed | 26 skipped (6744)      # +17, no regressions

$ npm run typecheck
Type check passed β€” no new errors (33 known, baselined).

Open question this raises β€” not decided here

PUT-1726 specifies sharing as puter.fs.share('/me/Docs', { team: 'design-team' }, 'write') β€” a handle.

That is genuinely not the same case as a route: it is typed by a person and resolved immediately, so it is an input rather than a stored reference, and a uuid there would be miserable to write. But a scripted share to a handle keeps working after that handle is released and reclaimed, and would silently start sharing with an unrelated workspace. A 404 would be safe; retargeting is not.

Left open in PUT-1726 and Β§11 of the design doc rather than settled quietly. Three options are written up there.


Closes PUT-1702.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.97%
🟰 ±0%
27385 / 29140
πŸ”΅ Statements 92.08%
🟰 ±0%
29688 / 32239
πŸ”΅ Functions 90.39%
🟰 ±0%
4920 / 5443
πŸ”΅ Branches 80.93%
⬆️ +0.02%
19752 / 24405
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/stores/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/stores/team/TeamStore.ts 95.16% 91.17% 90.9% 96.42% 154, 192, 218
Generated in workflow #1426 for commit 37cb11d by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from e844c2f to 9e89579 Compare September 1, 2026 20:09
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 9e89579 to 77e40ef Compare September 1, 2026 21:36
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 77e40ef to cfb3d07 Compare September 1, 2026 22:03
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from cfb3d07 to 847aad0 Compare September 1, 2026 22:50
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 847aad0 to e07708d Compare September 2, 2026 16:05
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from e07708d to 023482a Compare September 2, 2026 16:51
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 023482a to 342b32d Compare September 2, 2026 19:32
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 342b32d to f1a6bcb Compare September 2, 2026 19:39
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from f1a6bcb to d0e28cc Compare September 2, 2026 21:37

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since were calling feature "team" maybe we change table name to teams also, so groups can be more akin to the workspace concept of groups, like within a team?

Comment thread src/backend/stores/team/TeamStore.ts
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from d0e28cc to ba55583 Compare September 3, 2026 13:46
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from ba55583 to 470ac9b Compare September 3, 2026 14:30
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 470ac9b to 425efd2 Compare September 3, 2026 15:42
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 425efd2 to beb27ad Compare September 3, 2026 18:55
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from beb27ad to 18b8059 Compare September 3, 2026 19:07
Base automatically changed from juancastro/put-1701-13-add-audit_team_membership-and-shareholder_group_id to main September 3, 2026 19:17
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 18b8059 to 1bfa430 Compare September 3, 2026 19:17
`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.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1702-21-teamstore-workspace-crud-handle-uniqueness-reserved-words branch from 1bfa430 to 37cb11d Compare September 3, 2026 19:20
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation

Engine: mysql 8 Β· teams_enabled: true

Create, read, rename

POST /teams {"name":"Acme Corp","handle":"acme"}
  -> 200 {"uid":"13341565-5223-4c0a-9d28-ca71c9da6682","name":"Acme Corp",
          "handle":"acme","is_owner":true,"created_at":"2026-09-03T23:25:43.000Z"}
PUT  /teams/<uid> {"name":"Acme Renamed"} -> 200, name updated

Handle uniqueness across owners β€” a second user taking the same handle:

POST /teams {"name":"Other Acme","handle":"acme"}
  -> 409 {"error":"That handle is taken","code":"conflict"}

A conflict, not a 500 β€” the unique index is caught and translated.

Handle validation runs before the index. HANDLE_PATTERN is
/^[a-z0-9]+(?:-[a-z0-9]+)*$/, commented "Narrower than the column, so the
engines' collations cannot disagree"
β€” and that is what happens in practice:

POST /teams {"handle":"ACME"} -> 400 {"error":"Unusable handle: malformed","code":"bad_request"}

Worth noting for review: this means a case-differing handle is rejected at
validation and never reaches the case-insensitive index, so on mysql the
utf8mb4_unicode_ci collation is defense-in-depth rather than the primary
mechanism. That is the stronger arrangement, and it is the first time the mysql
collation has been exercised at all β€” the automated suite runs sqlite and
postgres only.

Reserved handles

admin  -> 400 {"error":"Unusable handle: reserved","code":"bad_request"}
system -> 400 {"error":"Unusable handle: reserved","code":"bad_request"}
api    -> 400 {"error":"Unusable handle: reserved","code":"bad_request"}

One observation, not a defect: www is accepted. That is consistent with the
list's stated purpose β€” the docblock says "A handle reaching users in email makes
an impersonation more convincing"
, so the list guards impersonation, not routing.
If handles ever become subdomains or path prefixes (open question 2 in the design
doc), the list would want a routing-oriented pass β€” www, cdn, static,
assets, site, host. Flagging so the decision is deliberate rather than
inherited.

@jfcastro92
jfcastro92 merged commit d855315 into main Sep 3, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants