diff --git a/.changeset/api-keys-command.md b/.changeset/api-keys-command.md new file mode 100644 index 0000000..c32f532 --- /dev/null +++ b/.changeset/api-keys-command.md @@ -0,0 +1,5 @@ +--- +"@freeticket/cli": minor +--- + +Add `ft api-keys create|list|revoke` for self-service API key management. `create` mints a key and prints the plaintext secret once (with a copy-now warning), `list` shows your keys without ever exposing the secret, and `revoke` deletes a key with confirmation. diff --git a/openapi.json b/openapi.json index 05b845e..dac7649 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FreeTicket B2B API", - "version": "1.4.0", + "version": "1.5.0", "description": "API REST B2B de FreeTicket para integraciones y el CLI. Autenticación por API key (header `Authorization: Bearer ` o `x-api-key`); el workspace activo se selecciona con el header `X-Workspace-Id`." }, "servers": [ @@ -273,7 +273,7 @@ "tags": [ "events" ], - "summary": "Crear evento (fase 2)", + "summary": "Crear evento", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -441,7 +441,7 @@ "tags": [ "events" ], - "summary": "Actualizar evento (fase 2)", + "summary": "Actualizar evento", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -534,7 +534,7 @@ "tags": [ "events" ], - "summary": "Eliminar (soft) evento (fase 2)", + "summary": "Eliminar (soft) evento", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -611,7 +611,7 @@ "tags": [ "events" ], - "summary": "Publicar evento (fase 2)", + "summary": "Publicar evento", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -782,7 +782,7 @@ "tags": [ "events" ], - "summary": "Agregar fecha a un evento (fase 2)", + "summary": "Agregar fecha a un evento", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -799,6 +799,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDateCreate" + } + } + } + }, "responses": { "201": { "description": "OK", @@ -867,7 +877,7 @@ "tags": [ "events" ], - "summary": "Actualizar fecha (fase 2)", + "summary": "Actualizar fecha", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -892,6 +902,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDateUpdate" + } + } + } + }, "responses": { "200": { "description": "OK", @@ -958,7 +978,7 @@ "tags": [ "events" ], - "summary": "Eliminar fecha (fase 2)", + "summary": "Eliminar fecha", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -1150,7 +1170,7 @@ "tags": [ "ticket-types" ], - "summary": "Crear tipo de ticket (fase 2)", + "summary": "Crear tipo de ticket", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -1318,7 +1338,7 @@ "tags": [ "ticket-types" ], - "summary": "Actualizar tipo de ticket (fase 2)", + "summary": "Actualizar tipo de ticket", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -1335,6 +1355,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketTypeUpdate" + } + } + } + }, "responses": { "200": { "description": "OK", @@ -1401,7 +1431,7 @@ "tags": [ "ticket-types" ], - "summary": "Eliminar (soft) tipo de ticket (fase 2)", + "summary": "Eliminar (soft) tipo de ticket", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -1818,7 +1848,7 @@ "tags": [ "sales" ], - "summary": "Cancelar una venta (fase 2)", + "summary": "Cancelar una venta", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -1903,8 +1933,8 @@ "tags": [ "sales" ], - "summary": "Reembolsar una venta (fase 2)", - "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", + "summary": "Marcar una venta como REEMBOLSADA (NO ejecuta el reembolso en MercadoPago)", + "description": "Marca la venta como REFUNDED y libera las sillas numeradas asociadas. IMPORTANTE: este endpoint NO dispara el reembolso real en el proveedor de pago (MercadoPago); el dinero NO se devuelve al comprador automáticamente. Un 200 aquí confirma únicamente el cambio de estado contable, no el movimiento de fondos. El reembolso en MercadoPago debe gestionarse por separado (panel de MercadoPago o su API).", "security": [ { "apiKey": [] @@ -2086,7 +2116,7 @@ "tags": [ "membership-plans" ], - "summary": "Crear plan de membresía (fase 2)", + "summary": "Crear plan de membresía", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -2254,7 +2284,7 @@ "tags": [ "membership-plans" ], - "summary": "Actualizar plan (fase 2)", + "summary": "Actualizar plan", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -2271,6 +2301,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipPlanUpdate" + } + } + } + }, "responses": { "200": { "description": "OK", @@ -2337,7 +2377,7 @@ "tags": [ "membership-plans" ], - "summary": "Eliminar (soft) plan (fase 2)", + "summary": "Eliminar (soft) plan", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -2512,7 +2552,7 @@ "tags": [ "venues" ], - "summary": "Crear venue (fase 2)", + "summary": "Crear venue", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -2680,7 +2720,7 @@ "tags": [ "venues" ], - "summary": "Actualizar venue (fase 2)", + "summary": "Actualizar venue", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -2697,6 +2737,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VenueUpdate" + } + } + } + }, "responses": { "200": { "description": "OK", @@ -2763,7 +2813,7 @@ "tags": [ "venues" ], - "summary": "Eliminar (soft) venue (fase 2)", + "summary": "Eliminar (soft) venue", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -2938,7 +2988,7 @@ "tags": [ "staff" ], - "summary": "Crear usuario staff (fase 2)", + "summary": "Crear usuario staff en el workspace", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -3023,7 +3073,7 @@ "tags": [ "staff" ], - "summary": "Cambiar rol de un usuario staff (fase 2)", + "summary": "Cambiar rol de un usuario staff", "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", "security": [ { @@ -5182,14 +5232,14 @@ } } }, - "/tickets/{ticketCode}/resend": { - "post": { - "operationId": "postTicketsTicketCodeResend", + "/api-keys": { + "get": { + "operationId": "getApiKeys", "tags": [ - "tickets" + "api-keys" ], - "summary": "Reenviar el correo de confirmación con el ticket", - "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", + "summary": "Listar mis API keys", + "description": "Lista las API keys del usuario autenticado (nunca el hash ni el secreto en claro).", "security": [ { "apiKey": [] @@ -5197,9 +5247,17 @@ ], "parameters": [ { - "name": "ticketCode", - "in": "path", - "required": true, + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, "schema": { "type": "string" } @@ -5214,11 +5272,18 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/ResendTicket" + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKey" + } + }, + "page": { + "$ref": "#/components/schemas/Page" } }, "required": [ - "data" + "data", + "page" ] } } @@ -5265,127 +5330,372 @@ } } } - } - } - }, - "components": { - "schemas": { - "Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": {} - }, - "required": [ - "code", - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "error" - ], - "additionalProperties": false }, - "Page": { - "type": "object", - "properties": { - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "hasMore": { - "type": "boolean" + "post": { + "operationId": "postApiKeys", + "tags": [ + "api-keys" + ], + "summary": "Mintear una API key (devuelve el plano una única vez)", + "description": "Crea una API key de servicio para el usuario autenticado. scope=\"read\" (default) solo permite GET/HEAD; scope=\"write\" habilita mutaciones según el rol del usuario. El secreto en claro solo aparece en esta respuesta.", + "security": [ + { + "apiKey": [] } - }, - "required": [ - "nextCursor", - "hasMore" ], - "additionalProperties": false - }, - "Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "slug": { - "type": "string" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreate" + } + } } }, - "required": [ - "id", - "name", - "slug" - ], - "additionalProperties": false - }, - "Me": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "name": { - "type": "string" + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ApiKeyCreated" + } + }, + "required": [ + "data" + ] + } + } + } }, - "email": { - "type": "string" + "401": { + "description": "Credencial inválida o ausente.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "role": { - "type": "string", - "enum": [ - "SUPER_ADMIN", - "ADMIN", - "STAFF", - "VIEWER", - "MINCULTURA" - ] + "403": { + "description": "Rol insuficiente o recurso no accesible.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "activeWorkspaceId": { - "type": "string" + "404": { + "description": "Recurso inexistente o fuera de alcance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "workspaces": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" + "422": { + "description": "Validación del cuerpo/parámetros.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } } } - }, - "required": [ - "userId", - "name", - "email", - "role", - "activeWorkspaceId", - "workspaces" + } + } + }, + "/api-keys/{id}": { + "delete": { + "operationId": "deleteApiKeysId", + "tags": [ + "api-keys" ], - "additionalProperties": false - }, - "DeviceCodeResponse": { - "type": "object", - "properties": { - "device_code": { - "type": "string" + "summary": "Revocar una de mis API keys", + "description": "Rol mínimo: `VIEWER`. Workspace activo vía header `X-Workspace-Id`.", + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Credencial inválida o ausente.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Rol insuficiente o recurso no accesible.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Recurso inexistente o fuera de alcance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validación del cuerpo/parámetros.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tickets/{ticketCode}/resend": { + "post": { + "operationId": "postTicketsTicketCodeResend", + "tags": [ + "tickets" + ], + "summary": "Reenviar el correo de confirmación con el ticket", + "description": "Rol mínimo: `ADMIN`. Workspace activo vía header `X-Workspace-Id`.", + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "ticketCode", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ResendTicket" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "401": { + "description": "Credencial inválida o ausente.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Rol insuficiente o recurso no accesible.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Recurso inexistente o fuera de alcance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validación del cuerpo/parámetros.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": {} + }, + "required": [ + "code", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "error" + ], + "additionalProperties": false + }, + "Page": { + "type": "object", + "properties": { + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "nextCursor", + "hasMore" + ], + "additionalProperties": false + }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "Me": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "SUPER_ADMIN", + "ADMIN", + "STAFF", + "VIEWER", + "MINCULTURA" + ] + }, + "activeWorkspaceId": { + "type": "string" + }, + "workspaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Workspace" + } + } + }, + "required": [ + "userId", + "name", + "email", + "role", + "activeWorkspaceId", + "workspaces" + ], + "additionalProperties": false + }, + "DeviceCodeResponse": { + "type": "object", + "properties": { + "device_code": { + "type": "string" }, "user_code": { "type": "string" @@ -5777,6 +6087,129 @@ }, "additionalProperties": false }, + "EventDateCreate": { + "type": "object", + "properties": { + "label": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "startsAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "endsAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "doorsOpenAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "timezone": { + "default": "America/Bogota", + "type": "string" + }, + "venueId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "startsAt", + "timezone" + ], + "additionalProperties": false + }, + "EventDateUpdate": { + "type": "object", + "properties": { + "label": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "startsAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "endsAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "doorsOpenAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "timezone": { + "type": "string" + }, + "venueId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, "TicketType": { "type": "object", "properties": { @@ -5916,6 +6349,49 @@ ], "additionalProperties": false }, + "TicketTypeUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "price": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string" + }, + "capacity": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxPerOrder": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "isVisible": { + "type": "boolean" + }, + "organizerAbsorbsFee": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "SaleItem": { "type": "object", "properties": { @@ -6027,6 +6503,26 @@ } ] }, + "paymentProvider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "paymentRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "confirmedAt": { "anyOf": [ { @@ -6056,6 +6552,8 @@ "buyerEmail", "buyerPhone", "organizationId", + "paymentProvider", + "paymentRef", "confirmedAt", "createdAt" ], @@ -6107,7 +6605,27 @@ "buyerEmail": { "type": "string" }, - "buyerPhone": { + "buyerPhone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "organizationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "paymentProvider": { "anyOf": [ { "type": "string" @@ -6117,7 +6635,7 @@ } ] }, - "organizationId": { + "paymentRef": { "anyOf": [ { "type": "string" @@ -6162,6 +6680,8 @@ "buyerEmail", "buyerPhone", "organizationId", + "paymentProvider", + "paymentRef", "confirmedAt", "createdAt", "items" @@ -6317,6 +6837,12 @@ "isActive": { "default": true, "type": "boolean" + }, + "sortOrder": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ @@ -6329,10 +6855,70 @@ "benefitDiscount", "benefitExclusiveContent", "benefitMerch", - "isActive" + "isActive", + "sortOrder" ], "additionalProperties": false }, + "MembershipPlanUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "price": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string" + }, + "billingCycle": { + "type": "string", + "enum": [ + "MONTHLY", + "QUARTERLY", + "ANNUAL", + "LIFETIME" + ] + }, + "benefitPresale": { + "type": "boolean" + }, + "benefitFreeTicket": { + "type": "boolean" + }, + "benefitDiscount": { + "type": "boolean" + }, + "benefitExclusiveContent": { + "type": "boolean" + }, + "benefitMerch": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, "Venue": { "type": "object", "properties": { @@ -6473,6 +7059,68 @@ ], "additionalProperties": false }, + "VenueUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "address": { + "type": "string", + "minLength": 1 + }, + "city": { + "type": "string", + "minLength": 1 + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2 + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "latitude": { + "anyOf": [ + { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + { + "type": "null" + } + ] + }, + "longitude": { + "anyOf": [ + { + "type": "number", + "minimum": -180, + "maximum": 180 + }, + { + "type": "null" + } + ] + }, + "portalVisible": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "StaffUser": { "type": "object", "properties": { @@ -7498,6 +8146,179 @@ ], "additionalProperties": false }, + "ApiKeyScope": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "ApiKey": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope" + }, + "keyPrefix": { + "type": "string" + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "revokedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "name", + "scope", + "keyPrefix", + "lastUsedAt", + "expiresAt", + "revokedAt", + "createdAt" + ], + "additionalProperties": false + }, + "ApiKeyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope" + }, + "keyPrefix": { + "type": "string" + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "revokedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "scope", + "keyPrefix", + "lastUsedAt", + "expiresAt", + "revokedAt", + "createdAt", + "key" + ], + "additionalProperties": false + }, + "ApiKeyCreate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "scope": { + "default": "read", + "$ref": "#/components/schemas/ApiKeyScope" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "name", + "scope" + ], + "additionalProperties": false + }, "ResendTicket": { "type": "object", "properties": { diff --git a/src/commands/api-keys.test.ts b/src/commands/api-keys.test.ts new file mode 100644 index 0000000..dcf797a --- /dev/null +++ b/src/commands/api-keys.test.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { registerApiKeys } from "./api-keys"; + +/** Structural test: the SDK calls are exercised by integration/manual use; + * here we assert the command tree is wired as documented. */ +describe("registerApiKeys", () => { + function build(): Command { + const program = new Command(); + registerApiKeys(program); + return program.commands.find((c) => c.name() === "api-keys") as Command; + } + + it("registers create, list and revoke subcommands", () => { + const names = build() + .commands.map((c) => c.name()) + .sort(); + expect(names).toEqual(["create", "list", "revoke"]); + }); + + it("create defaults --scope to read and accepts --expires", () => { + const create = build().commands.find((c) => c.name() === "create"); + const opts = create?.opts() ?? {}; + expect(opts.scope).toBe("read"); + const flags = create?.options.map((o) => o.long); + expect(flags).toContain("--expires"); + expect(flags).toContain("--json"); + }); + + it("revoke supports --yes to skip confirmation", () => { + const revoke = build().commands.find((c) => c.name() === "revoke"); + const flags = revoke?.options.map((o) => o.long); + expect(flags).toContain("--yes"); + }); + + it("list supports pagination and output flags", () => { + const list = build().commands.find((c) => c.name() === "list"); + const flags = list?.options.map((o) => o.long); + expect(flags).toEqual( + expect.arrayContaining(["--all", "--cursor", "--csv", "--columns"]), + ); + }); +}); diff --git a/src/commands/api-keys.ts b/src/commands/api-keys.ts new file mode 100644 index 0000000..a5d644f --- /dev/null +++ b/src/commands/api-keys.ts @@ -0,0 +1,143 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { deleteApiKeysId, getApiKeys, postApiKeys } from "../client/sdk.gen"; +import type { ApiKeyScope } from "../client/types.gen"; +import { configureClient, fail, unwrap } from "../lib/api"; +import { confirm } from "../lib/input"; +import { print, printNextCursor, resolveColumns, toCsv } from "../lib/output"; + +/** Columns shown in `api-keys list` — never the secret (the API never returns it). */ +const COLUMNS = [ + "name", + "keyPrefix", + "scope", + "lastUsedAt", + "expiresAt", + "revokedAt", +]; + +/** + * `ft api-keys` — self-service API key management. Kept separate from + * registerResource because `create` prints the plaintext secret exactly once + * (special UX) and `revoke` is a DELETE that we present as a revoke. + */ +export function registerApiKeys(program: Command): void { + const root = program + .command("api-keys") + .description("Self-service API keys: create, list, revoke"); + + root + .command("create ") + .description("Mint a new API key (the secret is shown only once)") + .option("--scope ", "key scope", "read") + .option("--expires ", "expiry as ISO 8601 (e.g. 2027-01-01)") + .option("--workspace ", "workspace override") + .option("--json", "raw JSON output (includes the plaintext secret)") + .action(async (name, opts) => { + const scope = opts.scope as string; + if (scope !== "read" && scope !== "write") { + fail("Invalid --scope. Use `read` or `write`."); + } + configureClient(opts.workspace); + const body = unwrap( + await postApiKeys({ + body: { + name, + scope: scope as ApiKeyScope, + ...(opts.expires ? { expiresAt: opts.expires } : {}), + }, + }), + ); + const key = body.data; + + if (opts.json) { + print(key, { json: true }); + return; + } + + // The secret is returned only on creation — surface it prominently and + // warn that it will never be shown again. + console.log( + `\n${chalk.green("✓")} API key "${chalk.bold(key.name)}" created (scope: ${key.scope}).\n`, + ); + console.log( + chalk.yellow(" Copy your secret now — it will NOT be shown again:\n"), + ); + console.log(` ${chalk.bold.cyan(key.key)}\n`); + print( + { + id: key.id, + name: key.name, + scope: key.scope, + keyPrefix: key.keyPrefix, + expiresAt: key.expiresAt, + createdAt: key.createdAt, + }, + {}, + ); + }); + + root + .command("list") + .description("List your API keys (the secret is never returned)") + .option("--limit ", "results per page (1-100)", "20") + .option("--cursor ", "pagination cursor") + .option("--all", "auto-paginate: fetch every page (ignores --cursor)") + .option("--columns ", "comma-separated columns to display") + .option("--full", "show every field instead of the curated columns") + .option("--workspace ", "workspace override") + .option("--csv", "CSV output (for spreadsheets)") + .option("--json", "raw JSON output (data only)") + .action(async (opts) => { + configureClient(opts.workspace); + const columns = resolveColumns(opts, COLUMNS); + const query: Record = { + limit: opts.limit, + cursor: opts.cursor, + }; + + if (opts.all) { + const rows: unknown[] = []; + let cursor: string | undefined; + let page: { nextCursor?: string | null; hasMore?: boolean } | undefined; + do { + const body = unwrap( + await getApiKeys({ query: { ...query, cursor } }), + ); + rows.push(...(body.data ?? [])); + page = body.page; + cursor = page?.nextCursor ?? undefined; + } while (page?.hasMore && cursor); + if (opts.csv) { + process.stdout.write(`${toCsv(rows, columns)}\n`); + return; + } + print(rows, { json: opts.json, columns }); + return; + } + + const body = unwrap(await getApiKeys({ query })); + if (opts.csv) { + process.stdout.write(`${toCsv(body.data, columns)}\n`); + return; + } + print(body.data, { json: opts.json, columns }); + if (!opts.json) printNextCursor(body.page); + }); + + root + .command("revoke ") + .description("Revoke one of your API keys") + .option("--yes", "skip confirmation") + .option("--workspace ", "workspace override") + .option("--json", "raw JSON output") + .action(async (id, opts) => { + if (!opts.yes && !(await confirm(`Revoke API key ${id}?`))) { + console.error("Aborted."); + return; + } + configureClient(opts.workspace); + const body = unwrap(await deleteApiKeysId({ path: { id } })); + print(body?.data ?? { revoked: id }, { json: opts.json }); + }); +} diff --git a/src/index.ts b/src/index.ts index 8eb4a34..6732e3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import { postWebhooks, } from "./client/sdk.gen"; import { registerAdmin } from "./commands/admin"; +import { registerApiKeys } from "./commands/api-keys"; import { registerAuth } from "./commands/auth"; import { registerEventDates } from "./commands/event-dates"; import { registerReports } from "./commands/reports"; @@ -246,6 +247,7 @@ registerResource(program, { registerTickets(program); registerReports(program); +registerApiKeys(program); registerAdmin(program); // No arguments -> banner + help.