Keyring
ReferenceControl-plane API

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. 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'),
});

GET /v1/workspace

Answers 200 on success.

Authentication.

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

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',
  });

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