Federation & Service

Organizations Federation API

Complete HTTP and SDK API reference for Organizations Service

Organizations Federation API

This reference is for clients and product backends that call Federation. For the backend that consumes Organization Tokens and revocation events, see Integrate Organizations into a City Server.

Install and configure

import { Federation } from "@downcity/city";
import { OrganizationsService } from "@downcity/services";

const federation = new Federation({ db });

federation.use(new OrganizationsService({
  max_organizations_per_user: 3,
  organization_token_ttl: "7d",
}));
OptionRequiredDefaultMeaning
max_organizations_per_useryesnoneMaximum active Organizations a user may own in one City
organization_token_ttlno7dToken TTL as seconds or an s/m/h/d string; maximum 30d
fetchnoglobalThis.fetchRevocation-event transport override for custom runtimes and tests

The service supports better-sqlite3 and PostgreSQL. It rejects D1 because D1 does not provide the required interactive multi-table transactions. Production deployments should configure a Federation Queue adapter for Outbox retries.

Base URL and authentication

Every Action is exposed under:

{federation_origin}/v1/organizations/{action}

Except for events/deliver, all Actions require a Federation user token:

Authorization: Bearer ub_<JWT>

Federation derives user_id and city_id from the verified token. Body or query values cannot override either identity. events/deliver accepts only Federation Admin credentials or an internal trusted runtime identity.

GET Actions read query parameters; POST Actions read JSON. Successful responses are JSON with status 200. Timestamps are ISO 8601 strings, and unset timestamps currently use "".

GET /v1/organizations/get?organization_id=org_01J...
Authorization: Bearer ub_...
const organizations = city.service("organizations");
const my = await organizations.get("my");
const created = await organizations.action("create").invoke({
  name: "Research Team",
  server_url: "https://city.example.com",
});

Data types

type Organization = {
  organization_id: string;
  city_id: string;
  name: string;
  server_url: string;
  state: "active" | "archived";
  created_by: string;
  created_at: string;
  updated_at: string;
  archived_at: string;
};

type Membership = {
  membership_id: string;
  organization_id: string;
  user_id: string;
  role: "owner" | "admin" | "member";
  state: "active" | "removed";
  created_at: string;
  updated_at: string;
  removed_at: string;
  removed_by: string;
};

type JoinRequest = {
  request_id: string;
  organization_id: string;
  user_id: string;
  state: "pending" | "approved" | "rejected" | "canceled";
  requested_at: string;
  decided_at: string;
  decided_by: string;
};

server_url is a normalized HTTP or HTTPS origin with no credentials, path, query, or fragment. A removed Membership is never restored; rejoining creates a new membership_id.

Role permissions

OperationOwnerAdminMember
Read Organization and active membersyesyesyes
Rename Organizationyesyesno
Change server_url or archiveyesnono
Decide Join Requestsyesyesno
Assign or revoke Adminyesnono
Remove Memberyesyesno
Remove Adminyesnono
Transfer ownershipyesnono
Leavetransfer firstyesyes
Issue own Organization Tokenyesyesyes

Owners and Admins cannot add users directly. Every user must create their own Join Request.

Action summary

MethodActionAuthorizationPurpose
GETmyUserList the caller's Organizations
GETgetMemberRead one joined Organization
POSTcreateUserCreate an Organization as Owner
POSTupdateOwner/AdminRename an Organization
POSTserver/updateOwnerChange the City Server origin
POSTarchiveOwnerTerminally archive an Organization
GETmembership/getMemberRead the caller's Membership
GETmembers/listMemberList active Memberships
POSTmembers/roleOwnerSet an Admin or Member role
POSTmembers/removeOwner/AdminRemove a manageable member
POSTmembers/leaveAdmin/MemberLeave the Organization
POSTowner/transferOwnerTransfer the unique Owner role
POSTjoin-requests/createUserApply to join
POSTjoin-requests/cancelApplicantCancel a pending request
GETjoin-requests/listOwner/AdminList pending requests
POSTjoin-requests/decideOwner/AdminApprove or reject a request
POSTtoken/createMemberIssue an Organization Token
POSTevents/deliverFederation AdminManually advance the Outbox

Organization Actions

GET my

GET /v1/organizations/my
Authorization: Bearer ub_...

Returns { "items": UserOrganization[] }. Each item combines Organization fields with role, membership_id, and membership_state. Only active Memberships are listed. An archived Organization remains visible while the caller's Membership remains active.

GET get

GET /v1/organizations/get?organization_id=org_01J...

Returns { organization, membership } for the caller's active Membership. It can read an archived Organization but cannot restore or mutate it.

POST create

{
  "name": "Research Team",
  "server_url": "https://city.example.com/"
}

Any authenticated user can create within their Owner quota. Federation derives the City from the token, normalizes the URL, and returns { organization, membership }; the Membership is the unique Owner. Archiving releases a slot, and an ownership transfer moves the slot to the new Owner.

