# Spatrix Client API flow narrative 이 문서는 OpenAPI endpoint reference 앞에 붙는 절차형 보충 문서다. 값의 정확한 스키마와 status별 응답은 OpenAPI를 기준으로 하고, 여기서는 클라이언트가 동기화 상태를 어떻게 커밋해야 하는지를 흐름 우선으로 설명한다. ## Common protocol - 모든 public client API는 `X-API-Key` 헤더로 인증한다. API key는 project를 결정하므로 URL에 project id나 slug를 넣지 않는다. - 성공 응답은 envelope이 없다. `GET /api/manifest`는 `{ data: ... }`가 아니라 `FileManifestResponse` 객체 자체를 반환한다. - 호환성은 additive-only다. 새 필드와 새 endpoint는 추가될 수 있으므로 클라이언트는 모르는 필드를 무시한다. - 시간값은 ISO 8601 UTC 문자열이다. `updatedAt`, `expiresAt`, CSV 조회의 `start`/`end`는 timezone 포함 값을 쓴다. - `nodeId`, `versionId`, `revision`, `size`, POI/category id 같은 정수 surface는 JS safe-integer 범위로 노출된다. - rate limit 기본값은 출발지 IP 기준 60 req/min이다. - 예외는 `POST /api/pose-logs` 하나다. 단말 직송 표면이라 출발지 IP가 아니라 **API key 단위 600 req/min**으로 센다 — 같은 NAT 뒤의 단말 여러 대가 서로의 예산을 깎지 않는다. - `429 RATE_LIMIT_EXCEEDED`에서는 `Retry-After` 또는 `X-RateLimit-Reset`을 우선해 backoff한다. - 에러 응답은 `{ "error": { "code": "...", "message": "...", "details": ... } }` 형태다. 분기는 `message`가 아니라 안정적인 `error.code`로 한다. ### Machine-derived `error.code` enum | `error.code` | 의미 | |---|---| | `AUTH_EXPIRED_KEY` | OpenAPI public response에서 수집된 안정 enum | | `AUTH_INSUFFICIENT_PERMISSION` | OpenAPI public response에서 수집된 안정 enum | | `AUTH_INVALID_KEY` | OpenAPI public response에서 수집된 안정 enum | | `AUTH_REFRESH_INVALID` | OpenAPI public response에서 수집된 안정 enum | | `INTERNAL_ERROR` | OpenAPI public response에서 수집된 안정 enum | | `NODE_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `PAYLOAD_TOO_LARGE` | OpenAPI public response에서 수집된 안정 enum | | `POI_CATEGORY_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `POI_IMAGE_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `POI_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `PROJECT_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `RATE_LIMIT_EXCEEDED` | OpenAPI public response에서 수집된 안정 enum | | `RELEASE_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `THUMBNAIL_NOT_FOUND` | OpenAPI public response에서 수집된 안정 enum | | `VALIDATION_ERROR` | OpenAPI public response에서 수집된 안정 enum | | `VL_CREDENTIAL_LOG_LEVEL_UNSAFE` | OpenAPI public response에서 수집된 안정 enum | | `VL_CREDENTIAL_NOT_CONFIGURED` | OpenAPI public response에서 수집된 안정 enum | ## Sync invariants > 파일 revision과 POI revision은 서로 독립이다. `/api/manifest`와 `/api/manifest/pois`는 별도 counter를 쓰므로 한쪽이 `304`여도 다른 쪽은 변경될 수 있다. - 로컬 revision은 해당 흐름의 모든 필수 item이 전부 성공한 뒤에만 갱신한다. - 파일 흐름에서 manifest diff, 각 `nodeId` 다운로드, `checksum` 검증 중 하나라도 실패하면 로컬 파일 revision을 유지하고 다음 sync cycle에서 재시도한다. - POI 흐름은 diff가 아니라 full replace다. 새 payload 저장이 전부 성공해야 POI ETag를 갱신한다. - 파일 실패가 POI revision commit을 막지 않고, POI 실패가 파일 revision commit을 막지 않는다. 단, 같은 revision 안에서는 부분 commit을 하지 않는다. - 이 독립성은 `/api/manifest`·`/api/manifest/pois`를 직접 쓸 때의 계약이다. 두 축을 **같은 시점의 조합**으로 받아야 하면 `/api/releases/active` 하나로 받는다 — 개별 호출 사이에 발행이 끼면 서버에 존재한 적 없는 revision 조합이 만들어진다(`Flow: release descriptor`). ## Flow: cold start bundle ### Step checklist 1. `GET /api/bundle`에 `X-API-Key`를 붙여 호출한다. 2. `state=ready`이면 `url`의 presigned ZIP을 TTL 안에 다운로드한다. 3. ZIP을 풀어 `manifest.json`, `pois.json`, `files/`를 staging 영역에 쓴다. 4. staging 영역의 파일 배치와 JSON parse가 전부 성공하면 live 영역으로 교체한다. 5. ZIP의 `revision`은 파일/POI 중 최신값을 담은 bootstrap high-water marker다. 로컬 파일 revision slot에는 저장할 수 있지만 POI ETag slot은 비워 둔다 — POI ETag는 opaque 문자열이라 숫자로 재구성할 수 없고, 첫 POI 증분 sync가 `If-None-Match` 없이 호출돼 정확한 ETag를 확정한다. 파일 revision도 다음 `/api/manifest` 응답으로 reconcile한다. 6. `state=building`이면 짧게 backoff 후 재폴링하거나 바로 파일/POI 증분 sync로 fallback한다. `revision=null`이면 최초 bundle 성공 전이므로 저장할 bundle baseline이 없다. ### Pseudocode ```ts const bundle = await getJson("/api/bundle", { "X-API-Key": apiKey }); if (bundle.state === "building") { if (bundle.revision === null) { runIncrementalSyncWithoutIfNoneMatch(); return; } scheduleRetryOrRunIncrementalSync(bundle.revision); return; } const zip = await download(bundle.url); const extracted = await extractToStaging(zip); await atomicallyReplaceLocalStore(extracted.files, extracted.manifest, extracted.pois); local.fileRevision = bundle.revision; // bootstrap high-water marker local.poiEtag = null; // 첫 POI manifest 호출이 If-None-Match 없이 ETag를 확정한다 ``` ### Example request ```http GET /api/bundle HTTP/1.1 X-API-Key: ``` ### Example response ```json { "state": "ready", "url": "https://object-storage.example/bundles/projects/1/bundle-42.zip?signature=...", "revision": 42, "size": 10485760, "expiresAt": "2026-06-17T03:00:00.000Z" } ``` ```json { "state": "building", "revision": 41 } ``` ```json { "state": "building", "revision": null } ``` ### Retry and failure rule - `building`은 실패가 아니라 worker 준비 중 상태다. `Retry-After`가 있으면 따르고, 없으면 짧은 backoff를 둔다. - `building.revision=null`은 최초 성공 bundle이 아직 없다는 뜻이다. 이 값은 로컬 revision에 저장하지 말고 `If-None-Match` 없이 증분 sync를 시작한다. - ZIP 다운로드, 압축 해제, JSON parse, local replace 중 하나라도 실패하면 기존 로컬 store와 revisions를 그대로 둔다. - `url` 만료는 `/api/bundle`을 다시 호출해 새 presigned URL을 받는다. ### 흔한 실수 - `state=building`을 fatal error로 처리한다. - bundle `revision`을 파일/POI endpoint의 정확한 독립 revision이라고 가정한다. 이것은 cold-start high-water marker이며 첫 증분 sync가 정확한 revision을 확정한다. - ZIP을 live 영역에 바로 풀어 중간 실패 시 반쯤 교체된 store를 만든다. ### Acceptance checklist - [ ] `ready` ZIP을 staging에 먼저 풀고 atomic replace한다. - [ ] `building`에서 polling 또는 incremental fallback 중 하나를 수행한다. - [ ] 파일 revision과 POI revision slot은 분리해 저장하고, bundle high-water marker를 첫 증분 sync에서 reconcile한다. ## Flow: file incremental sync ### Step checklist 1. 로컬 파일 revision이 있으면 `If-None-Match: ""`로 `GET /api/manifest`를 호출한다. 2. `304`이면 파일 store는 변경하지 않고 파일 sync를 종료한다. 3. `200`이면 응답의 `files`를 `nodeId` 기준으로 로컬 manifest와 diff한다. 4. 새 `nodeId` 또는 `versionId`가 달라진 항목은 `GET /api/nodes/{nodeId}/download?versionId=`로 받는다. 5. 응답은 `302` redirect다. redirect-follow 후 받은 bytes의 `checksum`을 manifest 값과 비교한다. legacy row의 `checksum=null`은 검증을 생략하되 파일 교체는 staging에서 한다. 6. 응답에서 사라진 `nodeId`는 삭제 대상으로 표시하고, `versionId`가 같고 `path`만 바뀐 항목은 이동 대상으로 처리한다. 7. 모든 다운로드, 삭제, 이동, checksum 검증이 전부 성공하면 staging manifest를 live로 교체하고 로컬 파일 revision을 갱신한다. ### Pseudocode ```ts const headers = { "X-API-Key": apiKey, ...(local.fileRevision === null ? {} : { "If-None-Match": quote(local.fileRevision) }), }; const response = await get("/api/manifest", headers); if (response.status === 304) return; const manifest = await response.json(); const plan = diffByNodeId(local.fileManifest, manifest.files); for (const file of plan.downloads) { const bytes = await downloadFollowingRedirect( "/api/nodes/" + file.nodeId + "/download?versionId=" + file.versionId, ); if (file.checksum !== null) assertChecksum(bytes, file.checksum); await writeStagingFile(file.path, bytes); } await applyMovesAndDeletesInStaging(plan); await atomicallyCommitFiles(manifest.files); local.fileRevision = manifest.revision; ``` ### Example request ```http GET /api/manifest HTTP/1.1 X-API-Key: If-None-Match: "41" ``` ### Example responses ```http HTTP/1.1 304 Not Modified ETag: "41" ``` ```json { "projectSlug": "warmemo", "revision": 42, "files": [ { "nodeId": 12345, "path": "models/a.glb", "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1048576, "versionId": 67890, "updatedAt": "2026-06-17T03:00:00.000Z" } ] } ``` ```http GET /api/nodes/12345/download?versionId=67890 HTTP/1.1 X-API-Key: ``` ```http HTTP/1.1 302 Found Location: https://object-storage.example/files/12345?signature=... ``` ### Retry and failure rule - `429`는 `Retry-After` 또는 `X-RateLimit-Reset`까지 기다린 뒤 다음 cycle에서 재시도한다. - `404 NODE_NOT_FOUND`는 node 존재 여부로 분기하지 않는다. manifest가 stale하다고 보고 다음 cycle에서 `/api/manifest`부터 다시 시작한다. - 파일 흐름은 all-or-nothing이다. 일부 파일만 성공했어도 로컬 파일 revision은 갱신하지 않는다. ### 흔한 실수 - `If-None-Match`에 quote 없는 숫자를 보내거나 POI revision을 파일 manifest에 재사용한다. - `path`를 key로 diff해서 rename/move를 delete+download로 오인한다. diff key는 `nodeId`다. - `versionId`를 생략해 manifest 수신 이후의 최신 파일을 받아 checksum mismatch를 만든다. - checksum 검증 전에 revision을 먼저 저장한다. ### Acceptance checklist - [ ] `304`에서 body parse를 시도하지 않는다. - [ ] `nodeId` 기준 diff와 `versionId` 기준 재다운로드를 분리한다. - [ ] 모든 required item이 전부 성공한 뒤에만 파일 revision을 갱신한다. ## Flow: POI incremental sync ### Step checklist 1. 로컬에 저장한 직전 POI ETag가 있으면 그 값을 그대로 `If-None-Match`로 보내 `GET /api/manifest/pois`를 호출한다. POI ETag는 revision·locale이 섞인 opaque 문자열이다 — revision 숫자로 재구성하지 않는다. 2. `304`이면 POI store는 변경하지 않고 종료한다. 3. `200`이면 `categories`와 `pois` 전체를 staging store에 쓴다. 4. `categories`는 `parentId`, `path`, `depth`로 tree를 복원한다. 5. 식별자는 모두 uuid다 — `pois[].id`는 POI uuid, `categories[].id`/`parentId`/`pois[].categoryId`는 category uuid. 썸네일·이미지 다운로드 경로의 `poiId`/`poiCategoryId`에 그대로 쓴다. amproj 좌표 매칭은 id가 아니라 `externalId` 필드로 한다. 6. `pois[].center`는 `[x, y, z]` 또는 `null`로 처리하고, `metadata`의 unknown field는 보존하거나 무시한다. 7. staging write가 전부 성공하면 live POI store를 full replace하고 로컬 POI ETag를 응답 `ETag` 헤더 값으로 갱신한다. ### Pseudocode ```ts const headers = { "X-API-Key": apiKey, ...(local.poiEtag === null ? {} : { "If-None-Match": local.poiEtag }), }; const response = await get("/api/manifest/pois", headers); if (response.status === 304) return; const manifest = await response.json(); await writePoiStaging({ categories: manifest.categories, pois: manifest.pois }); await atomicallyReplacePoiStore(); local.poiEtag = response.headers.get("ETag"); ``` ### Example request ```http GET /api/manifest/pois HTTP/1.1 X-API-Key: If-None-Match: "12:ko" ``` ### Example response ```json { "projectSlug": "warmemo", "revision": 13, "categories": [ { "id": "550e8400-e29b-41d4-a716-446655440101", "name": "Lobby", "slug": "lobby", "parentId": null, "path": "/lobby", "depth": 0 } ], "pois": [ { "id": "550e8400-e29b-41d4-a716-446655440301", "externalId": "amproj-entrance-001", "name": "Entrance", "description": null, "status": "active", "categoryId": "550e8400-e29b-41d4-a716-446655440101", "stage": "1F", "center": [0, 1.25, 2.5], "metadata": null, "updatedAt": "2026-06-17T03:00:00.000Z" }, { "id": "550e8400-e29b-41d4-a716-446655440302", "externalId": null, "name": "Info Desk", "description": null, "status": "active", "categoryId": "550e8400-e29b-41d4-a716-446655440101", "stage": "1F", "center": null, "metadata": null, "updatedAt": "2026-06-17T03:00:00.000Z" } ] } ``` ### Retry and failure rule - POI payload는 가볍기 때문에 diff하지 않고 full replace한다. - parse나 staging write가 실패하면 기존 POI store와 로컬 POI revision을 유지한다. - POI sync 실패는 파일 sync 성공분의 파일 revision commit을 되돌리지 않는다. ### 흔한 실수 - 파일 revision을 `If-None-Match`로 보내 POI 변경을 놓친다. - POI ETag를 revision 숫자로 재구성한다. POI ETag는 opaque 문자열이므로 응답 `ETag` 헤더를 그대로 저장·재전송한다. - `pois[].id`나 `categoryId`를 숫자로 parse한다. 식별자는 모두 uuid string이다. - `externalId`를 API 경로 식별자로 쓴다. amproj 좌표 매칭 전용 필드고, 조회·다운로드 경로는 uuid를 쓴다. - POI를 partial patch로 갱신해 삭제된 POI나 category를 로컬에 남긴다. - `center=null`을 `[0,0,0]`으로 바꿔 미배치 좌표를 실제 좌표처럼 렌더한다. ### Acceptance checklist - [ ] POI ETag를 파일 revision과 별도로 저장한다. - [ ] `200` 응답은 full replace로 반영한다. - [ ] full replace가 전부 성공한 뒤에만 POI ETag를 갱신한다. ## Flow: release descriptor `GET /api/releases/active`는 발행된 파일 목록·POI/카테고리·런타임 프로필을 **하나의 서명된 스냅샷**으로 돌려준다. 위의 파일/POI 증분 sync를 각각 돌리는 대신 이 경로 하나로 활성 조합을 받는 진입점이다 — 조합의 원자성이 필요하면 이쪽을, 두 축을 각자의 주기로 증분 갱신하면 되면 그쪽을 쓴다. ### Step checklist 1. 로컬에 직전 release ETag가 있으면 `If-None-Match: <직전 ETag>`로 `GET /api/releases/active`를 호출한다. `?locale=`는 요청 표면으로 남아 있지만 이 endpoint의 응답을 가르지 않는다 — 발행본은 박제 시점 locale 하나로 고정이다. 2. `304`이면 로컬 store를 그대로 두고 종료한다. 3. `404`는 두 가지다 — body의 `error.code`로 가른다. `RELEASE_NOT_FOUND`는 아직 발행본이 없는 project이므로 로컬 store를 비우지 말고 직전 활성 release와 저장한 ETag를 유지한 채 다음 cycle에 재시도한다. `PROJECT_NOT_FOUND`는 삭제됐거나 이 API key의 scope 밖이라 재시도로 낫지 않는다 — 설정·provisioning 오류로 올린다. 4. `200`이면 응답 헤더의 `ETag`와 `Content-Language`를 **메모리에만** 담아둔다. 아직 영속화하지 않는다 — 아래 8번 전에 저장하면 적용이 실패해도 다음 요청이 그 ETag로 `304`를 받아 옛 release에 영구 고착된다. 적용된 locale은 요청 locale이 아니라 `Content-Language`가 확정한다. 5. 파일을 받기 전에 `signature`부터 검증한다. 이 endpoint는 서명된 발행본만 내보내므로 `signature`가 `null`이면 그 자체가 invalid다 — 없거나 검증에 실패하면 아무것도 반영하지 않는다. 검증 방식은 아래 실패 규칙에 있다. 6. `files[]`에는 본문이 없다. 각 항목의 `nodeId`/`versionId`로 `GET /api/nodes/{nodeId}/download?versionId=`를 받아 `checksum`을 검증한다. 7. `pois.categories`/`pois.pois`는 diff가 아니라 full replace다. `profile`은 Unity 빌드에 baked되던 공간별 설정이 wire로 옮겨온 값이다. 8. 서명 검증·파일 다운로드·checksum·POI 교체·프로필 반영이 **전부 성공한 뒤에만** 활성 포인터·ETag·locale을 한꺼번에 영속화한다. ### Pseudocode ```ts const headers = { "X-API-Key": apiKey, ...(local.releaseEtag === null ? {} : { "If-None-Match": local.releaseEtag }), }; const response = await get("/api/releases/active", headers); if (response.status === 304) return; if (response.status === 404) { const { error } = await response.json(); if (error.code === "RELEASE_NOT_FOUND") return; // 미발행 — 직전 활성 release를 유지하고 재시도 throw new ProjectUnavailable(error.code); // PROJECT_NOT_FOUND — 재시도로 낫지 않는다 } const release = await response.json(); // 서명된 발행본만 내려온다. signature=null은 invalid descriptor다 — fail-closed. if (release.signature === null || !verifyReleaseSignature(release)) return; const staged = await stageAll(release.files); // nodeId/versionId 다운로드 + checksum 검증 if (!staged.ok) return; // 활성 포인터를 바꾸지 않는다 await atomicallyReplaceLocalStore(staged.files, release.pois, release.profile); local.releaseEtag = response.headers.get("ETag"); local.releaseLocale = response.headers.get("Content-Language"); ``` ### Example request ```http GET /api/releases/active?locale=ko HTTP/1.1 X-API-Key: If-None-Match: "v1-f42-p17-9c1a2b3d4e5f:ko" ``` ### Example response ```json { "releaseId": "v1-f42-p17-9c1a2b3d4e5f", "schemaVersion": 1, "projectUuid": "550e8400-e29b-41d4-a716-446655440000", "projectSlug": "demo-space", "revision": { "files": 42, "pois": 17, "profile": "sha256:" }, "files": [], "pois": { "categories": [], "pois": [] }, "profile": { "projectKey": "demo-hall-a", "stages": [] }, "signature": "", "validation": { "ok": true, "checks": [{ "name": "file-checksum-format", "status": "pass" }] } } ``` 이 endpoint는 박제된 발행본 snapshot만 돌려주므로 `signature`는 항상 채워져 있다 — 즉석 조립(candidate) 응답 경로는 없고, 발행본이 없으면 body 대신 `404 RELEASE_NOT_FOUND`다. `revision.profile`(Runtime Profile의 canonical SHA-256)과 `validation`(박제 전 콘텐츠 검증 요약)은 두 필드가 생기기 전에 박제된 발행본에는 없다. 부재를 허용하되, 있으면 그대로 서명 원문에 포함된다. ### Retry and failure rule - ETag는 `"{releaseId}:{박제 시점 locale}"`이고 요청 locale은 참여하지 않는다. **weak validator(`W/"..."`)는 지원하지 않는다.** 받은 값을 가공 없이 그대로 `If-None-Match`에 되돌려 보낸다. - `releaseId`에는 본문 해시가 들어간다. revision 카운터를 올리지 않는 쓰기(번역 수정·slug 변경)도 `releaseId`를 바꾸므로, 카운터만 비교해 `304`로 단정하지 않는다. - 파일 다운로드·checksum 중 하나라도 실패하면 활성 포인터와 저장한 ETag를 유지하고 다음 cycle에서 재시도한다. 부분 교체를 커밋하지 않는다. - `404`를 상태 코드만으로 판정하지 않는다. `RELEASE_NOT_FOUND`는 발행 전 project의 정상 응답이라 로컬 상태를 유지한 채 재시도하지만, 같은 `404`인 `PROJECT_NOT_FOUND`는 삭제·scope 이탈이라 재시도가 무의미하다 — 구분하지 않으면 사라진 project를 미발행으로 오인해 옛 release를 무한히 붙든다. - `signature`는 발행본이 박제(freeze)될 때 붙는 base64 ECDSA P-256 서명(DER)이다. 서명 원문은 응답 body에서 `signature` **하나만** `null`로 치환한 객체를, 모든 객체 키를 재귀적으로 **UTF-16 code unit** 사전순 정렬한 뒤 공백 없는 JSON(UTF-8)으로 직렬화한 바이트이고 `SHA256withECDSA`로 검증한다. **`releaseId`는 원문에 남는다** — 제외하면 정상 release도 검증에 실패한다. `revision.profile`·`validation`을 포함해 body의 나머지 필드도 전부 원문에 들어간다. - 키 정렬은 문자열 비교이지 숫자 비교가 아니다. `"10"`은 `"2"`보다 **앞**에 온다 — `mapSettings`나 POI `metadata`처럼 free-form인 곳에 정수형 문자열 키가 들어오면 이 차이가 서명 검증을 가른다. - 서명 검증에 실패하거나 `signature`가 `null`이면 활성 포인터를 바꾸지 않는다. 미서명은 "검증 생략"이 아니라 **invalid descriptor**다 — 이 표면은 서명된 발행본만 내보내므로 서명이 없다는 사실 자체가 응답을 신뢰할 수 없다는 신호다. checksum·호환성 검증으로 대체하지 않는다. ### 흔한 실수 - `/api/manifest`와 `/api/manifest/pois`를 따로 호출해 두 응답을 조합한다. 그 사이에 발행이 끼면 **서버에 존재한 적 없는 revision 조합**을 활성화하게 된다 — 그 조합이 필요 없다면 이 endpoint 하나를 쓴다. - 캐시·슬롯 키를 `projectSlug`로 잡는다. slug는 변경 가능하므로 불변 `projectUuid`를 쓴다. - `?locale`을 보냈으니 그 locale이 적용됐다고 가정한다. 발행본은 박제 시점 locale로 고정돼 요청 locale이 무시되므로 `Content-Language`로 확인한다. - release의 `revision` 쌍을 파일/POI 증분 sync의 로컬 revision slot에 그대로 밀어 넣는다. 두 경로를 섞어 쓸 거라면 slot을 분리해 관리한다. - 서명 검증에서 수신한 JSON 바이트를 그대로 쓴다. 원문은 `signature`만 `null`로 치환하고 키를 재귀 정렬해 공백 없이 다시 직렬화한 형태다. 반대로 `releaseId`를 제외하는 것도 같은 실패다 — 제외하지 않는다. - `signature`가 없는 응답을 "서명 도입 전 서버"로 보고 통과시킨다. 이 표면은 발행본 전용이라 서명 없는 정상 응답이 존재하지 않는다 — 그대로 거부한다. ### Acceptance checklist - [ ] 서버가 준 ETag 문자열을 그대로 보관하고 그대로 되돌려 보낸다. - [ ] 적용 locale을 `Content-Language`로 판정한다. - [ ] `signature` 존재를 필수로 요구하고, 검증까지 통과했을 때만 반영한다. 없거나 실패하면 아무것도 반영하지 않는다. - [ ] 파일·POI·프로필 반영이 전부 성공한 뒤에만 활성 포인터를 교체한다. - [ ] `404`를 `error.code`로 갈라 `RELEASE_NOT_FOUND`만 재시도하고, `PROJECT_NOT_FOUND`는 오류로 올린다. - [ ] ETag를 적용 성공 뒤에만 영속화한다 — 실패한 cycle의 ETag를 저장하지 않는다. - [ ] 캐시 키에 `projectUuid`를 쓴다. ## Flow: runtime lookup ### Step checklist 1. 대량 동기화는 manifest 흐름을 쓰고, 화면에서 즉시 필요한 검색·상세만 runtime lookup으로 호출한다. 2. 목록 검색은 `GET /api/pois`를 사용한다. 3. 단건 상세는 `GET /api/pois/{poiId}`를 사용한다. `poiId`는 POI uuid다. 4. category 목록·tree·상세는 `/api/poi-categories*`를 사용한다. 5. type 목록은 `GET /api/poi-types`를 사용한다. 6. 결과를 장기 cache에 넣는 경우에도 다음 manifest sync에서 full replace 규칙을 우선한다. ### Pseudocode ```ts const pois = await getJson("/api/pois?search=entrance", { "X-API-Key": apiKey }); const selected = await getJson("/api/pois/" + poiUuid, { "X-API-Key": apiKey }); const categories = await getJson("/api/poi-categories/tree", { "X-API-Key": apiKey }); const types = await getJson("/api/poi-types", { "X-API-Key": apiKey }); renderRuntimeOverlay({ pois, selected, categories, types }); ``` ### Example request ```http GET /api/pois/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1 X-API-Key: ``` ### Example response ```json { "uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Entrance", "status": "active", "categoryId": "550e8400-e29b-41d4-a716-446655440101", "typeUuid": "550e8400-e29b-41d4-a716-446655440201", "externalId": "550e8400-e29b-41d4-a716-446655440301", "stage": "1F", "center": [0, 1.25, 2.5], "rotate": [0, 0, 0], "scale": [1, 1, 1], "sortOrder": 10, "metadata": null, "project": { "uuid": "550e8400-e29b-41d4-a716-446655440401", "slug": "warmemo", "name": "War Memo" }, "createdAt": "2026-06-17T02:00:00.000Z", "updatedAt": "2026-06-17T03:00:00.000Z" } ``` ### Retry and failure rule - `401`은 설정 문제다. 같은 key로 자동 재시도하지 않는다. - `404 POI_NOT_FOUND` 또는 category/type 관련 `404`는 사용자가 오래된 id를 보고 있을 수 있음을 뜻한다. manifest 또는 목록을 다시 조회한다. - `429`와 5xx는 backoff 후 재시도하되, 런타임 화면은 마지막 정상 cache를 보여줄 수 있다. ### 흔한 실수 - `/api/pois`를 전체 동기화 수단으로 반복 호출한다. 대량 cache 기준점은 manifest다. - lookup 결과를 local manifest revision 갱신의 근거로 삼는다. - unknown metadata field를 strict parser 오류로 처리한다. ### Acceptance checklist - [ ] runtime lookup은 sync revision을 직접 변경하지 않는다. - [ ] stale id의 `404`에서 manifest/list refresh 경로를 제공한다. - [ ] unknown fields를 무시하거나 보존한다. ## Flow: logs 이 흐름은 계약이 다른 두 표면을 함께 다룬다 — 임의 JSON telemetry(`/api/log/entries`)와 VL pose chunk 적재(`/api/pose-logs`)다. 둘 다 write 권한 API key를 요구하지만 body 형태·멱등 방식·rate limit 기준이 서로 다르므로 한쪽 규칙을 다른 쪽에 적용하지 않는다. ### Step checklist 1. telemetry append는 `POST /api/log/entries`를 호출한다. 이 endpoint는 write 권한 API key가 필요하다. 2. 성공은 `204 No Content`이므로 response body를 기대하지 않는다. 3. payload는 임의 JSON이지만 클라이언트 쪽에서 너무 큰 batch를 만들지 않는다. 4. export가 필요하면 `GET /api/log/entries.csv?start=&end=`를 호출한다. 5. CSV `start`/`end`는 timezone 포함 ISO 8601이고, 서버는 31일·100,000 rows 이하 범위를 허용한다. 6. VL pose 로그는 `POST /api/pose-logs`로 보낸다. sealed chunk 파일 하나가 요청 하나이고, 본문은 그 파일의 각 줄을 순서 그대로 담은 JSON 배열이다(마지막 원소가 `kind: "seal"` record). 요청당 1000행·본문 4mb까지다. 7. pose 적재는 `Idempotency-Key` 헤더에 chunkId(UUID)를 **필수로** 싣는다. 누락되거나 UUID 형식이 아니면 `400`이고, 이 값이 서버가 보관하는 원본 객체 key를 결정한다. 서버는 형식만 보고 버전을 따지지 않되, 같은 chunk를 대소문자가 다른 문자열로 재전송하면 다른 chunk로 취급하므로 소문자 표준형으로 고정한다. 8. 재시도는 같은 `Idempotency-Key`를 유지한다. 서버가 중복을 걸러 `deduped: true`로 최초 적재와 같은 결과를 돌려주므로, 재시도마다 키를 새로 만들면 같은 chunk가 중복 적재된다. 9. pose 응답의 `dataCount`는 `seal`을 제외한 적재 record 수라 본문의 `seal.count`와 그대로 대조한다. ### Pseudocode ```ts await postJson("/api/log/entries", { event: "poi_opened", poiId: 5001, occurredAt: new Date().toISOString(), }, { "X-API-Key": writeApiKey }); const csv = await getText( "/api/log/entries.csv?start=2026-06-17T00:00:00.000Z&end=2026-06-18T00:00:00.000Z", { "X-API-Key": readApiKey }, ); // pose chunk: chunkId는 chunk를 만들 때 한 번 정하고 재시도 내내 유지한다. const chunkId = local.chunkId ?? uuidV4(); const result = await postJson("/api/pose-logs", sealedRecords, { "X-API-Key": writeApiKey, "Idempotency-Key": chunkId, }); if (result.dataCount !== seal.count) reportChunkMismatch(chunkId); ``` ### Example request ```http POST /api/log/entries HTTP/1.1 X-API-Key: Content-Type: application/json { "event": "poi_opened", "poiId": 5001, "occurredAt": "2026-06-17T03:00:00.000Z" } ``` ### Example response ```http HTTP/1.1 204 No Content ``` ```http GET /api/log/entries.csv?start=2026-06-17T00:00:00.000Z&end=2026-06-18T00:00:00.000Z HTTP/1.1 X-API-Key: ``` pose chunk 적재는 sealed 파일의 줄 배열을 그대로 싣는다. `kind`만 필수이고 모르는 필드는 원본 그대로 보존된다. ```http POST /api/pose-logs HTTP/1.1 X-API-Key: Idempotency-Key: 67f5d654-43c6-42ec-93fb-ae2393406b3f Content-Type: application/json [ { "v": 1, "kind": "pose", "sessionId": "s-1", "gen": 1, "seq": 0, "stage": "1F", "at": "2026-06-17T03:00:00.000Z", "pose": { "tx": 1.2, "ty": 0.0, "tz": -3.4, "qw": 1.0, "qx": 0.0, "qy": 0.0, "qz": 0.0 } }, { "kind": "destination", "sessionId": "s-1", "gen": 1, "seq": 1, "dest": { "poiId": "5001" } }, { "kind": "seal", "sessionId": "s-1", "gen": 1, "reason": "rotate", "count": 2 } ] ``` ```json { "poseLogId": 1024, "poseLogUuid": "9c1a2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d", "dataCount": 2, "parseErrorCount": 0, "deduped": false } ``` ### Retry and failure rule - `403 AUTH_INSUFFICIENT_PERMISSION`은 write 권한 없는 key다. key를 바꾸기 전에는 재시도하지 않는다. - `413 PAYLOAD_TOO_LARGE`는 batch를 줄여 다시 보낸다. - CSV의 `400`/`422 VALIDATION_ERROR`는 시간 범위나 row 제한을 고친 뒤 재요청한다. - network/5xx 실패 시 append는 client-side queue에 보존하고 idempotency가 필요한 payload는 자체 event id를 넣는다. - pose 적재의 network/5xx 재시도는 **같은 `Idempotency-Key`로** 보낸다. 서버가 걸러 `deduped: true`를 돌려주므로 재전송이 안전하다. - pose 적재의 `429`는 API key 단위 600 req/min을 넘긴 것이다. 단말 수를 줄이는 대신 chunk 크기를 키워 요청 수를 낮춘다. - pose 적재의 `400`은 본문이 파싱되지 않거나 알려진 필드의 타입이 어긋난 경우, 또는 `Idempotency-Key`가 UUID가 아닌 경우로 한정한다. 모르는 `kind`·`status`, `seal` 부재, `seal.count` 불일치는 거절이 아니라 원본 보존 적재다 — 재전송해도 결과가 같으므로 재시도하지 않는다. ### 흔한 실수 - `POST /api/log/entries` 성공에서 JSON body를 파싱하려 한다. - read-only key로 로그 적재를 시도한다. - CSV export에 timezone 없는 local time을 보낸다. - pose 재시도마다 `Idempotency-Key`를 새로 만들어 같은 chunk를 중복 적재한다. - pose 전송량을 IP 기준 60 req/min에 맞춰 과도하게 조인다. 이 표면만 API key 단위 한도를 쓴다. - `seal.count` 불일치를 서버가 거절할 것이라 가정하고 클라이언트 검증을 생략한다. 서버는 보존 적재하므로 대조는 `dataCount`로 클라이언트가 한다. ### Acceptance checklist - [ ] append 성공을 `204 No Content`로 처리한다. - [ ] write 권한 key와 read 조회 key 운용을 구분한다. - [ ] CSV 범위를 ISO 8601 UTC 또는 timezone 포함 값으로 보낸다. - [ ] pose chunk의 `Idempotency-Key`를 chunk 생성 시점에 정하고 재시도 내내 유지한다. - [ ] pose 응답의 `dataCount`를 본문 `seal.count`와 대조한다. ## Remote MCP: POI import Spatrix는 agent가 POI를 조회·정규화·bulk import할 수 있도록 REST와 별개의 stateless MCP Streamable HTTP endpoint를 제공한다. MCP host가 tool schema를 discovery하므로 `/api/mcp`를 일반 REST endpoint처럼 직접 호출하지 않는다. ### Connection - endpoint: `https:///api/mcp` - transport: MCP Streamable HTTP, stateless - authentication: `X-API-Key: ` - request body limit: `4mb` - throttle: source IP 기준 `60 req/min` MCP host에는 remote HTTP server URL과 custom header를 함께 설정한다. 다음은 host-agnostic 개념 예시이며 outer config key와 environment-variable expansion 문법은 host 문서를 따른다. ```json { "type": "http", "url": "https:///api/mcp", "headers": { "X-API-Key": "" } } ``` API key는 source file이나 공유 config에 넣지 않고 host의 secret/environment mechanism으로 주입한다. 현재 endpoint는 header API key 방식이며 OAuth resource server가 아니다. custom header를 지원하지 않거나 OAuth-only connector에서는 연결할 수 없다. ### Tools | Tool | Required key | Behavior | |---|---|---| | `list_poi_types` | read | key project의 POI type 목록과 적용 locale 반환 | | `list_poi_categories` | read | key project의 POI category 목록과 적용 locale 반환 | | `import_pois` | write | 안정적인 `externalId` 기준 최대 500행 upsert; `dryRun` 기본값은 `true` | ### Safe import workflow 1. `list_poi_types`와 `list_poi_categories`를 호출해 현재 project의 UUID 후보를 얻는다. type의 `uuid`는 `typeUuid`, category의 `uuid`는 `categoryId`로 사용한다. 가능하면 source locale을 `locale`로 전달한다. 2. 이름은 exact `slug`/`path`를 우선하고 type의 `modelNodeName`을 보조로 쓴다. 후보가 둘 이상이면 추측하지 않고 사용자에게 확인한다. 3. 각 source row에 재시도 후에도 유지되는 `externalId`를 지정한다. random identifier를 만들면 같은 import가 새 POI를 중복 생성할 수 있다. 4. 먼저 `import_pois({ pois, dryRun: true })`를 호출한다. 최대 500행이며 모든 failed row와 category/type 매칭을 검토한다. 5. 검토한 동일 payload에만 `dryRun: false`를 명시해 저장한다. 생략하면 계속 preview이고 DB에 반영되지 않는다. 한 호출에 담기 힘든 대용량 입력은 Skill 번들(`/api/skills/poi-bulk-import.zip`)이 대안이다. 무인증으로 받을 수 있고, Python 실행이 가능한 환경에서 파일 분할·chunk 전송·재시도를 helper가 처리한다. 같은 `POST /api/pois/batch` 계약을 쓰므로 MCP와 결과가 같다. ### Errors - invalid/expired key는 MCP dispatch 전에 HTTP `401`과 REST Client API와 같은 error envelope를 반환한다. - domain failure(read-only key의 `import_pois` 포함)는 tool result가 `isError=true`이고, content text에 REST와 같은 `{ "error": { "code", "message", "details"? } }` JSON이 담긴다. `structuredContent`는 성공 결과에만 있다 — 오류는 content text를 parse해 안정적인 `error.code`로 분기한다. - input schema failure는 `isError=true` tool result의 content text로 실패 상세가 온다(`error.code` 없음). tool schema에 맞게 arguments를 고친 뒤 재호출한다. - `429 RATE_LIMIT_EXCEEDED`는 MCP dispatch 전 HTTP 오류라 tool result가 아니라 transport 오류로 뜬다. `Retry-After` 또는 `X-RateLimit-Reset`까지 기다린 뒤 재연결한다. rate limit은 source IP 기준이며 initialize/handshake 요청도 소모한다. ### Acceptance checklist - [ ] remote Streamable HTTP와 `X-API-Key` custom header를 지원하는 MCP host를 사용한다. - [ ] API key를 source/config에 평문으로 commit하지 않는다. - [ ] list tools로 reference UUID를 확인한 뒤 import한다. - [ ] `dryRun=true` 결과를 검토하고 동일 payload에만 `dryRun=false`를 적용한다. ## Endpoint reference ### 기기 인증 1회성 bootstrap 키로 기기를 등록하고, 공간(project)을 골라 짧은 수명의 access token을 받는다. 바이너리에 project 장기 키(X-API-Key)를 고정하지 않는 대체 인증 경로다. #### POST /api/device-auth/bootstrap **Summary:** 1회성 bootstrap 키로 기기 등록 admin이 발급한 1회성 bootstrap 키를 제출해 기기를 등록하고 refresh token과 허용 공간 목록을 받는다. 키는 성공 즉시 소진되어 재사용할 수 없다 — 기기를 다시 등록하려면 새 키를 발급받는다. `refreshToken`은 기기 안전 저장소에 보관하고, 공간을 골라 `POST /api/device-auth/token`으로 짧은 수명의 access token을 받는다. 이 경로가 APK 등 네이티브 바이너리에 project 장기 키를 고정하지 않기 위한 대체다. ##### Parameters No parameters. ##### Request Body Required: yes | content-type | schema | |---|---| | application/json | DeviceBootstrapBody { bootstrapKey } | ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: DeviceBootstrapResponse { refreshToken, spaces } | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### POST /api/device-auth/token **Summary:** refresh token 회전 + project-scoped access token 발급 refresh token과 공간(`projectUuid`)을 제출해 그 공간으로 scope된 access token(15분)과 회전된 refresh token을 받는다. **응답의 새 토큰으로 교체 저장해야 한다** — 이전 refresh token은 곧 무효가 된다. 다만 응답을 받지 못했을 때(타임아웃·네트워크 끊김) 같은 refresh token으로 **60초 안에 재시도하는 것은 안전하다** — 새 토큰 쌍이 다시 발급되고 앞선 응답의 토큰은 버려진다. 단 그 응답의 새 토큰을 이미 한 번 쓴 뒤라면 60초 안이어도 탈취로 판정한다(재시도가 아니라 중복 사용이다). 그 창을 넘겨 이미 교체된 refresh token을 다시 쓰는 경우도 같다 — 해당 기기의 모든 세션이 폐기되고 그 경우 bootstrap부터 다시 시작한다. access token은 `X-API-Key` 대신 `Authorization: Bearer `으로 보내며 POC2 전용 인증 경계에서만 사용한다. 기존 manifest·download API는 종전대로 `X-API-Key`만 받는다. `expiresIn`은 access token 수명(초)이다. ##### Parameters No parameters. ##### Request Body Required: yes | content-type | schema | |---|---| | application/json | DeviceTokenBody { refreshToken, projectUuid } | ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: DeviceTokenResponse { accessToken, refreshToken, expiresIn } | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_REFRESH_INVALID (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_REFRESH_INVALID | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/device-auth/spaces **Summary:** 허용 공간 목록 재조회 등록된 기기가 접근할 수 있는 공간(project) 목록을 돌려준다 — bootstrap 응답의 `spaces`와 같은 내용의 재조회다. `Authorization: Bearer `으로 호출한다(회전 없음 — refresh token은 그대로 유효하다). 공간 추가·보관 후 목록 갱신에 쓴다. ##### Parameters No parameters. ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: DeviceSpacesResponse { spaces } | - | | 401 | AUTH_REFRESH_INVALID (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_REFRESH_INVALID | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | ### 동기화 cold-start 번들과 파일/POI 매니페스트 기반 증분 싱크. 클라이언트가 로컬 revision을 들고 와 변경분을 판단한다. #### GET /api/bundle **Summary:** 에셋 번들(ZIP) — cold-start 최적화 worker 가 project 전체를 미리 빌드해 둔 ZIP 을 presigned URL 로 받는다. **항상 200** 이고 `state` 로 분기한다. `ready` 면 `url`(TTL 600초)·`revision`·`size`·`expiresAt`. `building` 이면 worker 가 아직 빌드 중이라 `revision`(직전 성공본, 최초면 `null`)만. `building` 이면 짧게 backoff 폴링하거나 바로 `/manifest` + 개별 `/download` 증분 경로로 fallback 한다 — 번들은 최적화이지 필수가 아니다. ZIP 내부엔 `manifest.json` + `pois.json` + `files/` 가 들어 있다. `revision` 은 파일/POI 중 최신값인 bootstrap high-water marker라 초기 로컬 revision slot에 넣을 수 있지만, 정확한 독립 revision은 이후 `/manifest` 와 `/manifest/pois` 응답으로 reconcile 한다. ##### Parameters No parameters. ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: BundleResponse (oneOf(object { state, url, revision, size, expiresAt } \| object { state, revision })) | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/manifest **Summary:** 파일 매니페스트 (증분 싱크 기준점) project 의 활성 파일 전체를 경량 메타로 반환한다 (폴더 제외, `path` 정렬, 페이지네이션 없음). `ETag` = 파일 revision(`"42"`). 직전 revision 을 `If-None-Match` 로 보내면 변경 없을 때 body 없이 `304`. 변경 감지는 `nodeId` 키로 diff 한다 — `versionId` 가 다르면 재다운로드, 같은 `versionId` 에 `path` 만 다르면 이동, 응답에 없으면 삭제. 모든 파일 다운로드+checksum 검증이 성공해야 로컬 파일 revision 을 갱신한다 (부분 실패 시 다음 사이클 재시도). ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | If-None-Match | header | no | string | 직전 파일 revision ETag. 일치하면 304 Not Modified. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: FileManifestResponse { projectSlug, revision, files } | - | | 304 | Not Modified — ETag matched, no body. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/nodes/{nodeId}/download **Summary:** 단일 파일 본문 다운로드 단일 파일 본문을 NCloud presigned URL 로 받는다. **`302` redirect** 이므로 HTTP 클라이언트의 redirect-follow 로 그대로 받으면 된다 (Range 헤더는 presigned URL 이 직접 지원). `versionId` 를 **매니페스트의 값(uuid)으로 명시**한다 — 매니페스트 수신과 다운로드 사이 새 버전이 올라가도 매니페스트 시점의 정확한 파일을 받아 checksum 검증이 보장된다. 생략 시 최신 버전. `nodeId` 도 매니페스트의 uuid 다. 존재하지 않는 node / 다른 project 의 node / 폴더 / 미완성 버전 / malformed uuid 등 **모든 실패는 `404 NODE_NOT_FOUND` 로 합쳐진다** (정보 누설 차단). id 존재 여부로 분기하지 말 것. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | nodeId | path | yes | string | - | | versionId | query | no | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to NCloud presigned download URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | NODE_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | NODE_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/manifest/pois **Summary:** POI + 카테고리 매니페스트 POI 데이터는 가벼워서 diff 없이 **통째로 교체**한다. `/manifest` 와 같은 ETag 메커니즘이되 **별도 counter**(`poiManifestVersion`)를 쓴다 — 파일만 바뀌면 여기는 `304` 로 유지된다. 식별자는 모두 uuid 다 — `pois[].id` 는 POI uuid, `categories[].id`/`parentId`/`pois[].categoryId` 는 category uuid. `categories` 는 `parentId`/`path`/`depth` 로 트리 복원하고, `pois[].center` 는 `[x,y,z]` 또는 `null`(좌표 미배치). `externalId` 는 amproj 좌표 매칭 키 별도 필드다(미사용 project 는 `null`) — id 가 아니라 이 필드로 매칭한다. POI 싱크는 파일 싱크와 독립이라 한쪽 실패가 다른 쪽을 막지 않는다. `name`/`description`은 요청 locale로 번역되고(없으면 base 값), ETag는 `"{revision}:{locale}"`로 locale마다 갈려 캐시가 cross-locale로 잘못 304를 반환하는 것을 막는다. `categories[].enabled` 는 **실효값**이다 — 자신과 모든 조상이 켜져 있을 때만 `true` 이고, `false` 인 카테고리 소속 POI 는 `pois` 에 실리지 않는다(카테고리 entry 자체는 남으므로 삭제와 구분되고 client 가 표시 상태를 고를 수 있다). `categories[].showInMenu` 가 `false` 면 그 카테고리를 메뉴에 별도 항목으로 두지 않고 소속 POI 를 **부모 카테고리 항목으로 롤업해 표시한다** — 이 롤업은 client 책임이다. 서버는 `pois[].categoryId` 를 실제 소속 그대로 두므로(부모로 rewrite하지 않는다) 토글을 되돌리면 원래 소속이 그대로 복원된다. root 카테고리(`parentId: null`)에는 롤업 대상이 없어 `showInMenu` 를 해석하지 않는다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | query | no | string | 응답 name/description의 요청 locale. 미지정 시 `Accept-Language` → tenant/instance defaultLocale 순으로 resolve된다. 적용된 locale은 `Content-Language` 응답 헤더로 확인 가능. | | If-None-Match | header | no | string | 직전 POI revision ETag(locale-keyed). 같은 locale·revision이면 304 Not Modified. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: PoiManifestResponse { projectSlug, revision, categories, pois } | - | | 304 | Not Modified — ETag matched, no body. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/releases/active **Summary:** 활성 release descriptor (파일·POI·프로필 단일 스냅샷) `X-API-Key`가 가리키는 project의 활성 release를 **하나의 스냅샷**으로 반환한다 — 파일 목록, POI/카테고리, 런타임 프로필이 같은 트랜잭션에서 읽힌 동일 시점의 조합이다. `/manifest` 와 `/manifest/pois` 를 따로 호출하면 그 사이 발행이 끼어들어 서버에 존재한 적 없는 revision 조합을 받을 수 있다. `ETag` = `"{releaseId}:{snapshot locale}:{channel}"` 이고 `releaseId` 에 본문 해시가 들어간다 — revision 카운터만 쓰면 카운터를 올리지 않는 쓰기(번역 수정·slug 변경)가 본문을 바꿔도 `304` 가 나가 client 가 낡은 값을 쓴다. 직전 ETag 를 `If-None-Match` 로 보내면 변경 없을 때 body 없이 `304`. 파일 본문은 이 응답에 포함되지 않는다 — `files[]` 의 `nodeId`/`versionId` 로 별도 다운로드한다. `profile` 은 Unity 빌드에 baked 되던 공간별 설정을 wire 로 옮긴 표면이다. `signature` 는 박제(freeze) 시점에 붙는 base64 ECDSA P-256 서명(DER 인코딩)이다 — 서명 원문은 body 에서 `signature` 만 `null` 로 둔 객체를(**`releaseId` 는 남긴다**), 모든 객체 키 재귀 사전순 정렬 후 공백 없는 JSON(UTF-8)으로 직렬화한 바이트이고, `SHA256withECDSA` 로 그대로 검증된다. `releaseId` 가 서명 대상에 포함되므로 body 를 그대로 두고 `releaseId` 만 바꾼 응답은 검증에 실패한다 — client 는 releaseId 를 슬롯·활성 포인터 identity 로 쓸 수 있다. `releaseId` **계산** 입력은 이와 다르다: 그쪽은 콘텐츠 identity 만 담아 `releaseId` 와 `validation` 을 제외한다(재계산은 진단용이고 무결성 판정은 서명으로 한다). **이 endpoint 는 요청한 채널(기본 `production`)에 발행된 snapshot(박제 시점에 고정된 immutable release)만 반환한다. 발행본이 없으면 `404 RELEASE_NOT_FOUND` 다** — 편집 중 상태(Working Copy)를 즉석 조립해 주던 경로는 제거했다. `404` 는 아직 발행하지 않은 project 의 정상 상태이므로 client 는 동기화 실패로 처리하고 직전 활성 release 를 유지한다(캐시가 없는 신규 설치는 발행 전까지 콘텐츠가 없다). 편집 중 상태의 미리보기는 admin 표면이 담당한다. 발행본은 박제 시점의 tenant 기본 locale 로 고정돼 요청 locale 이 무시된다 — 적용 locale 은 `Content-Language` 로 확인한다(locale 별 snapshot 은 다국어 요구 시점에). client 는 서명·SHA-256·호환성 검증을 통과한 뒤에만 활성 포인터를 바꾼다. 캐시·슬롯 키에는 변경 가능한 `projectSlug` 대신 불변 `projectUuid` 를 쓴다. `releaseId` 는 콘텐츠 identity 의 해시를 포함하므로 그 부분이 바뀌면 반드시 달라진다(번역·slug 수정 포함). 다만 `validation` 은 releaseId 계산 입력에서 제외되므로 검증 결과만 달라지는 경우는 releaseId 가 그대로다 — 그 변화는 서명이 잡는다(`validation` 은 서명 원문에 포함된다). **인증은 전환기 병행이다 (게이트 C5).** 목표 계약대로 bootstrap credential 로 허용 공간을 조회한 뒤 선택 공간의 짧은 수명 scoped token 을 받는 경로가 열렸다 — `/api/device-auth/*` 로 기기를 등록하고 `Authorization: Bearer ` 으로 이 endpoint 를 호출한다(project 는 token 의 scope 가 결정). 기존 `X-API-Key` 도 하위호환으로 병행 수용하며, 신규 클라이언트는 네이티브 바이너리에 project 별 장기 비밀을 고정하지 않는 bootstrap 경로를 쓴다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | query | no | string | **이 endpoint 에서는 응답을 가르지 않는다** — 발행본 snapshot 이 박제 시점 locale 하나로 고정이라 요청 locale 은 무시되고, 적용된 locale 이 `Content-Language` 로 내려간다. 요청 표면은 외부 계약이라 유지하며, locale 별 snapshot 이 들어오는 시점에 이 값이 선택에 쓰인다. | | If-None-Match | header | no | string | 직전 release ETag. 값은 `"{releaseId}:{snapshot locale}:{channel}"` 이고 요청 locale 은 참여하지 않는다. 같은 발행본이면 304 Not Modified. 발행본이 없으면 304 가 아니라 404 다. channel 이 값에 들어가므로 채널을 바꿔 조회하면 같은 releaseId 라도 304 가 아니라 200 이다. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: ReleaseResponse { releaseId, schemaVersion, projectUuid, projectSlug, revision, files, pois, media, profile, signature, validation, bundle } | - | | 304 | Not Modified — ETag matched, no body. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404)

