Resource: Organizations
Tag: Organizations · Module: organization (shrunk — space CRUD and invitations were carved out)
The organization itself. After the space (task #4) and invitation (task #5) extractions, this module keeps only org create/update. WorkOS is the source of truth for organizations.
Grounded: organization/api/{createorganization,updateorganization}.go,
internal/domain/database/organization.go.
Operations
| OperationID | Method + Path | RBAC | Notes |
|---|---|---|---|
create-organization | POST /organizations/create | — (any authed user) | collection action; creates an org; caller becomes admin |
update-organization | POST /organizations/{id}/update | EditOrganization | id = WorkOS org id (the caller's current org, from User.currentOrganizationId); RBAC verifies membership |
organization-logo-upload-ticket | POST /organizations/{id}/logo/upload-ticket | EditOrganization | presigned R2 upload (step 1) |
organization-logo-finalize | POST /organizations/{id}/logo/finalize | EditOrganization | validate + set logoUrl (step 2) |
organization-logo-remove | POST /organizations/{id}/logo/remove | EditOrganization | clear the logo |
switch-organizationis not here — it's a session operation (SessionApiHandler, reissues tokens) and lives in the Auth/Session resource (task #7).preview-organization(a public join-page org lookup) is deferred — not in the contract (no consumer today; the join route ignores the org slug). See the Deferred section below.delete-organizationis disabled in the current codebase (commented out) — out of scope until needed.
C10 / addressing note. The org is modeled as a collection item addressed by
{id}, not a singleton —create-organizationalready makes/organizationsa real collection, the client always holds its current org id (User.currentOrganizationId), and RBAC verifies membership. We do not use a singular/organizationsingleton root.
Identifier — WorkOS org id
The org has an internal Id int64 and a WorkosOrgId (uniqueIndex). Since WorkOS is the source of truth
for orgs (and the auth context already carries the WorkOS org id), the wire id is the WorkOS org id —
opaque, the external identity. The internal int64 stays internal. All item ops (update, logo/*) key on
this {id} (the caller holds it via User.currentOrganizationId).
DTOs
Organization
{
"id": "string", // WorkOS org id
"slug": "string", // stable URL handle (e.g. "acme") — feeds the invite-link path
"name": "string",
"logoUrl": "string | null", // org icon; set via the avatar-style upload flow (read-only here)
"domains": [ /* Domain */ ] // C6 — our own type, replaces the WorkOS-SDK OrganizationDomainData
}
// Not exposed: internal int64 id, inviteId, workosOrgId-as-separate-field, initialized (all internal).
Domain
{
"domain": "string", // e.g. "acme.com"
"state": "pending | verified | failed" // C6 — verification lifecycle, from WorkOS domain_data
}
Domains are email domains for auto-join / SSO routing: a user signing up with a matching, verified
domain is auto-added to the org. State is WorkOS-managed (you add a domain → pending → DNS-verify → verified).
create-organization
// Request
{
"name": "string", // required
"slug?": "string", // C8 — optional; auto-derived (slugified) from name if omitted
"domains?": ["string"] // C8 — optional; domain names to add (verification starts pending)
}
// Response — the authenticated User/session (creating an org logs you into it).
// Shape = the shared SignInAPIResponse { user: ... } — see the Auth/Session resource for the User DTO.
update-organization ({id} in path)
// Request — patches the org named by {id} (logo is changed via the upload flow, not here)
{
"name?": "string", // C8 — all fields optional (partial patch)
"slug?": "string", // rename the handle
"domains?": ["string"] // desired domain set (names; state is WorkOS-managed)
}
// Response — the updated Organization (today it leaks the raw DB row; return the clean Organization DTO)
{ /* Organization */ }
Input/output asymmetry on
domainsis intentional: you set domains by name (string[]); you read them back asDomainobjects with verificationstate(you can't set state — WorkOS verifies ownership).
Organization logo (two-step direct-to-R2 upload — mirrors the user-avatar flow)
// organization-logo-upload-ticket — Request
{ "contentType": "image/jpeg | image/png | image/webp" }
// Response
{ "uploadUrl": "string", "tmpKey": "string", "expiresInSeconds": 0 }
// organization-logo-finalize — after the client PUTs the file to uploadUrl — Request
{ "tmpKey": "string" }
// Response
{ "logoUrl": "string" }
// organization-logo-remove — Request body empty {} → Response 204 No Content
Same presigned-upload pattern as user avatars (the shared image-upload flow is defined with User/Avatar,
task #7); image validation is offloaded to Cloudflare Images. name/slug/domains change via
update-organization; the logo changes only through these three ops.
C6 fix — drop the WorkOS-SDK type leak
CreateOrganizationRequest/UpdateOrganizationRequest currently embed
*organizations.OrganizationDomainData (workos-go SDK) directly in the wire contract
(organization/models/types.go:10,19). Replace with our own types: input domains: string[], output
Domain[] ({domain, state}). The backend maps to/from WorkOS domain_data internally; a WorkOS SDK
upgrade can no longer mutate our published contract.
Module (backend)
organizationkeepscreate+updateonly (room CRUD →space; invitations →invitation).- At the wire boundary: input
domains []string/ outputDomain{domain,state}(mapped from WorkOSdomain_data);update-organizationreturns the cleanOrganizationDTO, not the raw GORM model. - New fields (net-new backend work):
slug— add a column (slugify from name on create; globally unique, since it's the invite-link URL segment).buildInviteLinkswitches fromorgName→slug, producing{baseURL}/join/{slug}/{inviteId}to match the FE member-invite route/join/$orgSlug/$token(app-shared/src/common/constants.ts). This fixes current drift (backend builds/{orgName}/join/{id}today, which that route doesn't match). Cross-refs invitations.md. (A public join-page org preview that would consume the slug is deferred — see below.)logoUrl— org icon stored in R2 via the avatar-style upload-ticket → finalize flow (mirror the user-avatar pattern; not a writable URL string). Read-only in the Organization DTO.
Deferred
preview-organization— a public, unauthenticated org lookup (slug → name + logo) for the join page. Not in the contract. Nothing consumes it today: the member-invite route/join/$orgSlug/$tokenignores the slug (memberInviteHandlerreads only the token), the join screen (Auth.tsx) shows no org branding, and it's the only op not grounded in current code. Add it when a real consumer exists — a branded join page, or the future guest-access feature (earmarked as a separate/guest-passesresource in invitations.md). Open questions to settle then: keyed by{slug}vs the invite token vs a space-scoped key; org-preview vs space-preview for guests; whether the join page needs a separate public lookup at all or can resolve org info from the invite token. If kept, it must declareSecurity: []explicitly and carry an unauthenticated-reachability test (a lone public op on an otherwise-authenticated resource is easy to regress).