Workspaces
The vendor's account: created once, then read and updated.
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), orKEYRING_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 akrses_dashboard session, asAuthorization: Bearer.
PATCH /v1/workspace
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: 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',
);