Keyring

Workspaces

The vendor's account: created once, then read and updated.

Generated from packages/api/src/resources/workspaces.controller.ts by packages/docs/tools/generate-reference.mjs. Every example is a real exchange recorded against the control plane by packages/api/src/testing/reference-examples.spec.ts, with ids from that run. Credentials in the formats this product issues (kr_, krsk_, krses_, JWTs) and values under credential-named fields (password, *_secret, *_token, *_key, lookup_hash) are elided at record time; the recorded file and every page of this site are swept for credential shapes (kr_, krsk_, krses_, JWTs, URLs carrying a password, password fields) before either is accepted. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.

POST /v1/workspaces

The one endpoint a secret key cannot authenticate, because it is what mints the first one. The workspace identifier is generated first so the insert can run under the workspace's own scope: workspace's policy is on id, so a row can only be created by a session already scoped to it -- which means even this handler never needs an owner connection.

Two credentials reach it (WorkspaceCreationGuard), and they produce deliberately different workspaces. A person becomes its owner, which is the self-serve path a dashboard needs. The bootstrap token creates one with no members at all, which is what a scripted deployment wants and what week 2 shipped -- a seeded workspace whose first human arrives by invitation.

A person whose session was in no workspace lands in the one they just made, exactly as POST /v1/auth/invites/accept does. One already working somewhere is not moved out from under themselves.

Answers 201 on success.

Authentication.

  • A signed-in person's krses_ session (who becomes the owner), or KEYRING_BOOTSTRAP_TOKEN (a workspace with no members).

Body. Validated by this schema, from the control plane's own source:

const CreateWorkspace = z.object({
  name: Name,
  slug: Slug,
  billing_email: z.email().optional(),
  env: Env.default('live'),
});

Example.

Request
curl https://keyring-api.belghalem.fr/v1/workspaces \
  -X POST \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Payments",
    "slug": "acme-payments",
    "billing_email": "billing@acme.example"
  }'
Response
HTTP/1.1 201 Created
Content-Type: application/json

{
  "workspace": {
    "object": "workspace",
    "id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
    "name": "Acme Payments",
    "slug": "acme-payments",
    "billing_email": "billing@acme.example",
    "embed_origins": [],
    "created_at": "2026-09-17T05:47:19.647Z",
    "updated_at": "2026-09-17T05:47:19.647Z"
  },
  "secret_key": {
    "object": "secret_key",
    "id": "01a0ade7-b8e3-7531-bbd6-1fa72692a681",
    "env": "live",
    "display_prefix": "krsk_live_HSnKWG",
    "name": "Bootstrap secret key",
    "meta": {},
    "expires_at": null,
    "revoked_at": null,
    "revoked_reason": null,
    "quarantined_at": null,
    "quarantine_reason": null,
    "rotated_from": null,
    "created_at": "2026-09-17T05:47:19.647Z",
    "last_used_at": null,
    "key": "krsk_live_HSnKWG…",
    "key_shown_once": true
  },
  "membership": {
    "user_id": "01a0ade7-b878-7739-871a-8faaa66097ed",
    "role": "owner"
  }
}

GET /v1/workspace

Answers 200 on success.

Authentication.

  • A krsk_ secret key or a krses_ dashboard session, as Authorization: Bearer.

Example.

Request
curl https://keyring-api.belghalem.fr/v1/workspace \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY"
Response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "object": "workspace",
  "id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
  "name": "Acme Payments",
  "slug": "acme-payments",
  "billing_email": "billing@acme.example",
  "embed_origins": [],
  "created_at": "2026-09-17T05:47:19.647Z",
  "updated_at": "2026-09-17T05:47:19.647Z"
}

Example: With a revoked secret key.

Request
curl https://keyring-api.belghalem.fr/v1/workspace \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY"
Response
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "error": {
    "code": "invalid_key",
    "message": "Invalid API key."
  }
}

PATCH /v1/workspace

Answers 200 on success.

Authentication.

  • A krsk_ secret key or a krses_ dashboard session, as Authorization: Bearer.

Body. Validated by this schema, from the control plane's own source:

const UpdateWorkspace = z.object({
  name: Name.optional(),
  billing_email: z.email().nullable().optional(),
  embed_origins: EmbedOrigins.optional(),
});

Report section 7.3: the origins an embed token may be presented from. The database checks the same shape (keyring.embed_origins_valid, migration 0010) and keeps checking it whatever writes the column; this is here so a bad entry is a 400 naming the field rather than a 500 wrapping a constraint violation.

A serialised origin and nothing else: no path, no trailing slash, no *. Anything else compares unequal to what a browser puts in Origin, so registering one would buy a control that silently never matches.

const EmbedOrigins = z
  .array(
    z
      .string()
      .max(255)
      .regex(
        /^https?:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/,
        'must be a scheme, host and optional port with no path or trailing slash',
      ),
  )
  .max(20)
  .refine((origins) => new Set(origins).size === origins.length, {
    message: 'embed origins must be unique',
  });

Example.

Request
curl https://keyring-api.belghalem.fr/v1/workspace \
  -X PATCH \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "embed_origins": [
      "https://app.acme.example"
    ]
  }'
Response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "object": "workspace",
  "id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
  "name": "Acme Payments",
  "slug": "acme-payments",
  "billing_email": "billing@acme.example",
  "embed_origins": [
    "https://app.acme.example"
  ],
  "created_at": "2026-09-17T05:47:19.647Z",
  "updated_at": "2026-09-17T05:47:19.668Z"
}

Shared validators

Defined once in packages/api/src/validation.ts and used by the schemas above.

const Env = z.enum(['live', 'test']);
const Name = z.string().min(1).max(200);
const Slug = z.string().regex(
  /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/,
  // The length floor is in the pattern and invisible from outside it, so the
  // message says it: "p1" is rejected and "must be a lowercase slug" does not
  // explain why.
  'must be 3 to 63 characters of lowercase letters, digits and hyphens, starting and ending with a letter or digit',
);

On this page