RELEASE_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND, RELEASE_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | | 500 | INTERNAL_ERROR (500) | application/json: allOf(ApiError { error } + object { error }) | INTERNAL_ERROR | #### GET /api/vl-credential **Summary:** ArcEye vlSecretKey 조회 (임시 표면) `X-API-Key`가 가리키는 project의 ArcEye(VL) 결제 키를 반환한다. ⚠️ **이 endpoint는 임시다.** VL 호출을 서버가 대행하는 릴레이 도입 시 이 표면은 제거되고, 클라이언트는 키 없이 릴레이 endpoint를 호출하게 된다 — 신규 연동은 이 표면에 장기 의존하지 않아야 한다. 키는 저장하지 말고 메모리에서만 사용한다(응답은 `Cache-Control: no-store`). 릴리스/profile 응답에는 이 키가 실리지 않는다 — 배송 경로는 이 endpoint 하나다. admin이 키를 등록하지 않은 project는 `404 VL_CREDENTIAL_NOT_CONFIGURED`. profile의 `vlLogLevel`이 VERBOSE(0)·DEBUG(1)이면 `409 VL_CREDENTIAL_LOG_LEVEL_UNSAFE`로 배송을 보류한다 — VLSDK native가 그 수준에서 secretKey를 평문으로 logcat에 찍는 것을 실기기에서 확인했다(2026-08-20). 배송을 받으려면 `vlLogLevel`을 INFO(2) 이상으로 올린다. ##### Parameters No parameters. ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: VlCredentialResponse { vlSecretKey } | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | VL_CREDENTIAL_NOT_CONFIGURED (404) | application/json: allOf(ApiError { error } + object { error }) | VL_CREDENTIAL_NOT_CONFIGURED | | 409 | VL_CREDENTIAL_LOG_LEVEL_UNSAFE (409) | application/json: allOf(ApiError { error } + object { error }) | VL_CREDENTIAL_LOG_LEVEL_UNSAFE | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/pois/{poiId}/thumbnail **Summary:** POI 썸네일 다운로드 POI 썸네일(정사각 JPEG)을 presigned URL 로 받는다. **`302` redirect**. `poiId` 는 POI uuid (`/manifest/pois` 의 `pois[].id` = `/pois` 의 `id`). 구형 manifest 캐시의 numeric id 도 계속 해석된다. 썸네일 미배치/미존재/malformed POI 는 모두 `404 THUMBNAIL_NOT_FOUND` 로 합쳐진다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiId | path | yes | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to presigned thumbnail URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | THUMBNAIL_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | THUMBNAIL_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/pois/{poiId}/images/{imageId} **Summary:** POI 정보 이미지 다운로드 POI 정보 이미지(display variant, JPEG 긴 변 1600px)를 presigned URL 로 받는다. **`302` redirect**. `poiId` 는 POI uuid(구형 manifest 캐시의 numeric id 허용), `imageId` 는 manifest `imageIds` 의 uuid. 미존재/격리 위반/malformed id는 모두 `404 POI_IMAGE_NOT_FOUND` 로 합쳐진다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiId | path | yes | string | - | | imageId | path | yes | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to presigned image URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | POI_IMAGE_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | POI_IMAGE_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories/{poiCategoryId}/thumbnail **Summary:** POI 카테고리 썸네일 다운로드 POI 카테고리 썸네일(정사각 JPEG)을 presigned URL 로 받는다. **`302` redirect**. `poiCategoryId` 는 category uuid(`/manifest/pois` 의 `categories[].id` = `/poi-categories` 의 `id`). 구형 manifest 캐시의 numeric id 도 허용. 미배치/미존재/malformed id는 모두 `404 THUMBNAIL_NOT_FOUND`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiCategoryId | path | yes | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to presigned thumbnail URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | THUMBNAIL_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | THUMBNAIL_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories/{poiCategoryId}/icon **Summary:** POI 카테고리 아이콘 다운로드 POI 카테고리 앱 메뉴용 아이콘(PNG)을 presigned URL 로 받는다. **`302` redirect**. `poiCategoryId` 는 category uuid(`/manifest/pois` 의 `categories[].id` = `/poi-categories` 의 `id`). 구형 manifest 캐시의 numeric id 도 허용. 미배치/미존재/malformed id는 모두 `404 THUMBNAIL_NOT_FOUND`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiCategoryId | path | yes | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to presigned category icon URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | THUMBNAIL_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | THUMBNAIL_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories/{poiCategoryId}/icon-active **Summary:** POI 카테고리 활성 아이콘 다운로드 POI 카테고리 선택(활성) 상태 아이콘(PNG)을 presigned URL 로 받는다. **`302` redirect**. `poiCategoryId` 는 category uuid(`/manifest/pois` 의 `categories[].id` = `/poi-categories` 의 `id`). 구형 manifest 캐시의 numeric id 도 허용. 미배치/미존재/malformed id는 모두 `404 THUMBNAIL_NOT_FOUND`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiCategoryId | path | yes | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to presigned category active icon URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | THUMBNAIL_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | THUMBNAIL_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories/{poiCategoryId}/marker **Summary:** POI 카테고리 마커 다운로드 POI 카테고리 지도 마커(PNG)를 presigned URL 로 받는다. **`302` redirect**. `poiCategoryId` 는 category uuid(`/manifest/pois` 의 `categories[].id` = `/poi-categories` 의 `id`). 구형 manifest 캐시의 numeric id 도 허용. 미배치/미존재/malformed id는 모두 `404 THUMBNAIL_NOT_FOUND`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiCategoryId | path | yes | string | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 302 | Redirect to presigned category marker URL. | - | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | THUMBNAIL_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | THUMBNAIL_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | ### POI 조회 검색·필터·상세 등 런타임 조회. 대량 동기화에는 매니페스트 경로를 쓴다. #### GET /api/pois **Summary:** POI 목록 (페이지네이션·검색·필터) 매니페스트 싱크 없이 런타임에 바로 질의할 때 쓴다 — 매니페스트보다 풍부한 필드(`typeUuid`, `rotate`, `scale`, `project`)를 준다. `categoryId` + `includeChildren=true` 면 하위 카테고리 POI 까지 포함한다. 잘못된 `categoryId` 는 `404 POI_CATEGORY_NOT_FOUND`. 대량 동기화에는 부적합 — 그건 `/manifest/pois` 경로를 쓴다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | query | no | string | 응답 name/description의 요청 locale. 미지정 시 `Accept-Language` → tenant/instance defaultLocale 순으로 resolve된다. 적용된 locale은 `Content-Language` 응답 헤더로 확인 가능. | | sortDir | query | no | string enum(asc, desc) | - | | sortBy | query | no | string enum(name, status, stage, externalId, updatedAt, category) | - | | search | query | no | string | - | | status | query | no | string enum(active, inactive) | - | | includeChildren | query | no | boolean | - | | categoryId | query | no | string(uuid) | - | | limit | query | no | integer | - | | page | query | no | integer | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: allOf(PaginatedBase { page, limit, hasNext, totalCount } + object { items }) | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404)

