openapi: 3.1.0 info: title: matter-core description: | Core HTTP API for the Zimi Matter cloud. Per api-conventions.mdx this file is the source of truth; handlers conform to it and clients are generated from it. M4 Plan A adds shared components (error envelope, security schemes, common headers). Paths land in Plans B (end-user auth) and C (admin + RBAC). M4.4: opt-in cookie-mode refresh token. When `POST /v1/auth/login` or `POST /v1/auth/refresh` is called with `cookie_mode: true`, the refresh token is delivered as an httpOnly `matter_refresh` cookie scoped to `Path=/v1/auth`, with `SameSite=Strict` and `Secure` (outside the `dev` env). The cookie is rotated on every refresh and evicted on logout. Programmatic callers should leave `cookie_mode` unset and continue to use the JSON body. M4.5: read API for audit_event. `GET /v1/admin/audit/events` returns filterable, cursor-paginated rows from the audit table. Two capabilities gate it: `audit.read.global` (zimi-* roles) sees everything; `audit.read.factory` (factory-admin) sees rows from the caller's bound factory only. M6-A: admin list/detail GETs over the M5 write surfaces. `GET /v1/admin/catalogue/products` + detail; `GET /v1/admin/factories` + detail; `GET /v1/admin/factories/{code}/registers` + detail; `GET /v1/admin/factories/{code}/quota-grants`. All paginate offset- style with a `next_offset` field driven by a limit+1 probe. Each is gated on the corresponding M5 write capability. version: 0.9.0-m6b servers: - url: https://matter-mfg.zimi.life description: production - url: https://matter-dev.zimitest.page description: dev paths: /v1/auth/.well-known/jwks.json: get: tags: [auth-discovery] summary: JWKS for access-token verification security: [] responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/JwksResponse' } /v1/auth/login: post: tags: [auth-user] summary: Password + TOTP login; returns access token (and refresh, by chosen transport) security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/LoginRequest' } responses: '200': description: ok headers: Set-Cookie: $ref: '#/components/headers/RefreshCookieSet' content: application/json: schema: { $ref: '#/components/schemas/TokenPair' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/refresh: post: tags: [auth-user] summary: Rotate the refresh token; accepts the token from body OR matter_refresh cookie security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RefreshRequest' } responses: '200': description: ok headers: Set-Cookie: $ref: '#/components/headers/RefreshCookieSet' content: application/json: schema: { $ref: '#/components/schemas/TokenPair' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/activate: post: tags: [auth-user] summary: Consume an activation token, set initial password, get TOTP enrolment material security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ActivateRequest' } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ActivateResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/totp/verify: post: tags: [auth-user] summary: Confirm TOTP code and persist secret; completes activation security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TOTPVerifyRequest' } responses: '204': { description: enrolled } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/logout: post: tags: [auth-user] summary: Revoke current session; always evicts matter_refresh cookie security: [ { bearerAuth: [] } ] responses: '204': description: logged out headers: Set-Cookie: $ref: '#/components/headers/RefreshCookieEvict' '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/logout-all: post: tags: [auth-user] summary: Revoke every session for the authenticated user; always evicts matter_refresh cookie security: [ { bearerAuth: [] } ] responses: '200': description: ok headers: Set-Cookie: $ref: '#/components/headers/RefreshCookieEvict' content: application/json: schema: { $ref: '#/components/schemas/LogoutAllResponse' } '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/me: get: tags: [auth-user] summary: Current user, roles, factory bindings security: [ { bearerAuth: [] } ] responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/MeResponse' } '401': { $ref: '#/components/responses/Unauthenticated' } /v1/auth/admin/users: get: tags: [auth-admin] summary: List users with roles and factory bindings description: | Returns users ordered by created_at DESC. Roles and factory_bindings are aggregated arrays so the UI can render chips without an N+1. Pagination is offset-based; the response includes `next_offset` when more rows exist beyond the current page (computed via a limit+1 probe — no count query). Required capability: mfg.user.create. security: [ { bearerAuth: [] } ] parameters: - in: query name: q schema: { type: string } description: Case-insensitive substring match on email. - in: query name: status schema: { type: string, enum: [active, disabled] } - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ListUsersResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } post: tags: [auth-admin] summary: Create a user, optionally bind a factory, return activation URL description: 'Required capability: mfg.user.create' security: [ { bearerAuth: [] } ] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateUserRequest' } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/CreateUserResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/auth/admin/users/{id}/disable: post: tags: [auth-admin] summary: Disable a user and revoke active sessions description: 'Required capability: mfg.user.disable' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } responses: '200': { description: ok } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': { description: user not found } /v1/auth/admin/users/{id}/roles: post: tags: [auth-admin] summary: Grant a role to a user description: 'Required capability: mfg.role.grant' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/GrantRoleRequest' } responses: '200': { description: ok } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/auth/admin/users/{id}/roles/{role}: delete: tags: [auth-admin] summary: Revoke a role from a user description: 'Required capability: mfg.role.revoke' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } - in: path name: role required: true schema: { type: string } responses: '200': { description: ok } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/auth/admin/users/{id}/factory-bindings: post: tags: [auth-admin] summary: Bind a user to a factory description: 'Required capability: mfg.factory.bind' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BindFactoryRequest' } responses: '200': { description: ok } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/auth/admin/users/{id}/factory-bindings/{factory_code}: delete: tags: [auth-admin] summary: Remove a factory binding description: 'Required capability: mfg.factory.bind' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } - in: path name: factory_code required: true schema: { type: string, maxLength: 1 } responses: '200': { description: ok } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/auth/admin/users/{id}/reset-totp: post: tags: [auth-admin] summary: Clear a user's TOTP secret description: 'Required capability: mfg.totp.reset' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } responses: '200': { description: ok } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/auth/admin/users/{id}/logout-all: post: tags: [auth-admin] summary: Revoke every active session for a user description: 'Required capability: mfg.session.revoke' security: [ { bearerAuth: [] } ] parameters: - in: path name: id required: true schema: { type: string, format: uuid } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/LogoutAllResponse' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/admin/catalogue/products: get: tags: [catalogue] summary: List catalogue products (offset-paginated) description: | Returns products ordered by created_at DESC. Pagination is offset- based; the response includes `next_offset` when more rows exist (limit+1 probe — no COUNT query). Required capability: mfg.catalogue.create. security: [ { bearerAuth: [] } ] parameters: - in: query name: q schema: { type: string } description: Case-insensitive substring match on product name. - in: query name: status schema: { type: string, enum: [active, inactive] } - in: query name: silicon_family schema: { type: string, enum: [nrfconnect] } - in: query name: is_demo_mode schema: { type: boolean } - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ListProductsResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } post: tags: [catalogue] summary: Create a product catalogue entry description: 'Required capability: mfg.catalogue.create' security: [ { bearerAuth: [] } ] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateProductRequest' } responses: '201': description: Created content: application/json: schema: { $ref: '#/components/schemas/Product' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/admin/catalogue/products/{product_id}: parameters: - in: path name: product_id required: true schema: { type: string, format: uuid } get: tags: [catalogue] summary: Fetch one catalogue product description: 'Required capability: mfg.catalogue.create' security: [ { bearerAuth: [] } ] responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/Product' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Product not found patch: tags: [catalogue] summary: Update a product catalogue entry description: 'Required capability: mfg.catalogue.update. vendor_id and matter_pid are immutable.' security: [ { bearerAuth: [] } ] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UpdateProductRequest' } responses: '200': description: Updated content: application/json: schema: { $ref: '#/components/schemas/Product' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Product not found /v1/admin/catalogue/products/{product_id}/deactivate: parameters: - in: path name: product_id required: true schema: { type: string, format: uuid } post: tags: [catalogue] summary: Deactivate a product catalogue entry description: 'Required capability: mfg.catalogue.delete. Idempotent.' security: [ { bearerAuth: [] } ] responses: '200': description: Deactivated (idempotent — same response if already inactive) content: application/json: schema: { $ref: '#/components/schemas/Product' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Product not found /v1/admin/catalogue/products/{product_id}/reactivate: parameters: - in: path name: product_id required: true schema: { type: string, format: uuid } post: tags: [catalogue] summary: Reactivate a deactivated product catalogue entry description: 'Required capability: mfg.catalogue.update. Idempotent.' security: [ { bearerAuth: [] } ] responses: '200': description: Reactivated (idempotent) content: application/json: schema: { $ref: '#/components/schemas/Product' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Product not found /v1/admin/factories: get: tags: [factory] summary: List manufacturing factories (offset-paginated) description: | Returns factories ordered by created_at DESC with a server-computed `active` boolean (derived from active_to vs today). Pagination is offset-based with a limit+1 next_offset probe. Required capability: mfg.factory.create. security: [{ bearerAuth: [] }] parameters: - in: query name: status schema: { type: string, enum: [active, retired, all], default: all } - in: query name: country schema: { type: string, maxLength: 64 } - in: query name: q schema: { type: string } description: Case-insensitive substring match on site_name. - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ListFactoriesResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } post: tags: [factory] summary: Create a manufacturing factory description: 'Required capability: mfg.factory.create' security: [{ bearerAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateFactoryRequest' } responses: '201': description: Created content: application/json: schema: { $ref: '#/components/schemas/Factory' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '409': description: factory_code already exists /v1/admin/factories/{factory_code}: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } get: tags: [factory] summary: Fetch one factory description: 'Required capability: mfg.factory.create' security: [{ bearerAuth: [] }] responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/Factory' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found patch: tags: [factory] summary: Update a factory (factory_code is immutable) description: 'Required capability: mfg.factory.create' security: [{ bearerAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UpdateFactoryRequest' } responses: '200': description: Updated content: application/json: schema: { $ref: '#/components/schemas/Factory' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found /v1/admin/factories/{factory_code}/retire: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } post: tags: [factory] summary: Retire a factory (idempotent) description: 'Required capability: mfg.factory.create. Sets active_to=today UTC; blocks future FR-MFG-004 calls but preserves existing serials.' security: [{ bearerAuth: [] }] responses: '200': description: Retired (idempotent — already-retired returns 200 with details.was_already_retired=true in audit log) content: application/json: schema: { $ref: '#/components/schemas/Factory' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found /v1/admin/factories/{factory_code}/registers: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } get: tags: [register] summary: List register rows for a factory (offset-paginated) description: | Returns rows ordered created_at DESC, joined with the catalogue product to expose product_name. `active` is derived from `active_to IS NULL`. Limit+1 next_offset. Required capability: mfg.factory.create. security: [{ bearerAuth: [] }] parameters: - in: query name: status schema: { type: string, enum: [active, inactive, all], default: all } - in: query name: product_id schema: { type: string, format: uuid } - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ListRegistersResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } post: tags: [register] summary: Bind a Product Code to a catalogue product description: 'Required capability: mfg.register.create. Pre-conditions: factory active, product status=active, no existing active row for (factory, product_code).' security: [{ bearerAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateRegisterRowRequest' } responses: '201': description: Created content: application/json: schema: { $ref: '#/components/schemas/RegisterRow' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory or product not found '409': description: factory-retired, product-inactive, or register-row-exists /v1/admin/factories/{factory_code}/registers/{register_id}: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } - in: path name: register_id required: true schema: { type: string, format: uuid } get: tags: [register] summary: Fetch one register row description: | Returns the register row only if `factory_code` matches the path — cross-factory lookups return 404. Required capability: mfg.factory.create. security: [{ bearerAuth: [] }] responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/RegisterRow' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Register row not found patch: tags: [register] summary: Update a register row (active_from / active_to only) description: 'Required capability: mfg.register.update. product_id and product_code are immutable.' security: [{ bearerAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UpdateRegisterRowRequest' } responses: '200': description: Updated content: application/json: schema: { $ref: '#/components/schemas/RegisterRow' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Register row not found /v1/admin/factories/{factory_code}/registers/{register_id}/deactivate: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } - in: path name: register_id required: true schema: { type: string, format: uuid } post: tags: [register] summary: Deactivate a register row (idempotent) description: 'Required capability: mfg.register.delete. Sets active_to=today UTC.' security: [{ bearerAuth: [] }] responses: '200': description: Deactivated (idempotent) content: application/json: schema: { $ref: '#/components/schemas/RegisterRow' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Register row not found /v1/admin/factories/{factory_code}/quota-state: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } get: tags: [quota] summary: Read a factory's running quota totals description: | Returns total_granted (sum of every quota_allocation row), devices_created (the consumption counter), and remaining (server-computed = total_granted - devices_created). Required capability: `mfg.quota.read` (global, any factory) OR `mfg.quota.read.own` (own factory only). factory-admin / factory-operator with `.read.own` querying a not-own factory returns 403 tenancy-violation. security: [{ bearerAuth: [] }] responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/QuotaState' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found /v1/admin/factories/{factory_code}/sequence-state: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } get: tags: [factory] summary: List non-zero sequence counters for a factory description: | Returns every non-zero sequence_counter row for the factory, joined with the active register row for product_code (null if the register has been deactivated). Filterable by product_id / year_code / month_code. Bounded by `products × 12 months × years`; no pagination. Required capability: `mfg.allocation.read` (global, any factory) OR `mfg.allocation.read.own` (own factory only). security: [{ bearerAuth: [] }] parameters: - in: query name: product_id schema: { type: string, format: uuid } - in: query name: year_code schema: { type: string, minLength: 1, maxLength: 1 } - in: query name: month_code schema: { type: string, minLength: 1, maxLength: 1 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/SequenceStateResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found /v1/admin/factories/{factory_code}/production-stats: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } get: tags: [factory] summary: Bucketed device-creation counts for a factory description: | Returns device counts bucketed by day, calendar month, or calendar year (UTC, per ADR-0004). At M6 the device table is empty so all buckets count 0; M8's FR-MFG-004 will fill it. Optional `by_product` breakdown and `product_id` filter. Required capability: `mfg.stats.read` (global, any factory) OR `mfg.stats.read.own` (own factory only). Default time ranges when `since`/`until` are omitted: `granularity=day` → last 30 days; `month` → last 12 months; `year` → last 5 years. security: [{ bearerAuth: [] }] parameters: - in: query name: granularity required: true schema: { type: string, enum: [day, month, year] } - in: query name: since schema: { type: string, format: date-time } - in: query name: until schema: { type: string, format: date-time } - in: query name: by_product schema: { type: boolean, default: false } - in: query name: product_id schema: { type: string, format: uuid } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ProductionStatsResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found /v1/admin/factories/{factory_code}/quota-grants: parameters: - in: path name: factory_code required: true schema: { type: string, minLength: 1, maxLength: 1 } get: tags: [quota] summary: List quota grants for a factory (offset-paginated) description: | Returns quota_allocation rows for the factory, ordered granted_at DESC, allocation_id. Each row is joined with "user" to expose granted_by_email. Required capability: mfg.quota.grant. (At v1 read and write share the capability — anyone who can grant needs to see what's been granted.) security: [{ bearerAuth: [] }] parameters: - in: query name: since schema: { type: string, format: date-time } description: Inclusive lower bound on granted_at. - in: query name: until schema: { type: string, format: date-time } description: Inclusive upper bound on granted_at. - in: query name: issued_by schema: { type: string, format: uuid } - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ListQuotaGrantsResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found post: tags: [quota] summary: Grant device-creation quota to a factory description: 'Required capability: mfg.quota.grant. Append-only (FR-MFG-002).' security: [{ bearerAuth: [] }] parameters: - in: header name: Idempotency-Key schema: { type: string, maxLength: 128 } description: Optional. Replays within 24h return the original response byte-identically; same key with a different body returns 409 idempotency-conflict. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/QuotaGrantRequest' } responses: '201': description: Grant recorded content: application/json: schema: { $ref: '#/components/schemas/QuotaGrantResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } '404': description: Factory not found '409': description: Idempotency conflict or factory retired /v1/admin/registers: get: tags: [factory] summary: Cross-factory list of register rows by product description: | Returns every register row whose product_id matches, across all factories. product_id is required (400 if missing). Returns the same RegisterWithProduct items as the per-factory list, so the UI can render "bound at" cross-links on a product detail page. Required capability: mfg.factory.create. No 404 — unknown product_id returns an empty list. security: [{ bearerAuth: [] }] parameters: - in: query name: product_id required: true schema: { type: string, format: uuid } - in: query name: status schema: { type: string, enum: [active, inactive, all], default: all } - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/ListRegistersResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } /v1/admin/audit/events: get: tags: [audit] summary: List audit_event rows (cursor-paginated) description: | Returns rows from the append-only `audit_event` table, ordered `(occurred_at, event_id) DESC`. Two capabilities gate access: - `audit.read.global` — granted to zimi-admin, zimi-viewer, zimi-operator, zimi-manufacturing-admin. Sees every row. - `audit.read.factory` — granted to factory-admin. The handler injects `factory_code = `. A `factory_code` query param that conflicts with the caller's binding is rejected with 403, not silently overridden. Callers with neither capability get 403 permission-denied. Reads do NOT themselves emit audit events. security: [ { bearerAuth: [] } ] parameters: - in: query name: actor_user_id required: false schema: { type: string, format: uuid } description: Restrict to events authored by this user. - in: query name: kind required: false schema: { type: array, items: { type: string } } style: form explode: true description: | Repeatable. Filters within `kind` are OR-combined. Values must match the audit_event.kind enum (e.g. `auth-success`, `user-create`, `role-grant`, `factory-write`, `register-write`, `catalogue-write`, `quota-grant`, `device-create`, `kms-unwrap`, …). - in: query name: subject_id required: false schema: { type: string } description: | Filter by the audit row's `subject_id` (free-form — typically a user_id, register_id, product_id, or device serial depending on the kind). - in: query name: factory_code required: false schema: { type: string } description: | Single Crockford Base32 character. For `audit.read.factory` callers this must match the caller's binding or 403. - in: query name: since required: false schema: { type: string, format: date-time } description: Inclusive lower bound on `occurred_at` (RFC3339). - in: query name: until required: false schema: { type: string, format: date-time } description: Exclusive upper bound on `occurred_at` (RFC3339). - in: query name: cursor required: false schema: { type: string } description: | Opaque pagination cursor from a prior response's `next_cursor`. Clients MUST NOT parse the cursor — its shape is reserved. - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } description: Page size. Values outside [1,200] are clamped. responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/AuditEventsResponse' } '400': { $ref: '#/components/responses/ValidationFailed' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PermissionDenied' } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: | Access token issued by /v1/auth/login. EdDSA-signed JWT, 15 min lifetime. Verify against /v1/auth/.well-known/jwks.json. Plans B and C add the issuing endpoints. parameters: RequestId: name: X-Request-Id in: header required: false schema: type: string description: Client-supplied request ID (ULID recommended). The server generates one if absent and always echoes it in the response. headers: RefreshCookieSet: description: | Present only when the request set `cookie_mode: true` (or, on /v1/auth/refresh, when the inbound request carried a `matter_refresh` cookie). Sets the httpOnly `matter_refresh` cookie carrying the refresh token. Attributes: `Path=/v1/auth`, `SameSite=Strict`, `HttpOnly`, `Secure` outside the `dev` env, `Max-Age` equal to the refresh-token TTL. schema: { type: string } RefreshCookieEvict: description: | Always present. Emits an eviction `Set-Cookie` for `matter_refresh` (`Max-Age=0`, empty value, same `Path` + `SameSite` as the issued cookie). Idempotent on body-only callers — they may discard it. schema: { type: string } schemas: ErrorEnvelope: type: object required: [error] properties: error: type: object required: [code, message] properties: code: type: string description: | Stable machine-readable error code per api-conventions.mdx. Known v1 values include `validation-failed`, `unauthenticated`, `permission-denied`, `idempotency-conflict`, `not-found`. example: validation-failed message: type: string description: Human-readable summary. Localisation-free at v1. example: request body failed schema validation trace_id: type: string description: Echo of X-Request-Id. Pairs with the value on every structured log line for the request. example: 01HXYZABCD1234EFGH56789JKM details: type: object additionalProperties: true description: Optional structured detail. Shape per error code. LoginRequest: type: object required: [email, password, totp] additionalProperties: false properties: email: { type: string, format: email } password: { type: string, minLength: 8 } totp: { type: string, pattern: '^[0-9]{6}$' } cookie_mode: type: boolean default: false description: | When true, the response sets an httpOnly `matter_refresh` cookie (Path=/v1/auth, SameSite=Strict, Secure outside dev) carrying the refresh token, and omits `refresh_token` from the JSON body. Browser-based clients should set this true; CLI / programmatic clients should leave it false. TokenPair: type: object required: [access_token, expires_in, user_id] properties: access_token: { type: string } refresh_token: type: string description: | Present only when the request was body-mode. In cookie mode the refresh token is delivered via Set-Cookie and this field is omitted from the response. expires_in: { type: integer, example: 900 } user_id: { type: string, format: uuid } RefreshRequest: type: object additionalProperties: false properties: refresh_token: type: string description: | Optional. If omitted, the server reads the refresh token from the `matter_refresh` cookie. If both are present, the body wins. cookie_mode: type: boolean default: false description: | When true, the rotated refresh token is returned via Set-Cookie and omitted from the response body. When false but the inbound request carried a `matter_refresh` cookie, the response is also cookie-mode (the mode follows the request). ActivateRequest: type: object required: [token, password, email] additionalProperties: false properties: token: { type: string } password: { type: string, minLength: 8 } email: { type: string, format: email } ActivateResponse: type: object required: [user_id, totp_secret, totp_otpauth_url] properties: user_id: { type: string, format: uuid } totp_secret: { type: string } totp_otpauth_url: { type: string } TOTPVerifyRequest: type: object required: [user_id, totp_secret, code] additionalProperties: false properties: user_id: { type: string, format: uuid } totp_secret: { type: string } code: { type: string, pattern: '^[0-9]{6}$' } MeResponse: type: object required: [user_id, email, tenant, roles, factory_bindings] properties: user_id: { type: string, format: uuid } email: { type: string } tenant: { type: string, enum: [zimi, partner] } roles: { type: array, items: { type: string } } factory_bindings: { type: array, items: { type: string } } LogoutAllResponse: type: object required: [revoked_sessions] properties: revoked_sessions: { type: integer, example: 3 } JwksResponse: type: object required: [keys] properties: keys: type: array items: { type: object, additionalProperties: true } UserListItem: type: object required: [user_id, email, tenant, status, created_at, roles, factory_bindings] properties: user_id: { type: string, format: uuid } email: { type: string } tenant: { type: string, enum: [zimi, factory] } status: { type: string, enum: [active, disabled] } created_at: { type: string, format: date-time } roles: type: array items: { type: string } factory_bindings: type: array items: { type: string, maxLength: 1 } ListUsersResponse: type: object required: [users] properties: users: type: array items: { $ref: '#/components/schemas/UserListItem' } next_offset: type: ['integer', 'null'] description: Present when more rows exist beyond this page. CreateUserRequest: type: object required: [email, role] additionalProperties: false properties: email: { type: string, format: email } role: { type: string } factory: { type: string, maxLength: 1 } CreateUserResponse: type: object required: [user_id, activation_url] properties: user_id: { type: string, format: uuid } activation_url: { type: string } GrantRoleRequest: type: object required: [role] additionalProperties: false properties: role: { type: string } BindFactoryRequest: type: object required: [factory_code] additionalProperties: false properties: factory_code: { type: string, maxLength: 1 } Product: type: object required: [product_id, name, vendor_id, matter_pid, hardware_version, silicon_family, flash_partition_offset, flash_partition_size, ines_template_id, ines_cn_prefix, pai_revision, is_demo_mode, embed_serial_in_qr, status, created_at, updated_at] properties: product_id: { type: string, format: uuid } name: { type: string, maxLength: 128 } vendor_id: { type: integer, minimum: 0, maximum: 65535 } matter_pid: { type: integer, minimum: 0, maximum: 65535 } hardware_version: { type: integer, minimum: 0 } silicon_family: { type: string, enum: [nrfconnect] } flash_partition_offset: { type: integer, minimum: 0 } flash_partition_size: { type: integer, minimum: 1 } ines_template_id: { type: integer, minimum: 0 } ines_cn_prefix: { type: string, maxLength: 16 } pai_revision: { type: string, maxLength: 32 } colour: { type: ['string', 'null'], maxLength: 32 } finish: { type: ['string', 'null'], maxLength: 32 } is_demo_mode: { type: boolean } embed_serial_in_qr: { type: boolean } status: { type: string, enum: [active, inactive] } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } CreateProductRequest: type: object required: [name, vendor_id, matter_pid, hardware_version, silicon_family, flash_partition_offset, flash_partition_size, ines_template_id, ines_cn_prefix, pai_revision] properties: name: { type: string, maxLength: 128 } vendor_id: { type: integer, minimum: 0, maximum: 65535 } matter_pid: { type: integer, minimum: 0, maximum: 65535 } hardware_version: { type: integer, minimum: 0 } silicon_family: { type: string, enum: [nrfconnect] } flash_partition_offset: { type: integer, minimum: 0 } flash_partition_size: { type: integer, minimum: 1 } ines_template_id: { type: integer, minimum: 0 } ines_cn_prefix: { type: string, maxLength: 16 } pai_revision: { type: string, maxLength: 32 } colour: { type: ['string', 'null'], maxLength: 32 } finish: { type: ['string', 'null'], maxLength: 32 } is_demo_mode: { type: boolean, default: false } embed_serial_in_qr: { type: boolean, default: false } UpdateProductRequest: type: object properties: name: { type: string, maxLength: 128 } hardware_version: { type: integer, minimum: 0 } silicon_family: { type: string, enum: [nrfconnect] } flash_partition_offset: { type: integer, minimum: 0 } flash_partition_size: { type: integer, minimum: 1 } ines_template_id: { type: integer, minimum: 0 } ines_cn_prefix: { type: string, maxLength: 16 } pai_revision: { type: string, maxLength: 32 } colour: { type: ['string', 'null'], maxLength: 32 } finish: { type: ['string', 'null'], maxLength: 32 } is_demo_mode: { type: boolean } embed_serial_in_qr: { type: boolean } ListProductsResponse: type: object required: [items] properties: items: type: array items: { $ref: '#/components/schemas/Product' } next_offset: type: [integer, "null"] description: Offset for the next page, or null when no more rows exist. Factory: type: object required: [factory_code, site_name, country, created_at, updated_at] properties: factory_code: { type: string, minLength: 1, maxLength: 1 } site_name: { type: string, maxLength: 128 } country: { type: string, maxLength: 64 } active_from: { type: ['string', 'null'], format: date } active_to: { type: ['string', 'null'], format: date } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } CreateFactoryRequest: type: object required: [factory_code, site_name, country] properties: factory_code: { type: string, minLength: 1, maxLength: 1, description: 'Single Crockford-Base32 char per ADR-0004. Excludes I, L, O, U.' } site_name: { type: string, maxLength: 128 } country: { type: string, maxLength: 64 } active_from: { type: ['string', 'null'], format: date } UpdateFactoryRequest: type: object properties: site_name: { type: string, maxLength: 128 } country: { type: string, maxLength: 64 } active_from: { type: ['string', 'null'], format: date } active_to: { type: ['string', 'null'], format: date } FactoryListItem: allOf: - $ref: '#/components/schemas/Factory' - type: object required: [active] properties: active: type: boolean description: Server-computed convenience boolean — true iff active_to is NULL or in the future. ListFactoriesResponse: type: object required: [items] properties: items: type: array items: { $ref: '#/components/schemas/FactoryListItem' } next_offset: type: [integer, "null"] RegisterRow: type: object required: [register_id, factory_code, product_code, product_id, created_at, updated_at] properties: register_id: { type: string, format: uuid } factory_code: { type: string, minLength: 1, maxLength: 1 } product_code: { type: string, minLength: 1, maxLength: 1 } product_id: { type: string, format: uuid } active_from: { type: ['string', 'null'], format: date } active_to: { type: ['string', 'null'], format: date } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } CreateRegisterRowRequest: type: object required: [product_code, product_id] properties: product_code: { type: string, minLength: 1, maxLength: 1, description: 'Single Crockford-Base32 char per ADR-0004.' } product_id: { type: string, format: uuid } active_from: { type: ['string', 'null'], format: date } active_to: { type: ['string', 'null'], format: date } UpdateRegisterRowRequest: type: object properties: active_from: { type: ['string', 'null'], format: date } active_to: { type: ['string', 'null'], format: date } RegisterWithProduct: allOf: - $ref: '#/components/schemas/RegisterRow' - type: object required: [product_name, active] properties: product_name: type: string description: Joined from the catalogue at read time; saves the UI an N+1 fetch. active: type: boolean description: Server-computed — true iff active_to IS NULL. ListRegistersResponse: type: object required: [items] properties: items: type: array items: { $ref: '#/components/schemas/RegisterWithProduct' } next_offset: type: [integer, "null"] QuotaGrantRequest: type: object required: [quota_increment] properties: quota_increment: { type: integer, minimum: 1, maximum: 10000000 } note: { type: ['string', 'null'], maxLength: 256 } QuotaGrant: type: object required: [grant_id, factory_code, quota_increment, granted_by, granted_at] properties: grant_id: { type: string, format: uuid } factory_code: { type: string, minLength: 1, maxLength: 1 } quota_increment: { type: integer } note: { type: ['string', 'null'] } granted_by: { type: string, format: uuid } granted_at: { type: string, format: date-time } QuotaGrantResponse: allOf: - $ref: '#/components/schemas/QuotaGrant' - type: object required: [total_granted, devices_created, remaining] properties: total_granted: { type: integer, format: int64 } devices_created: { type: integer, format: int64 } remaining: { type: integer, format: int64 } QuotaState: type: object required: [factory_code, total_granted, devices_created, remaining] properties: factory_code: { type: string, minLength: 1, maxLength: 1 } total_granted: { type: integer, format: int64 } devices_created: { type: integer, format: int64 } remaining: { type: integer, format: int64 } SequenceStateResponse: type: object required: [factory_code, entries] properties: factory_code: { type: string, minLength: 1, maxLength: 1 } entries: type: array items: { $ref: '#/components/schemas/SequenceStateEntry' } SequenceStateEntry: type: object required: [product_id, year_code, month_code, next_sequence, allocated_count] properties: product_id: { type: string, format: uuid } product_code: { type: ['string', 'null'], minLength: 1, maxLength: 1 } year_code: { type: string, minLength: 1, maxLength: 1 } month_code: { type: string, minLength: 1, maxLength: 1 } next_sequence: { type: integer, minimum: 0, maximum: 32768 } allocated_count: { type: integer, minimum: 0 } ProductionStatsResponse: type: object required: [factory_code, granularity, buckets] properties: factory_code: { type: string, minLength: 1, maxLength: 1 } granularity: { type: string, enum: [day, month, year] } buckets: type: array items: { $ref: '#/components/schemas/ProductionStatsBucket' } ProductionStatsBucket: type: object required: [bucket, count] properties: bucket: { type: string } count: { type: integer, format: int64, minimum: 0 } by_product: type: ['array', 'null'] items: type: object required: [product_id, count] properties: product_id: { type: string, format: uuid } count: { type: integer, format: int64, minimum: 0 } QuotaGrantListItem: allOf: - $ref: '#/components/schemas/QuotaGrant' - type: object required: [granted_by_email] properties: granted_by_email: type: string description: Joined from "user" at read time; saves the UI an N+1 fetch. ListQuotaGrantsResponse: type: object required: [items] properties: items: type: array items: { $ref: '#/components/schemas/QuotaGrantListItem' } next_offset: type: [integer, "null"] AuditEvent: type: object required: [event_id, occurred_at, kind] properties: event_id: { type: string, format: uuid } occurred_at: { type: string, format: date-time } actor_user_id: type: string format: uuid description: Null for system-initiated events (no authenticated subject). actor_role: type: string description: | Role label observed at write time (e.g. "zimi-admin", "cli-admin", "anonymous"). Free-form. kind: type: string description: | Audit kind from the audit_event.kind closed enum (see the CHECK constraint in sql/migrations/20260513040000_initial_schema.sql). factory_code: type: string description: Single Crockford-Base32 character; null for non-tenant events. subject_id: type: string description: | Free-form — typically the row's primary key for the action's subject (user_id, register_id, product_id, device serial, …). details: type: object additionalProperties: true description: | Per-kind structured detail. The action-specific shape lives in the writer; readers should treat this as opaque JSON. AuditEventsResponse: type: object required: [events] properties: events: type: array items: { $ref: '#/components/schemas/AuditEvent' } next_cursor: type: string description: | Opaque cursor for the next page; empty when the result is exhausted. Clients MUST NOT parse the cursor. responses: Unauthenticated: description: Missing, expired, or invalid bearer token. content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' PermissionDenied: description: Authenticated but lacks the required capability. content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' ValidationFailed: description: Request body or parameters failed schema validation. content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope'