{
  "openapi": "3.1.0",
  "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
  "info": {
    "title": "Nalke Push 외부 프로그램 API",
    "version": "0.1.0",
    "description": "외부 프로그램용 HTTPS 발송 API. Google FCM·외부 푸시 릴레이를 사용하지 않는다. 모든 성공은 HTTP 200, 오류는 {error:문자열}. 관리자는 전용 프로그램 키를 발급하고 프로그램은 허용된 앱·활성 장치와 자기 발송 작업만 조회한다. 기존 앱별 발송·Android ACK 계약은 이 명세 범위 밖이며 변경하지 않는다."
  },
  "servers": [{"url": "https://push.nalke.net"}],
  "tags": [
    {"name": "관리자 프로그램", "description": "관리자 Bearer 또는 승인된 OIDC 관리자 세션. 세션 변경 요청에는 기존 Origin·X-CSRF-Token 검증을 적용한다."},
    {"name": "프로그램 발송", "description": "활성 ps_ 프로그램 Bearer 키만 사용. 관리자·앱·기기 자격으로 대신 인증할 수 없다."}
  ],
  "paths": {
    "/v1/admin/senders": {
      "get": {
        "tags": ["관리자 프로그램"], "operationId": "listSenders", "summary": "발송 프로그램과 권한·상태 목록",
        "description": "폐기된 프로그램도 반환한다. key·키 해시는 반환하지 않는다. 전체 앱 권한은 현재·미래 앱에 적용된다.",
        "security": [{"AdminBearer": []}, {"AdminSession": []}],
        "responses": {
          "200": {"description": "프로그램 목록", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Sender"}}}}},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "503": {"$ref": "#/components/responses/Unavailable"}
        }
      },
      "post": {
        "tags": ["관리자 프로그램"], "operationId": "createSender", "summary": "전용 프로그램 키 한 번 발급",
        "description": "name과 appIds 또는 allApps:true 중 하나로 발급한다. appIds는 존재하는 앱의 비어 있지 않은 배열. 키는 ps_ 난수이며 서버는 해시만 저장한다. 생성 응답으로 한 번 반환한 키는 다시 조회할 수 없다. 세션 인증은 같은 Origin 및 X-CSRF-Token이 필요하다.",
        "security": [{"AdminBearer": []}, {"AdminSession": [], "CsrfToken": []}],
        "parameters": [{"$ref": "#/components/parameters/Origin"}],
        "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateSender"}, "examples": {"selected": {"value": {"name": "메일 알림 발송기", "appIds": ["mail"]}}, "all": {"value": {"name": "전체 앱 발송기", "allApps": true}}}}}},
        "responses": {
          "200": {"description": "발급 키. 응답을 로그·브라우저 저장소에 보관하지 말고 안전한 키 파일로 옮긴다.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreatedSender"}}}},
          "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"},
          "413": {"$ref": "#/components/responses/TooLarge"}, "503": {"$ref": "#/components/responses/Unavailable"}
        }
      }
    },
    "/v1/admin/senders/{senderId}": {
      "delete": {
        "tags": ["관리자 프로그램"], "operationId": "disableSender", "summary": "프로그램 키 폐기",
        "description": "즉시 새 프로그램 인증을 거부한다. 이 프로그램 작업의 미발송 queued 전달을 cancelled로 전환한다. 이미 accepted인 메시지는 회수하지 않는다. 세션 인증에는 같은 Origin 및 X-CSRF-Token이 필요하다.",
        "security": [{"AdminBearer": []}, {"AdminSession": [], "CsrfToken": []}],
        "parameters": [{"name": "senderId", "in": "path", "required": true, "schema": {"type": "string", "minLength": 1}, "description": "관리자 목록의 id 또는 발급 응답의 senderId"}, {"$ref": "#/components/parameters/Origin"}],
        "responses": {
          "200": {"description": "폐기 완료 (이미 폐기된 프로그램도 동일 응답)", "content": {"application/json": {"schema": {"type": "object", "required": ["state"], "properties": {"state": {"const": "disabled"}}, "additionalProperties": false}}}},
          "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"},
          "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/Unavailable"}
        }
      }
    },
    "/v1/send/apps": {
      "get": {
        "tags": ["프로그램 발송"], "operationId": "senderApps", "summary": "허용된 앱 조회",
        "description": "프로그램 권한에 포함된 앱만 반환한다. allApps 권한 키에는 현재 모든 앱이 보인다. 앱 발송 키 등 비밀은 반환하지 않는다.",
        "security": [{"ProgramBearer": []}],
        "responses": {
          "200": {"description": "허용 앱 목록", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/App"}}}}},
          "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/Unavailable"}
        }
      }
    },
    "/v1/send/devices": {
      "get": {
        "tags": ["프로그램 발송"], "operationId": "senderDevices", "summary": "허용된 활성 장치 조회",
        "description": "허용된 앱의 active 장치만 반환하며 app을 생략하면 허용 앱 전체다. last_seen은 마지막 보고 시각이고 실시간 온라인 여부가 아니다. 수신 토큰·기기 관리 키·topic은 반환하지 않는다.",
        "security": [{"ProgramBearer": []}],
        "parameters": [{"$ref": "#/components/parameters/App"}],
        "responses": {
          "200": {"description": "활성 장치 목록", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Device"}}}}},
          "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"},
          "503": {"$ref": "#/components/responses/Unavailable"}
        }
      }
    },
    "/v1/send/jobs": {
      "post": {
        "tags": ["프로그램 발송"], "operationId": "sendJob", "summary": "대상을 선택해 원자적으로 발송 큐 등록",
        "description": "target의 appIds·deviceIds·allApps:true 중 정확히 하나를 지정한다. appIds/allApps는 해당 앱의 모든 활성 장치, deviceIds는 지정 활성 장치다. allApps는 이 키에 허용된 앱 전체이며 서버 전체로 권한을 확대하지 않는다. 알 수 없는 앱·장치, 해제 장치, 권한 밖 대상이 하나라도 있으면 전체 요청을 거부한다. 장치가 없는 앱은 0개 대상으로 처리하며 총 대상 0개면 404. 동일 장치를 중복 발송하지 않는다. 전체 선택·권한·wire payload 검증 후 여러 앱의 메시지/전달 큐를 단일 SQLite 트랜잭션으로 만든다. id는 프로그램별 idempotency ID: 같은 id와 정규화된 제목·본문·target은 최초 대상 snapshot과 기존 jobId를 반환한다. 배열 순서·중복은 정규화한다. 기기 등록/해제 후 재요청해도 재발송하지 않는다. 같은 id의 내용이 다르면 409. id 생략 시 서버 생성. 프로그램이 다르면 같은 id는 독립이다. 접수 응답은 실제 기기 수신/OS 표시 성공을 의미하지 않는다. SDK는 발송 자동 재시도를 하지 않는다.",
        "security": [{"ProgramBearer": []}],
        "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SendJob"}, "examples": {"apps": {"value": {"id": "mail-alert-20261013-001", "title": "새 메일", "body": "받은 편지함을 확인하세요.", "target": {"appIds": ["mail"]}}}, "devices": {"value": {"title": "점검", "body": "선택 장치", "target": {"deviceIds": ["d0123456789abcd"]}}}, "all": {"value": {"title": "공지", "body": "허용된 앱 전체", "target": {"allApps": true}}}}}}},
        "responses": {
          "200": {"description": "등록 또는 같은 id의 기존 작업 (장치별 전달 포함)", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Job"}}}},
          "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"},
          "409": {"$ref": "#/components/responses/Conflict"}, "413": {"$ref": "#/components/responses/TooLarge"},
          "503": {"$ref": "#/components/responses/Unavailable"}
        }
      },
      "get": {
        "tags": ["프로그램 발송"], "operationId": "jobHistory", "summary": "자기 프로그램 발송 작업 이력",
        "description": "필터는 AND 조합. 생성 시각 내림차순, 동률은 jobId 내림차순. app은 그 앱이 포함된 작업을 선택하며 summary와 state는 작업 전체 대상으로 계산한다. 빈 결과는 items:[],total:0. 장치별 deliveries는 단건 status로 조회한다. 조회 파라미터를 중복 지정하면 400.",
        "security": [{"ProgramBearer": []}],
        "parameters": [
          {"$ref": "#/components/parameters/App"},
          {"name": "state", "in": "query", "schema": {"$ref": "#/components/schemas/JobState"}, "description": "작업 집계 상태"},
          {"name": "q", "in": "query", "schema": {"type": "string", "maxLength": 200}, "description": "제목·본문·jobId·requestId 부분 검색, 영문 대소문자 무시"},
          {"name": "from", "in": "query", "schema": {"type": "number", "minimum": 0}, "description": "생성 Unix 초 시작 포함. 유한한 숫자."},
          {"name": "until", "in": "query", "schema": {"type": "number", "minimum": 0}, "description": "생성 Unix 초 끝 미포함. 유한한 숫자. from과 함께 지정 시 from보다 커야 한다."},
          {"name": "page", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 2147483647, "default": 1}},
          {"name": "size", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}
        ],
        "responses": {
          "200": {"description": "작업별 전체 대상 집계 이력", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobHistory"}}}},
          "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"},
          "503": {"$ref": "#/components/responses/Unavailable"}
        }
      }
    },
    "/v1/send/jobs/{jobId}": {
      "get": {
        "tags": ["프로그램 발송"], "operationId": "jobStatus", "summary": "자기 발송 작업 상태와 장치별 전달 조회",
        "description": "자기 프로그램 소유 작업만 조회한다. 없는 작업과 다른 프로그램의 작업은 모두 404. 다른 프로그램 작업 존재 여부를 공개하지 않는다.",
        "security": [{"ProgramBearer": []}],
        "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "string", "minLength": 1}, "description": "발송 응답의 jobId. 요청 id나 앱별 messageId가 아니다."}],
        "responses": {
          "200": {"description": "현재 전체 작업 상태와 장치별 전달", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Job"}}}},
          "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"},
          "503": {"$ref": "#/components/responses/Unavailable"}
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "AdminBearer": {"type": "http", "scheme": "bearer", "description": "기존 복구용 관리자 키. 외부 발송 프로그램에 배포하지 않는다."},
      "AdminSession": {"type": "apiKey", "in": "cookie", "name": "__Host-push_session", "description": "승인된 OIDC 관리자 세션. HTTPS Secure·HttpOnly·SameSite=Lax 쿠키. GET에는 CSRF 불필요. 변경 요청에는 같은 Origin과 X-CSRF-Token 필요. loopback HTTP 시험에서는 push_session 이름을 사용한다."},
      "CsrfToken": {"type": "apiKey", "in": "header", "name": "X-CSRF-Token", "description": "기존 GET /auth/session의 csrfToken. 관리자 세션 변경 요청에만 필요하며 같은 Origin이어야 한다. Bearer 요청에는 불필요."},
      "ProgramBearer": {"type": "http", "scheme": "bearer", "description": "관리자가 발급한 활성 ps_ 프로그램 키. 허용된 앱·활성 장치와 자기 작업만 접근. 관리자·수신 기능 접근 금지. 폐기 즉시 인증 거부."}
    },
    "parameters": {
      "Origin": {"name": "Origin", "in": "header", "required": false, "schema": {"type": "string", "format": "uri"}, "description": "관리자 OIDC 세션 변경 요청에서는 필수이며 https://push.nalke.net과 일치해야 한다. Sec-Fetch-Site가 있으면 same-origin이어야 한다. Bearer 인증에는 불필요."},
      "App": {"name": "app", "in": "query", "schema": {"type": "string", "minLength": 1, "maxLength": 200}, "description": "허용된 앱 ID. 권한 밖 앱 403, 존재하지 않는 앱 404."}
    },
    "responses": {
      "BadRequest": {"description": "JSON/요청 값·대상 방식·배열·필터 오류 또는 wire payload 제한 위반. 요청 전체를 거부한다.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "Unauthorized": {"description": "해당 관리자/프로그램 자격 없음·오류·만료·폐기. 프로그램 API는 관리자/앱/기기 자격을 받지 않는다.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "Forbidden": {"description": "권한 밖 앱/장치 또는 관리자 세션 Origin·CSRF 검증 실패.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "NotFound": {"description": "존재하지 않는 앱/프로그램/작업, 알 수 없거나 해제된 장치, 전체 활성 대상 0개. 다른 프로그램 작업도 404.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "Conflict": {"description": "같은 프로그램/id의 제목·본문·정규화된 target 충돌. 다른 프로그램의 같은 id는 독립이다.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "TooLarge": {"description": "HTTP 요청 본문 16384 bytes 초과.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "Unavailable": {"description": "서버 처리 실패. 비밀·내부 예외 원문은 반환하지 않는다.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
    },
    "schemas": {
      "Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "string"}}, "additionalProperties": false},
      "IdArray": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "description": "서버가 중복 제거·정렬해 정규화한다. 입력 중복은 허용한다."},
      "CreateSender": {
        "type": "object", "required": ["name"],
        "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100}, "appIds": {"$ref": "#/components/schemas/IdArray"}, "allApps": {"const": true}},
        "oneOf": [{"required": ["appIds"], "not": {"required": ["allApps"]}}, {"required": ["allApps"], "not": {"required": ["appIds"]}}]
      },
      "Sender": {
        "type": "object", "required": ["id", "name", "appIds", "allApps", "state", "created"], "additionalProperties": false,
        "properties": {"id": {"type": "string", "format": "uuid"}, "name": {"type": "string"}, "appIds": {"type": "array", "items": {"type": "string"}}, "allApps": {"type": "boolean", "description": "true이면 appIds는 빈 배열이며 현재/미래의 모든 앱 권한"}, "state": {"enum": ["active", "disabled"]}, "created": {"type": "number", "description": "생성 Unix 초"}}
      },
      "CreatedSender": {
        "type": "object", "required": ["senderId", "key", "name", "appIds", "allApps"], "additionalProperties": false,
        "properties": {"senderId": {"type": "string", "format": "uuid"}, "key": {"type": "string", "pattern": "^ps_", "description": "한 번만 반환하는 비밀. 다시 조회할 수 없다."}, "name": {"type": "string"}, "appIds": {"type": "array", "items": {"type": "string"}}, "allApps": {"type": "boolean"}}
      },
      "App": {"type": "object", "required": ["id", "label", "created"], "additionalProperties": false, "properties": {"id": {"type": "string"}, "label": {"type": "string"}, "created": {"type": "number", "description": "생성 Unix 초"}}},
      "Device": {
        "type": "object", "required": ["id", "app", "name", "state", "last_seen", "version"], "additionalProperties": false,
        "properties": {"id": {"type": "string"}, "app": {"type": "string"}, "name": {"type": "string"}, "state": {"const": "active"}, "last_seen": {"type": ["number", "null"], "description": "마지막 보고 Unix 초. 아직 보고하지 않으면 null."}, "version": {"type": ["string", "null"]}}
      },
      "Target": {
        "oneOf": [
          {"type": "object", "required": ["appIds"], "properties": {"appIds": {"$ref": "#/components/schemas/IdArray"}}, "additionalProperties": false},
          {"type": "object", "required": ["deviceIds"], "properties": {"deviceIds": {"$ref": "#/components/schemas/IdArray"}}, "additionalProperties": false},
          {"type": "object", "required": ["allApps"], "properties": {"allApps": {"const": true}}, "additionalProperties": false}
        ], "description": "정확히 하나의 대상 방식. allApps는 이 키의 허용 앱 전체다."
      },
      "SendJob": {
        "type": "object", "required": ["title", "body", "target"],
        "properties": {"id": {"type": "string", "minLength": 1, "maxLength": 128, "description": "프로그램별 idempotency 요청 ID. 생략 시 서버 생성."}, "title": {"type": "string", "description": "UTF-8 최대 1024 bytes. 전체 기기별 envelope 4096 bytes·escaped wire 8192 bytes 제한도 적용된다."}, "body": {"type": "string", "description": "전체 기기별 envelope 4096 UTF-8 bytes·escaped wire 8192 bytes 제한이 적용된다."}, "target": {"$ref": "#/components/schemas/Target"}}
      },
      "DeliveryState": {"type": "string", "enum": ["queued", "accepted", "received", "failed", "cancelled"]},
      "JobState": {"type": "string", "enum": ["queued", "accepted", "received", "failed", "cancelled", "partial"], "description": "queued가 하나라도 있으면 queued; queued 없이 accepted가 있으면 accepted; 모두 received/failed/cancelled이면 각각 그 상태; 나머지 종료 상태 혼합은 partial. accepted는 기기 수신/OS 표시 성공이 아니며 received는 기기 저장 확인일 뿐 OS 표시 성공이 아니다."},
      "Summary": {
        "type": "object", "required": ["queued", "accepted", "received", "failed", "cancelled"], "additionalProperties": false,
        "properties": {"queued": {"type": "integer", "minimum": 0}, "accepted": {"type": "integer", "minimum": 0}, "received": {"type": "integer", "minimum": 0}, "failed": {"type": "integer", "minimum": 0}, "cancelled": {"type": "integer", "minimum": 0}}, "description": "작업 전체 대상의 상태별 개수. 합계는 targetCount."
      },
      "JobSummary": {
        "type": "object", "required": ["jobId", "requestId", "title", "body", "target", "created", "targetCount", "state", "summary", "transportComplete", "settled"],
        "properties": {
          "jobId": {"type": "string", "format": "uuid", "description": "서버 작업 ID"}, "requestId": {"type": "string", "minLength": 1, "maxLength": 128},
          "title": {"type": "string"}, "body": {"type": "string"}, "target": {"$ref": "#/components/schemas/Target"}, "created": {"type": "number", "description": "생성 Unix 초"},
          "targetCount": {"type": "integer", "minimum": 1, "description": "최초 대상 snapshot 수"}, "state": {"$ref": "#/components/schemas/JobState"}, "summary": {"$ref": "#/components/schemas/Summary"},
          "transportComplete": {"type": "boolean", "description": "queued가 0이면 true. 실패·취소가 있어도 true일 수 있으므로 성공으로 간주하지 않는다."},
          "settled": {"type": "boolean", "description": "queued·accepted 모두 0이면 true. 실패·취소 포함 가능. accepted가 ACK 없이 남으면 false이며 received 대기는 시간 초과할 수 있다."}
        }
      },
      "Delivery": {
        "type": "object", "required": ["appId", "deviceId", "deviceName", "messageId", "state", "attempts", "error", "nextTry", "receivedAt", "ntfyId"], "additionalProperties": false,
        "properties": {"appId": {"type": "string"}, "deviceId": {"type": "string"}, "deviceName": {"type": "string", "description": "발송 등록 시 장치 이름 snapshot"}, "messageId": {"type": "string", "format": "uuid", "description": "앱별 wire 메시지 ID. Android는 이 ID를 기존 received API로 ACK한다."}, "state": {"$ref": "#/components/schemas/DeliveryState"}, "attempts": {"type": "integer", "minimum": 0}, "error": {"type": ["string", "null"], "description": "안전한 실패 코드. transport_failure 또는 오류 없음 null. 토큰·내부 예외 원문 없음."}, "nextTry": {"type": "number", "description": "다음 시도 Unix 초. 예정 없음 0."}, "receivedAt": {"type": ["number", "null"], "description": "기기 저장 확인 Unix 초. 확인 전 null."}, "ntfyId": {"type": ["string", "null"], "description": "ntfy 접수 ID. 접수 전 null. wire messageId와 다르다."}}
      },
      "Job": {"allOf": [{"$ref": "#/components/schemas/JobSummary"}, {"type": "object", "required": ["deliveries"], "properties": {"deliveries": {"type": "array", "minItems": 1, "items": {"$ref": "#/components/schemas/Delivery"}}}}], "unevaluatedProperties": false},
      "JobHistory": {"type": "object", "required": ["items", "total", "page", "size"], "additionalProperties": false, "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/JobSummary"}}, "total": {"type": "integer", "minimum": 0}, "page": {"type": "integer", "minimum": 1}, "size": {"type": "integer", "minimum": 1, "maximum": 100}}}
    }
  }
}