POST update

{ "organization_id": "org_01J...", "name": "New Name" }

Owner/Admin only. The trimmed name must contain 1–120 characters. Returns the updated Organization.

POST server/update

{
  "organization_id": "org_01J...",
  "server_url": "https://new-city.example.com"
}

Owner only. The update and organization.server_url.changed Outbox event commit atomically. The event targets the old Server, while newly issued tokens target only the new Server. Repeating the same URL is idempotent and creates no event.

POST archive

{ "organization_id": "org_01J..." }

Owner only and irreversible. Archiving stops governance writes, Join Requests, and token issuance; releases the Owner quota slot; and writes an organization.archived event. Records remain for history. Repeating the operation returns 410 ORGANIZATION_ARCHIVED.

Membership Actions

GET membership/get

GET /v1/organizations/membership/get?organization_id=org_01J...

Returns { organization, membership }, where Membership belongs to the caller and is active.

GET members/list

GET /v1/organizations/members/list?organization_id=org_01J...

Any active Member may call it. Returns { "items": Membership[] } containing only active records.

POST members/role

{
  "organization_id": "org_01J...",
  "membership_id": "mem_01J...",
  "role": "admin"
}

Owner only. role accepts admin or member. Use owner/transfer, not this Action, for ownership.

POST members/remove

{
  "organization_id": "org_01J...",
  "membership_id": "mem_01J..."
}

An Owner can remove Admins or Members; an Admin can remove Members only. Owner removal is rejected. The returned Membership has state: "removed", and its update commits atomically with a signed revocation event.

POST members/leave

{ "organization_id": "org_01J..." }

Admins and Members can leave. The response is the removed Membership. An Owner must transfer first.

POST owner/transfer

{
  "organization_id": "org_01J...",
  "membership_id": "mem_new_owner"
}

The target must be active and have an available Owner quota slot. Returns { previous_owner, owner }; the previous Owner becomes Admin.

Join Request Actions

POST join-requests/create

{ "organization_id": "org_01J..." }

The service has no public Organization search. Products distribute organization_id through their own invitation flow. The first call returns a pending Join Request; a duplicate returns the same record. An existing active Member receives { state: "joined", organization, membership }.

POST join-requests/cancel

{ "request_id": "join_01J..." }

The applicant can cancel their own pending request. Returns the request with state: "canceled".

GET join-requests/list

GET /v1/organizations/join-requests/list?organization_id=org_01J...

Owner/Admin only. Returns { "items": JoinRequest[] } with pending requests only.

POST join-requests/decide

{ "request_id": "join_01J...", "decision": "approved" }

Owner/Admin only. decision is approved or rejected. Approval creates a new Member Membership in the same transaction and returns { request, membership }. Rejection returns the updated request without membership. Rejected or canceled users may apply again with a new request ID.

Token Action

POST token/create

{ "organization_id": "org_01J..." }

Returns:

{
  "organization_token": "ot_eyJ...",
  "organization_id": "org_01J...",
  "server_url": "https://city.example.com",
  "expires_at": "2026-08-05T10:00:00.000Z"
}

The caller must have an active Membership in an active Organization. The token defaults to 7 days, binds aud to server_url, and contains user, City, Organization, and Membership IDs. It contains no governance role or project permission and cannot authenticate to Federation APIs.

Outbox administration

POST events/deliver

POST /v1/organizations/events/deliver
Authorization: Bearer <federation-admin-secret>
Content-Type: application/json

{}

Returns { "delivered": number, "pending": number }. pending is the total remaining Outbox count after the delivery attempt. Prefer automatic Queue retries; use this Action for operational replay.

Errors

{
  "error": {
    "code": "ORGANIZATION_ROLE_DENIED",
    "message": "ORGANIZATION_ROLE_DENIED",
    "type": "server_error"
  }
}
HTTPCodeMeaning
400ORGANIZATION_INPUT_INVALIDInvalid name, ID, role, or decision
400ORGANIZATION_SERVER_URL_INVALIDInvalid Server origin
401AUTH_REQUIRED or absentUser credential is missing, expired, or invalid
403ORGANIZATION_CITY_MISMATCHToken City does not own the Organization
403NOT_AN_ORGANIZATION_MEMBERCaller has no active Membership
403ORGANIZATION_ROLE_DENIEDGovernance role cannot perform the operation
404ORGANIZATION_NOT_FOUNDOrganization does not exist
404ORGANIZATION_MEMBERSHIP_NOT_FOUNDTarget Membership is absent or inactive
404JOIN_REQUEST_NOT_FOUNDRequest is absent or no longer pending
409ORGANIZATION_LIMIT_REACHEDOwner quota is full
409OWNER_TRANSFER_REQUIREDTransfer ownership before the operation
410ORGANIZATION_ARCHIVEDOrganization is archived

Branch on error.code, never on the human-readable message.