POI_CATEGORY_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND, POI_CATEGORY_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/pois/{poiId} **Summary:** 단일 POI 상세 `poiId` = POI uuid. 없으면 `404 POI_NOT_FOUND`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiId | path | yes | string | - | | locale | query | no | string | list와 동일한 locale 계약(`Content-Language` 응답 헤더로 적용 locale 확인). | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: Poi { uuid, categoryId, typeUuid, externalId, stage, name, description, status, center, rotate, scale, sortOrder, metadata, thumbnailId, tags, project, createdAt, updatedAt } | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404)

POI_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND, POI_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### POST /api/pois/batch **Summary:** POI 대량 upsert (bulk import) `externalId` 기준 upsert(있으면 update, 없으면 insert). 행별 best-effort + 리포트로 부분 성공을 허용한다(HTTP 200). 미리보기는 default `?dryRun=true`, 실제 반영은 명시 `?dryRun=false`. 행수는 500개 이하. `externalId` 없는 행은 행 단위로 `failed`(멱등성 보장). ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | dryRun | query | no | string enum(true, false) | - | ##### Request Body Required: yes | content-type | schema | |---|---| | application/json | BatchPoiBody { pois } | ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: BatchPoiResult { dryRun, summary, results } | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 403 | AUTH_INSUFFICIENT_PERMISSION (403) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INSUFFICIENT_PERMISSION | | 413 | PAYLOAD_TOO_LARGE (413) | application/json: allOf(ApiError { error } + object { error }) | PAYLOAD_TOO_LARGE | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories **Summary:** POI 카테고리 flat list 이 project 범위의 카테고리 flat list. `path` 는 항상 `/` prefix. 트리 형태는 `/poi-categories/tree`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | query | no | string | 응답 name/description의 요청 locale. 미지정 시 `Accept-Language` → tenant/instance defaultLocale 순으로 resolve된다. 적용된 locale은 `Content-Language` 응답 헤더로 확인 가능. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: array | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories/tree **Summary:** POI 카테고리 트리 중첩 `children` 트리 형태. flat list 는 `/poi-categories`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | query | no | string | 응답 name/description의 요청 locale(중첩 children 노드 포함). 미지정 시 `Accept-Language` → tenant/instance defaultLocale 순으로 resolve된다. 적용된 locale은 `Content-Language` 응답 헤더로 확인 가능. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: array | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-categories/{poiCategoryId} **Summary:** 단일 POI 카테고리 상세 `poiCategoryId` = 카테고리 uuid. 없으면 `404 POI_CATEGORY_NOT_FOUND`. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | poiCategoryId | path | yes | string | - | | locale | query | no | string | list와 동일한 locale 계약(`Content-Language` 응답 헤더로 적용 locale 확인). | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: PublicPoiCategory { uuid, parentId, name, slug, depth, path, sortOrder, description, thumbnailId, iconId, markerId, iconActiveId, enabled, showInMenu, createdAt, updatedAt, tags } | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404)

POI_CATEGORY_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND, POI_CATEGORY_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/poi-types **Summary:** POI 타입(3D 모델 프리셋) 목록 `modelNodeUuid` 로 해당 타입의 3D 모델 file node 를 가리킨다 → `/manifest` 의 그 node 를 `/download` 로 받아 렌더한다. POI 의 `typeUuid`(`/pois`) ↔ poi-type 의 `uuid` 로 연결한다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | query | no | string | 응답 name의 요청 locale. 미지정 시 `Accept-Language` → tenant/instance defaultLocale 순으로 resolve된다. 적용된 locale은 `Content-Language` 응답 헤더로 확인 가능. `description`은 이 surface에 원래 없는 필드라 대상이 아니다. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: array | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 404 | PROJECT_NOT_FOUND (404) | application/json: allOf(ApiError { error } + object { error }) | PROJECT_NOT_FOUND | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | ### 다국어 locale 카탈로그와 UI 번역 번들. POI·카테고리 본문 번역은 이 경로가 아니라 각 조회 응답이 요청 locale로 실어 보낸다. #### GET /api/i18n/config **Summary:** API key의 tenant 기준 locale 설정 조회 tenant에 override가 없으면 instance 기본값으로 fallback한다. ##### Parameters No parameters. ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | application/json: LocaleConfig { supportedLocales, defaultLocale, fallbackLocale } | - | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | #### GET /api/i18n/{locale} **Summary:** UI string 번들 조회 (전체 namespace) `ETag`는 번들 버전이다. 직전 `ETag`를 `If-None-Match`로 보내면 변경 없을 때 body 없이 `304`. 응답은 `Vary: Accept-Language` + `Content-Language: <적용된 locale>`을 포함한다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | path | yes | string | - | | If-None-Match | header | no | string | 직전 번들 ETag. 일치하면 304 Not Modified. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | namespace -> key -> resolved value 형태의 번들. | - | - | | 304 | Not Modified — ETag matched, no body. | - | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | #### GET /api/i18n/{locale}/{namespace} **Summary:** UI string 번들 조회 (단일 namespace) `:locale` 전체 번들 중 `:namespace` 하나만 반환한다. ETag/304/Vary 계약은 전체 번들 엔드포인트와 동일하다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | locale | path | yes | string | - | | namespace | path | yes | string | - | | If-None-Match | header | no | string | 직전 번들 ETag. 일치하면 304 Not Modified. | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | key -> resolved value 형태의 namespace 번들. | - | - | | 304 | Not Modified — ETag matched, no body. | - | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | ### 로그 클라이언트 로그 적재(append-only)와 CSV export, VL pose 로그 chunk 수집. #### POST /api/log/entries **Summary:** 로그 적재 (append-only) 임의 JSON을 호출 키의 project에 append한다. 저장 성공은 **204 No Content**. ##### Parameters No parameters. ##### Request Body 임의 JSON payload Required: yes | content-type | schema | |---|---| | application/json | object | ##### Responses | status | description | schema | error.code | |---|---|---|---| | 204 | No Content | - | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 403 | AUTH_INSUFFICIENT_PERMISSION (403) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INSUFFICIENT_PERMISSION | | 413 | PAYLOAD_TOO_LARGE (413) | application/json: allOf(ApiError { error } + object { error }) | PAYLOAD_TOO_LARGE | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### GET /api/log/entries.csv **Summary:** 로그 CSV export `created_at >= start AND created_at < end` 범위의 로그를 고정 컬럼 CSV(`id,project,created_at,payload`)로 돌려준다. `start`/`end`는 timezone 포함 ISO 8601. 31일·100,000 rows 이하로 제한한다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | end | query | yes | string(date-time) | - | | start | query | yes | string(date-time) | - | ##### Request Body No request body. ##### Responses | status | description | schema | error.code | |---|---|---|---| | 200 | - | text/csv: string | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 422 | VALIDATION_ERROR (422) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED | #### POST /api/pose-logs **Summary:** pose-log chunk 적재 VL pose 로그를 적재한다. sealed chunk 파일 하나가 요청 하나이고, 소속 tenant/project 는 `X-API-Key` 가 확정한다. **본문** — sealed 파일의 각 줄을 순서 그대로 담은 JSON 배열이다(마지막 원소가 `seal` record). `Idempotency-Key` 헤더에 chunkId(UUID v4)를 싣고 재시도 내내 같은 값을 유지한다 — 중복 적재를 막고 서버가 보관하는 원본 객체 key 를 이 값이 결정한다. 누락되거나 UUID 가 아니면 400 이다. **응답** — `dataCount` 는 `seal` 을 제외한 적재 record 수라 본문의 `seal.count` 와 그대로 대조할 수 있다. 재전송이 걸러진 경우 `deduped: true` 로 최초 적재와 같은 결과를 돌려준다. **검증** — 모르는 `kind`·`status`, `seal` 부재, `seal.count` 불일치는 거절하지 않고 원본을 보존해 적재한다. 400 은 본문이 파싱되지 않거나 알려진 필드의 타입이 어긋난 경우로 한정한다. 요청당 record 1,000행·본문 4 MiB 까지 받는다. rate limit 은 API 키 단위이며 초과 시 `Retry-After` 를 따른다. 본문은 **배열**이어야 한다. 객체 본문은 400 이다 — 과거 객체 배치 형식은 저장 모델(테이블·멱등 규칙·상한)이 통째로 달라, 같은 endpoint 가 둘을 받으면 형식 실수가 다른 저장 경로로 조용히 흘러 응답만으로 구분되지 않는다. ##### Parameters | name | in | required | type | description | |---|---|---|---|---| | Idempotency-Key | header | yes | string | - | ##### Request Body Required: yes | content-type | schema | |---|---| | application/json | IngestPoseLogV2Body | ##### Responses | status | description | schema | error.code | |---|---|---|---| | 201 | - | application/json: PoseIngestResult { poseLogId, poseLogUuid, dataCount, parseErrorCount, deduped } | - | | 400 | VALIDATION_ERROR (400) | application/json: allOf(ApiError { error } + object { error }) | VALIDATION_ERROR | | 401 | AUTH_INVALID_KEY (401)

AUTH_EXPIRED_KEY (401) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INVALID_KEY, AUTH_EXPIRED_KEY | | 403 | AUTH_INSUFFICIENT_PERMISSION (403) | application/json: allOf(ApiError { error } + object { error }) | AUTH_INSUFFICIENT_PERMISSION | | 413 | PAYLOAD_TOO_LARGE (413) | application/json: allOf(ApiError { error } + object { error }) | PAYLOAD_TOO_LARGE | | 429 | RATE_LIMIT_EXCEEDED (429) | application/json: allOf(ApiError { error } + object { error }) | RATE_LIMIT_EXCEEDED |