store · personal second brain backend

store

다수 사람·LLM 에이전트가 공유하는 노트·개념·관계 그래프 백엔드. PostgreSQL 16 + Apache AGE + pgvector 한 인스턴스에 모두 들어가고, 모든 데이터는 REST API 로만 다룹니다. account 단위로 그래프가 분리되어 다른 사람의 데이터에 절대 접근할 수 없습니다.

basehttps://store.parklab.work authBearer <token> formatJSON · UTF-8 tlsLet's Encrypt
목차
  1. 무엇을 하는가
  2. 즉시 시작 — 누구나 가입 (사람·에이전트)
  3. 인증과 토큰
  4. 노드 CRUD
  5. 검색 (자동 임베딩 + 벡터)
  6. 온톨로지 (advisory)
  7. 표준 vocabulary 라이브러리
  8. 페이지네이션 (cursor + sticky offset)
  9. 낙관적 동시성 (ETag / If-Match)
  10. 노드 간 참조 — wikilink + 엣지
  11. 엣지 API
  12. account 격리
  13. 관리자 엔드포인트
  14. 헬스체크
  15. 메일 수신 (mail ingest)
  16. 기능 제안 (feature suggestions)
  17. 현재 한계와 다음

01무엇을 하는가

store 는 LLM 이나 사람이 만든 노트·개념·아이디어를 영구 저장하고, 노드 사이의 관계를 그래프로 연결하는 두뇌 보조 서비스입니다. 단일 도메인 https://store.parklab.work 에서 REST API 로 동작하며, 다음을 약속합니다.

02즉시 시작 — 누구나 가입 (사람·에이전트)

도메인만 알면 누구든 한 번의 호출로 service account 와 Bearer 토큰을 함께 발급받아 즉시 노드를 저장하고 검색할 수 있습니다. 사람도 에이전트도 동일한 절차.

# 가입 (이름은 선택 — 미지정 시 자동 발급)
curl -fsS -X POST https://store.parklab.work/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"name":"alice"}'

응답 (예시)

{
  "account": {
    "id": "019e0a80-...",
    "name": "alice",
    "graph_name": "acct_019e0a80..."
  },
  "token": {
    "id": "019e0a80-...",
    "account_id": "019e0a80-...",
    "name": "self-issued",
    "prefix": "abc123def456",
    "token": "abc123def456-RAW-PLAINTEXT-RETURNED-ONCE"
  }
}
⚠ 토큰은 응답 한 번만 보입니다. 즉시 안전한 곳(환경변수, 비밀 매니저)에 보관하세요. 잃어버리면 새 가입 + 토큰 발급이 필요합니다.
⚠ 자동 정리 정책 (1시간 간격 / hourly): 서버는 매 1시간마다 다음 두 종류의 service account 를 자동 삭제합니다.
  1. /v1/signup 으로 만든 계정에 노드를 한 개도 만들지 않은 채 24시간 (24 hours) 이 지나면 그 계정은 삭제됩니다.
  2. 어떤 토큰으로도 30일 (30 days) 동안 한 번도 접근(insert · update · delete · select 모두 포함)되지 않은 계정도 같은 hourly 배치에 의해 삭제됩니다.
잃어버리지 않으려면 정기적인 활동 또는 별도 백업이 필요합니다. 삭제 시 그 account 의 노드 · 엣지 · 그래프 · 모든 토큰이 함께 사라집니다.

한 줄 사용 예 (curl 셸 함수)

signup() {
  curl -fsS -X POST https://store.parklab.work/v1/signup \
    -H "Content-Type: application/json" \
    -d '{"name":"'"$1"'"}' | jq -r .token.token
}
export STORE_TOKEN=$(signup my-agent)

Rate limit: 같은 IP 에서 분당 5회 / 시간당 50회 까지만 /v1/signup 호출 가능. 초과 시 429 + Retry-After 헤더. captcha · email verification 은 다음 라운드.

03인증과 토큰

모든 사용자 엔드포인트(/v1/nodes 등)는 HTTP 헤더 Authorization: Bearer <token> 가 필요합니다. 토큰은 32바이트 임의 비밀의 base64url 표현(약 43자)이며, 발급 시 한 번만 응답에 노출됩니다. 잃어버리면 새로 발급받아야 합니다.

토큰 발급 응답에는 평문 token 외에 prefix(앞 12자)가 함께 들어 있습니다. prefix 는 평문 비밀이 아니므로 운영 로그·감사·티켓에 안전하게 적을 수 있고, 같은 account 가 여러 토큰을 가진 경우 이들을 구분하는 유일한 안전 식별자입니다 (DB 에 저장된 prefix 와 같음).

일반 사용자 — 토큰 받기

관리자(ADMIN_TOKEN 보유자)에게 다음을 요청합니다.

  1. 본인용 service account 생성
  2. 해당 account 에 발급한 Bearer 토큰 전달

관리자는 관리자 엔드포인트 절차로 발급해 줍니다. 받은 토큰은 한 번만 화면에 보이는 plaintext 이므로 즉시 안전한 곳에 보관하세요.

토큰 사용

export STORE_TOKEN='<발급받은_토큰>'

curl -fsS https://store.parklab.work/v1/nodes \
  -H "Authorization: Bearer $STORE_TOKEN"
⚠ 보안 · 토큰은 git, 채팅, 로그 어디에도 평문으로 남기지 말고 환경변수나 비밀 매니저에 두세요. 노출됐다고 의심되면 관리자에게 즉시 회수(revoke) 요청.

04노드 CRUD

노드 한 개는 노트·개념·엔터티 등 어떤 단위든 됩니다. kindtitle 은 필수, 나머지는 선택입니다. 모든 노드는 호출자 account 의 AGE 그래프 안에 동시에 vertex 로 만들어집니다.

노드 생성 — POST/v1/nodes

curl -fsS -X POST https://store.parklab.work/v1/nodes \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "Note",
    "title": "첫 노드",
    "body": "store 에 처음 저장한 노드.",
    "meta": { "tags": ["intro"] }
  }'

응답 (예시)

{
  "id": "a12f8457-e553-44a2-916e-5f89a5fd7569",
  "account_id": "49629210-9611-4d96-a067-dbb030fd4568",
  "kind": "Note",
  "title": "첫 노드",
  "body": "store 에 처음 저장한 노드.",
  "meta": { "tags": ["intro"] },
  "created_at": "2026-05-07T15:16:41.370414Z",
  "updated_at": "2026-05-07T15:16:41.370414Z"
}

단건 조회 / 목록 / 패치 / 삭제

endpoint설명응답
POST /v1/nodes새 노드 생성. Content-Type: application/json 필수.201 + node JSON · 400 잘못된 body · 415 잘못된 Content-Type
GET /v1/nodes/{id}한 노드 조회. account 의 노드가 아니면 404.200 + node JSON · 400 잘못된 id · 404 없음
GET /v1/nodes?limit=N&offset=M최신순. limit 1..200 (기본 50), offset >= 0. 잘못된 값은 400.200 + {"items":[…]} · 400 잘못된 페이징
PATCH /v1/nodes/{id}부분 수정. body 에 title, body, meta 중 변경할 키만. Content-Type: application/json 필수.200 + 갱신된 node · 400 · 404 · 415
DELETE /v1/nodes/{id}soft delete. account 격리 위반은 404.204 · 400 · 404

여러 건 한 번에 — POST/v1/nodes/batch · DELETE/v1/nodes/batch

bulk insert / purge round-trip 폭발을 막기 위한 best-effort 배치. 항목별 status 가 따로 돌아오고, 한 항목 실패가 다른 항목을 막지 않습니다 — synthesis cycle commit (1 Synthesis + N SYNTHESIZES + M CITES 합산 round-trip), predatory paper purge (수백 DELETE) 같은 흐름에 최대 50–100× latency 절감.

# bulk create
curl -fsS -X POST https://store.parklab.work/v1/nodes/batch \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "nodes": [
    {"kind":"Note", "title":"a"},
    {"kind":"Note", "title":"b"},
    {"kind":"Paper","title":"c","meta":{"doi":"10.1/foo"}}
  ]}'
# 응답: { "results": [ {"status":201,"node":{...}}, {"status":201,"node":{...}}, {"status":201,"node":{...}} ] }
# 부분 실패 가능 — 전체 200, 항목별 status (201 / 400 / 413 / 500).

# bulk delete (DELETE 메소드에 body)
curl -fsS -X DELETE https://store.parklab.work/v1/nodes/batch \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["uuid-1", "uuid-2", "uuid-3"] }'
# 응답: { "results": [ {"id":"uuid-1","status":204}, {"id":"uuid-2","status":404,"error":"not found"}, ... ] }
endpoint응답
POST /v1/nodes/batch200 + per-item results (개별 status 201/400/413/500) · 400 빈 배열 · 413 1000개 초과 · 415 Content-Type
DELETE /v1/nodes/batch200 + per-item results (개별 status 204/400/404/500) · 400 빈 배열 · 413 1000개 초과 · 415 Content-Type

한 흐름 예시

# 1) 노드 만들고 id 보관
NODE_ID=$(curl -fsS -X POST https://store.parklab.work/v1/nodes \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"Concept","title":"양자 얽힘"}' | jq -r .id)

# 2) 본문 채우기
curl -fsS -X PATCH "https://store.parklab.work/v1/nodes/$NODE_ID" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"두 입자가 측정 전까지 한 시스템처럼 행동하는 현상."}'

# 3) 다시 읽기
curl -fsS "https://store.parklab.work/v1/nodes/$NODE_ID" \
  -H "Authorization: Bearer $STORE_TOKEN"

# 4) 목록
curl -fsS "https://store.parklab.work/v1/nodes?limit=10" \
  -H "Authorization: Bearer $STORE_TOKEN"

# 5) 정리
curl -fsS -X DELETE "https://store.parklab.work/v1/nodes/$NODE_ID" \
  -H "Authorization: Bearer $STORE_TOKEN"

사람이 읽는 HTML 뷰 — GET/n/{uuid}

같은 노드의 사람용 렌더링. JSON 끄집어내 본문을 비교하던 마찰을 줄이고, store.parklab.work/n/<uuid> 형식 URL 을 mindmap / gist / 외부 문서의 markdown anchor 로 직접 쓸 수 있게 합니다.

인증: capability-URL 패턴 (ADR-0015). UUID 자체가 자격증명이라 Authorization 헤더 없이 200. v4/v7 UUID 의 random bit 가 brute-force 비실용 보장. 단, GET /v1/nodes 목록·POST /v1/search 등 enumeration 은 그대로 account-gated — UUID 를 모르면 코퍼스를 스캔할 수 없습니다.

종류별 렌더링:

XSS 가드: html/template auto-escape. 본문은 <pre> 로 변환되어 raw HTML 주입 불가. children/cited 의 node_id 는 UUID 파싱을 통과한 값만 링크로 렌더.

# mindmap 의 leaf 노드 markdown:
[Paper: HeartSteps RL](https://store.parklab.work/n/019e0def-1234-...)

# 직접 호출 (브라우저 외):
curl -fsS https://store.parklab.work/n/019e0def-1234-...
# (Bearer 토큰 필요 없음)

노드를 만들거나 본문을 PATCH 할 때마다 서버가 BGE-M3(1024차원) 임베딩을 자동으로 채웁니다. 호출자는 별도 처리 불필요 — 단지 한국어/영어/다국어 자연어를 본문에 넣으면 됩니다.

임베딩 생성이 일시적으로 실패해도(서버 재시작, 모델 로드 중 등) 노드 저장은 그대로 성공합니다 — 다만 검색 결과에서는 빠집니다. 같은 노드를 다시 PATCH 하면 재시도.

의미 검색 — POST/v1/search

curl -fsS -X POST https://store.parklab.work/v1/search \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "양자 얽힘이 의식에 미치는 영향",
    "k": 5,
    "kinds": ["Note", "Concept"]
  }'

응답 (예시)

{
  "items": [
    {
      "id": "...",
      "title": "양자 얽힘과 측정",
      "body": "...",
      "kind": "Concept",
      "distance": 0.123,
      "...": "..."
    },
    { "...": "..." }
  ]
}
인자의미
query자연어 질의. 1~2048 자.
k반환 개수. 1~100, 기본 10.
kinds(선택) kind 필터. 빈 배열·미지정이면 모든 종류.
응답 distancecosine 거리 (작을수록 가까움).

임베딩 서버가 일시적으로 다운이면 503. 검색 결과는 요청한 토큰의 account 노드만 포함합니다 (다른 사람의 노드 노출 0).

06온톨로지 (advisory)

account 마다 자체 ontology (= node·edge 의 type 체계) 를 등록할 수 있습니다. 1차 라운드는 advisory 모드 — 노드/엣지 작성 흐름은 그대로이고, 등록한 ontology 와의 일치 여부는 별도 호출로 확인합니다. 어겨도 거부되지 않습니다.

schema 형식

{
  "name": "personal-knowledge-v1",
  "description": "내 second brain 의 분류 체계",
  "schema": {
    "node_kinds": {
      "Person":  { "props": { "name":  {"type":"string","required":true},
                              "email": {"type":"string"} } },
      "Concept": { "props": { "domain": {"type":"string"} } },
      "Note":    { "props": {} },
      "Paper":   { "props": { "doi":   {"type":"string"},
                              "year":  {"type":"number"} } }
    },
    "edge_kinds": [
      { "kind": "MENTIONS",    "from": "*",       "to": "*" },
      { "kind": "AUTHORED_BY", "from": "Paper",   "to": "Person" },
      { "kind": "CITES",       "from": "Paper",   "to": "Paper" },
      { "kind": "RELATES_TO",  "from": "Concept", "to": "Concept" }
    ]
  }
}

지원 타입(1차): string / number / boolean / array / object. required 는 boolean. props 외의 키는 자유 — 미정의 키는 위반 아님.

등록 — POST/v1/ontologies

curl -fsS -X POST https://store.parklab.work/v1/ontologies \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d @ontology.json

단건 점검 — GET/v1/nodes/{id}/conforms?ontology={id}

curl -fsS "https://store.parklab.work/v1/nodes/$NODE_ID/conforms?ontology=$ONT_ID" \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"ok": false, "issues": [{"code":"missing_required","field":"name","message":"..."}]}

전수 검사 (sweep) — GET/v1/ontologies/{id}/violations

curl -fsS "https://store.parklab.work/v1/ontologies/$ONT_ID/violations?limit=50" \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"node_violations":[…], "edge_violations":[…], "nodes_scanned":…, "edges_scanned":…}

응답 상태표

endpoint응답
POST /v1/ontologies201 + ontology · 400 schema 위반 · 409 이름 중복 · 415
GET /v1/ontologies200 {items:[…]}
GET /v1/ontologies/{id}200 + schema · 404
PATCH /v1/ontologies/{id}200 (schema 갱신 시 version+1) · 400 · 404 · 409
DELETE /v1/ontologies/{id}204 · 404 (이미 삭제 포함)
GET /v1/ontologies/{id}/violations200 + report · 400 · 404
GET /v1/nodes/{id}/conforms?ontology=…200 + {ok, issues} · 400 · 404 (node 또는 ontology 없음)
다음 라운드 예정: strict 모드 (위반 시 400 으로 reject), schema 변경 history 보존, LLM 자동 kind 분류.

07표준 vocabulary 라이브러리

store 서버는 라이선스 자유한 외부 표준 vocabulary 들을 모든 account 가 read-only 로 공유합니다 (ADR-0012). 노드/엣지와 별도로 관리되며, account 격리가 깨지지 않습니다.

현재 활성: schema_org (Schema.org core types, 55개) · mesh (NLM Medical Subject Headings, 31,110 descriptors, version 2026-05-12). icd10 / rxnorm / wordnet 은 다음 라운드. SNOMED-CT 는 사용자 업로드 도구(별도 ADR).

⚠ 의학 키워드는 MeSH 의무 사용
store 에 저장되는 모든 의학·보건·생명과학 개념(질환·증상·약물·해부·시술·검사 등)은 반드시 MeSH 코드를 함께 기록합니다. 자유 텍스트만 단독 저장 금지. 근거: MeSH 는 NLM 이 매년 갱신하는 공인 controlled vocabulary 로, PubMed 색인의 기반이며 cross-language·cross-database 매핑의 사실상 표준입니다.

vocabulary 목록 — GET/v1/vocabularies

curl -fsS https://store.parklab.work/v1/vocabularies \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"items":[
#   {"name":"mesh","title":"MeSH (Medical Subject Headings)","concept_count":31110,"version":"2026-05-12", ...},
#   {"name":"schema_org","title":"Schema.org core types","concept_count":55, ...}
# ]}

vocabulary 단건 — GET/v1/vocabularies/{name}

해당 vocabulary 의 메타 정보 (라이선스, 출처 URL, 버전, concept_count, imported_at).

curl -fsS https://store.parklab.work/v1/vocabularies/mesh \
  -H "Authorization: Bearer $STORE_TOKEN"

concept 검색 — GET/v1/vocabularies/{name}/concepts?q=&limit=&offset=&parent=

q 는 label · code · synonyms(entry terms) 모두에 trigram 부분 일치 (case-insensitive). MeSH 에서는 동의어("Auricular Fibrillation")나 브랜드명("Glucophage") 으로 검색해도 preferred term descriptor 가 회수됩니다. parent 는 직속 자식만 (트리 탐색).

# MeSH 검색 — preferred term
curl -fsS "https://store.parklab.work/v1/vocabularies/mesh/concepts?q=Atrial%20Fibrillation&limit=3" \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"items":[{"code":"D001281","label":"Atrial Fibrillation",
#            "parent_codes":["D001145"],
#            "synonyms":["Auricular Fibrillation","Persistent Atrial Fibrillation", ...],
#            "meta":{"tree_numbers":["C14.280.067.198","C23.550.073.198"]}, ...}]}

# Entry term 으로 — "Auricular Fibrillation" → 같은 D001281 회수
curl -fsS "https://store.parklab.work/v1/vocabularies/mesh/concepts?q=Auricular%20Fibrillation" \
  -H "Authorization: Bearer $STORE_TOKEN"

# 브랜드명/화학명 — "Glucophage" → D008687 (Metformin) 회수
curl -fsS "https://store.parklab.work/v1/vocabularies/mesh/concepts?q=Glucophage" \
  -H "Authorization: Bearer $STORE_TOKEN"

# 트리 — Arrhythmias, Cardiac (D001145) 의 직속 자식 descriptor 들
curl -fsS "https://store.parklab.work/v1/vocabularies/mesh/concepts?parent=D001145" \
  -H "Authorization: Bearer $STORE_TOKEN"

concept 단건 — GET/v1/vocabularies/{name}/concepts/{code}

응답에 parent_codes(부모 UI 배열, DAG) + child_codes(직속 자식 UI 배열) 가 포함됩니다. MeSH 는 meta.tree_numbers 에 전체 tree number 배열을 보존합니다.

curl -fsS "https://store.parklab.work/v1/vocabularies/mesh/concepts/D008687" \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"code":"D008687","label":"Metformin",
#  "description":"A biguanide hypoglycemic agent ...",
#  "parent_codes":["D001645"],
#  "child_codes":["D000068899"],
#  "meta":{"tree_numbers":["D02.078.370.141.450"]}}

MeSH 사용 워크플로

의학 개념을 store 에 올리기 전 권장 절차:

  1. 매핑 검색: GET /v1/vocabularies/mesh/concepts?q=<영문 키워드> 로 preferred term 매칭. 한국어 입력은 한 차례 영문 번역 후 검색 (예: "심방세동" → "Atrial Fibrillation")
  2. 매칭 검증: 응답 단건이 의도한 개념인지 확인. 첫 결과가 동의어·관련 약물(예: metformin 검색 시 Phenformin 류)일 수 있으니 description 까지 확인
  3. 특이성 우선: 가장 구체적인 descriptor 선택. 예: D001281 (Atrial Fibrillation) > D001145 (Arrhythmias, Cardiac)
  4. 노드 작성: meta.refsvocab:mesh:<UI>, 표시 라벨은 title 또는 meta.local_label
# 노드 생성 예 — 한국어 표시 + MeSH UI 필수
curl -fsS -X POST https://store.parklab.work/v1/nodes \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "Concept",
    "title": "심방세동",
    "body": "심방의 무질서한 전기 활동으로 인한 부정맥.",
    "meta": {
      "refs": ["vocab:mesh:D001281"],
      "local_label": {"ko": "심방세동", "en": "Atrial Fibrillation"}
    }
  }'

# 여러 MeSH 조합 — 단일 descriptor 가 없을 때
{
  "kind": "Concept",
  "title": "수술 후 심방세동",
  "meta": {
    "refs": ["vocab:mesh:D001281", "vocab:mesh:D011183"],
    "mesh_combination": true
  }
}

응답·에러 요약

요청주요 응답
GET /v1/vocabularies200 + items · 401
GET /v1/vocabularies/{name}200 + 단건 · 401 · 404
GET /v1/vocabularies/{name}/concepts200 + items · 400 (limit/offset) · 401 · 404 (vocab)
GET /v1/vocabularies/{name}/concepts/{code}200 + 단건 + child_codes · 401 · 404
POST/PATCH/DELETE /v1/vocabularies*405 — read-only. 운영자 CLI 로만 import
다음 라운드 예정: ICD-10 / RxNorm / WordNet 운영 환경 import, MeSH supplementary concepts(SCR, C######) 보강, BGE-M3 기반 semantic lookup 엔드포인트(POST /v1/vocabularies/{name}/concepts/semantic — 한국어 직접 매핑 등). SNOMED-CT 는 사용자 업로드 도구로 별도.

08페이지네이션 (cursor + sticky offset)

두 가지 페이지네이션 모드를 지원합니다. 둘 다 동시 insert 환경에서 안정적입니다.

cursor 모드 (권장)

?after=<cursor>&limit=N 으로 호출. 첫 응답의 next 필드를 다음 호출에 그대로 전달.

# 첫 페이지
curl -fsS "https://store.parklab.work/v1/nodes?limit=10" \
  -H "Authorization: Bearer $STORE_TOKEN"
# 응답: { "items": [...], "next": "2026-05-09T12:34:56.789012Z_8ca37158-..." }

# 다음 페이지
curl -fsS "https://store.parklab.work/v1/nodes?limit=10&after=$NEXT" \
  -H "Authorization: Bearer $STORE_TOKEN"
# next 가 없으면 마지막 페이지

cursor 는 (created_at, id) 튜플의 RFC3339Nano 표현입니다. 다른 클라이언트가 새 노드를 끼워 넣어도 페이지가 절대 겹치거나 빠지지 않습니다.

offset 모드 (sticky snapshot)

?offset=N&limit=M. 첫 호출 시 서버가 자동으로 시점을 정해 응답에 as_of 필드 + Set-Cookie: store_list_snapshot 을 같이 보냅니다. 같은 시리즈의 후속 호출은 cookie 또는 query 의 as_of 를 재사용해 같은 시점의 view 만 보여줍니다 — 동시 insert 가 들어와도 페이지가 안정적입니다.

curl -fsS "https://store.parklab.work/v1/nodes?limit=10&offset=0" \
  -H "Authorization: Bearer $STORE_TOKEN"
# 응답: { "items": [...], "as_of": "2026-05-09T...Z" }
# Set-Cookie: store_list_snapshot=2026-05-09T...Z; Max-Age=300; Path=/v1/nodes

# 페이지 2: cookie 가 자동으로 같은 as_of 를 전달해 stable
curl -fsS "https://store.parklab.work/v1/nodes?limit=10&offset=10" \
  -b "store_list_snapshot=<…>" \
  -H "Authorization: Bearer $STORE_TOKEN"

cookie 가 없는 클라이언트는 query 로 직접: ?as_of=2026-05-09T12:34:56.789012Z&offset=10&limit=10. cookie 의 max-age 는 5분.

09낙관적 동시성 (ETag / If-Match)

GET /v1/nodes/{id} 응답마다 약한 ETag 가 붙습니다 (W/"<unix_ns>-<uuid>"). 노드 수정 시 그 값을 If-Match 헤더로 다시 보내면, 그 사이 누군가 같은 노드를 변경했을 때 412 Precondition Failed 가 돌아옵니다 (lost-update 회피).

r=$(curl -fsS -i "https://store.parklab.work/v1/nodes/$NODE_ID" \
       -H "Authorization: Bearer $STORE_TOKEN")
ETAG=$(echo "$r" | awk '/^etag:/{print $2}' | tr -d '\r')

curl -fsS -X PATCH "https://store.parklab.work/v1/nodes/$NODE_ID" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: $ETAG" \
  -d '{"title":"safe edit"}'
# 412 가 돌아오면 누군가 동시 수정. GET → 새 ETag → 재시도.

If-Match 미지정 시 PATCH 는 무조건 last-write-wins 입니다 (기존 동작 호환).

노드 본문 안에서 다른 노드를 가리키려면 Obsidian/Roam 호환 wikilink 문법을 씁니다.

노드를 만들거나 본문을 패치할 때, 서버가 본문에서 wikilink 를 추출해 kind: "MENTIONS" 엣지를 자동으로 만듭니다.

11엣지 API

단건 생성 — POST/v1/edges

curl -fsS -X POST https://store.parklab.work/v1/edges \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "<node uuid>",
    "to":   "<node uuid>",
    "kind": "RELATES_TO",
    "meta": { "weight": 0.8 }
  }'

여러 건 한 번에 — POST/v1/edges/batch

curl -fsS -X POST https://store.parklab.work/v1/edges/batch \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "edges": [
    {"from": "...", "to": "...", "kind": "DEPENDS_ON"},
    {"from": "...", "to": "...", "kind": "REFERENCES"}
  ]}'
# 응답: { "results": [ {"status":201, "edge":{...}}, {"status":409, "error":"..."} ] }
# 부분 실패 가능. 전체 200, 항목별 status.

노드의 엣지 — GET/v1/nodes/{id}/edges

curl -fsS "https://store.parklab.work/v1/nodes/$NODE_ID/edges" \
  -H "Authorization: Bearer $STORE_TOKEN"
# 응답: { "outgoing": [...], "incoming": [...], "as_of": "..." }

응답 상태표

endpoint응답
POST /v1/edges201 + edge JSON · 400 잘못된 입력 / cross-account · 409 중복 · 415 Content-Type
POST /v1/edges/batch200 + per-item results (개별 status 201/400/409) · 400 빈 배열 · 413 1000개 초과
GET /v1/edges/{id}200 + edge JSON · 400 · 404
DELETE /v1/edges/{id}204 · 400 · 404 (이미 삭제·다른 account 포함)
GET /v1/nodes/{id}/edges200 + outgoing/incoming · 400 · 404 (노드 없거나 다른 account)

12account 격리

account A 의 토큰으로 account B 의 노드 id 에 접근하면 무조건 404 가 돌아옵니다 — 존재 자체를 알려주지 않습니다. 격리는 두 층에서 강제됩니다.

  1. application 레이어: 모든 SQL 에 WHERE account_id = $auth.account_id 가 들어갑니다.
  2. 그래프 레이어: account 마다 자체 AGE 그래프(acct_<uuid_hex>)가 있고, cypher 호출자는 graph 이름을 직접 넘길 수 없습니다.

새로 발급받은 토큰으로 처음 호출하면 GET /v1/nodes{"items":[]} 입니다 — 다른 사용자의 노드는 절대 새 시야에 들어오지 않습니다.

13관리자 엔드포인트 (선택 — self-signup 으로 충분)

다음은 ADMIN_TOKEN 을 가진 관리자에게만 의미가 있습니다. /v1/admin/* 경로는 admin Bearer 가 필요합니다.

account / token 목록 조회

운영자가 정리 정책에 의해 곧 사라질 후보를 미리 점검할 수 있습니다.

# 모든 활성 account: id, 이름, created_at, last_accessed_at, node_count
curl -fsS https://store.parklab.work/v1/admin/accounts \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# 특정 account 의 모든 token (활성 + 회수): prefix, last_used_at, revoked_at
curl -fsS "https://store.parklab.work/v1/admin/accounts/$ACCOUNT_ID/tokens" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

node_count == 0 + created_at < now - 24h 인 account 는 다음 hourly 배치에 삭제됩니다. last_accessed_at < now - 30d 도 마찬가지입니다.

account 발급

curl -fsS -X POST https://store.parklab.work/v1/admin/accounts \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "alice"}'

응답(201)에는 idgraph_name(자동 derived: acct_<uuid_hex>) 이 들어 있습니다. 같은 이름이 이미 활성 상태면 409 Conflict 가 돌아옵니다.

토큰 발급

curl -fsS -X POST https://store.parklab.work/v1/admin/accounts/$ACCOUNT_ID/tokens \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "alice-laptop"}'

응답(201)의 token 필드가 사용자에게 한 번만 노출되는 plaintext 입니다. prefix (앞 12자) 는 안전하게 로그/감사에 적을 수 있는 식별자로 같이 돌아옵니다. 즉시 사용자에게 안전 채널로 전달.

토큰 회수 / account 삭제

curl -fsS -X DELETE https://store.parklab.work/v1/admin/tokens/$TOKEN_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"

curl -fsS -X DELETE https://store.parklab.work/v1/admin/accounts/$ACCOUNT_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"

둘 다 성공 시 204. 모르는 id 는 404. account 삭제는 그 account 의 노드와 AGE 그래프, 발급된 모든 토큰을 한 트랜잭션에서 함께 제거합니다 (cascade revoke).

관리자 응답 상태표

endpoint응답
POST /v1/admin/accounts201 · 400 잘못된 body · 409 이름 중복 · 415 Content-Type
DELETE /v1/admin/accounts/{id}204 · 400 잘못된 id · 404 없음
POST /v1/admin/accounts/{id}/tokens201 · 400 · 404 account 없음 · 415
DELETE /v1/admin/tokens/{id}204 · 400 · 404

모든 admin 엔드포인트는 Authorization: Bearer $ADMIN_TOKEN 미일치 시 401.

14헬스체크 & 시스템 버전

endpoint의미인증
GET /healthz프로세스 살아 있음.없음
GET /readyzDB 핑 성공 시 200, 실패 시 503.없음
GET /v1/version시스템 버전 메타정보 (semver, commit SHA, schema 버전, 프로세스 시작 시각).없음

시스템 버전 — GET/v1/version

Bearer 토큰 없이 호출 가능한 공개 엔드포인트입니다. 자율 루프가 새 기능을 배포할 때마다 api_version(semver)을 bump하므로, 외부 클라이언트는 이 엔드포인트로 현재 능력을 확인할 수 있습니다.

curl -fsS https://store.parklab.work/v1/version
# {"api_version":"0.1.0","commit":"abc123...","built_at":"2026-05-25T07:30:00Z","schema_version":17,"started_at":"2026-05-25T07:31:12Z"}

필드 설명:

필드설명
api_versionsemver 문자열 — CHANGELOG.md의 최신 항목과 일치
commit빌드된 Git commit SHA (ldflags 미주입 시 "dev")
built_atDocker 빌드 시각 ISO 8601 (ldflags 미주입 시 "unknown")
schema_version현재 적용된 DB 마이그레이션 번호
schema_dirty마이그레이션이 dirty 상태일 때만 true로 노출 (정상 시 필드 생략)
started_atapi 프로세스 시작 시각 ISO 8601

15메일 수신 (mail ingest)

store 는 <alias>@store.parklab.work 형식의 이메일을 영구 저장합니다. Cloudflare Email Routing 이 SMTP 수신·SPF·DKIM·DMARC 검증을 처리한 뒤 webhook 으로 store 에 전달하는 구조입니다. store 는 메일을 받아 저장하는 역할만 하며, 본문 파싱 결과(parts 배열, attachments 메타)는 제공하지만 링크 추출·요약·임베딩·분류는 일절 수행하지 않습니다 (헌법 §3). 처리는 REST API 로 메일을 가져가는 외부 에이전트의 몫입니다.

메일당 크기 한도는 25 MB (Cloudflare 제약)입니다. alias 가 등록되지 않은 주소로 오는 메일은 조용히 폐기됩니다 (bounce 없음, alias 존재 누설 방지).

store 는 받아 저장만 합니다. 본문 처리·요약·임베딩은 외부 에이전트의 일입니다. 에이전트는 GET /v1/messages 로 polling 하고, 단건 조회 후 직접 파싱합니다.

alias 발급 — POST/v1/aliases

alias name 은 RFC 5321 local-part 안전 부분집합 ^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$, 1..64자입니다. 예약어 postmaster·abuse·admin·noreply·dmarc 는 거부됩니다. 활성 alias 는 도메인 전역으로 고유합니다 — 다른 account 가 같은 이름을 이미 보유하면 409 로 응답합니다.

curl -fsS -X POST https://store.parklab.work/v1/aliases \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"newsletter"}'
# 201
# {"id":"019e...","name":"newsletter","created_at":"2026-05-24T09:00:00Z"}

발급 직후부터 newsletter@store.parklab.work 로 오는 메일이 자동으로 이 account 에 적재됩니다.

alias 목록·폐기 — GET/v1/aliases · DELETE/v1/aliases/{name}

GET 은 내 account 의 활성 alias 배열을 반환합니다. DELETE 는 soft delete 로, alias row 는 비활성 처리되고 기존 메일 이력은 보존됩니다. 이후 같은 이름으로 오는 신규 메일은 폐기됩니다. 폐기 후 같은 이름 재발급은 가능합니다.

# 내 alias 목록
curl -fsS https://store.parklab.work/v1/aliases \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"items":[{"id":"019e...","name":"newsletter","created_at":"..."}]}

# alias 폐기 (이름으로)
curl -fsS -X DELETE https://store.parklab.work/v1/aliases/newsletter \
  -H "Authorization: Bearer $STORE_TOKEN"
# 204

메일 목록 — GET/v1/messages

목록 조회는 read_at 을 자동으로 마킹하지 않습니다. polling 에 적합합니다.

파라미터설명
alias특정 alias 만 (반복 가능, OR)
label라벨 이름 (반복 가능 — AND, 모두 가진 메일만)
unreadtrue / false
since, untilRFC3339 (received_at 기준)
from발신자 주소 ILIKE 부분 일치
q제목(subject) ILIKE 부분 일치
cursor페이지네이션 — 응답의 next_cursor 를 그대로 전달
limit1..200, 기본 50
# 미처리 메일만 (최신 20건)
curl -fsS "https://store.parklab.work/v1/messages?unread=true&limit=20" \
  -H "Authorization: Bearer $STORE_TOKEN"

# 라벨 + 기간 필터
curl -fsS "https://store.parklab.work/v1/messages?label=finance&since=2026-05-01T00:00:00Z&until=2026-05-31T23:59:59Z" \
  -H "Authorization: Bearer $STORE_TOKEN"
# {"items":[...], "next_cursor":"eyJ..."}

단건 조회 (자동 read 마킹) — GET/v1/messages/{id}

200 응답 시 read_at = coalesce(read_at, now()) 가 자동 설정됩니다. 이미 읽은 메일은 시각이 바뀌지 않습니다 (idempotent). ?mark_read=false 로 opt-out 가능 (엿보기용).

응답에는 id·alias_id·received_at·sent_at·subject·from·from_name·to_raw·read_at·verdicts·labels·headers (raw jsonb)·parts (text/* 본문 인라인)·attachments (메타만, blob 은 별도 엔드포인트) 가 포함됩니다.

headers["Authentication-Results"] 한 줄에 Cloudflare 가 검증한 SPF/DKIM/DMARC 원문이 있습니다. (verdicts 객체는 현재 비어 있습니다 — Cloudflare 가 개별 필드가 아니라 Authentication-Results 헤더 한 줄에 묶어 전달하기 때문입니다.)

curl -fsS "https://store.parklab.work/v1/messages/019f..." \
  -H "Authorization: Bearer $STORE_TOKEN"
# {
#   "id": "019f...",
#   "alias_id": "019e...",
#   "received_at": "2026-05-24T09:05:00Z",
#   "subject": "May newsletter",
#   "from": "sender@example.com",
#   "from_name": "Example Sender",
#   "to_raw": "newsletter@store.parklab.work",
#   "read_at": "2026-05-24T09:10:00Z",
#   "verdicts": {},
#   "headers": {"Authentication-Results": "smtp.parklab.work; dkim=pass ..."},
#   "parts": [{"ordinal":1,"content_type":"text/plain","body":"..."}],
#   "attachments": [],
#   "labels": []
# }

raw eml 다운로드 — GET/v1/messages/{id}/raw

응답 Content-Type: message/rfc822, 본문은 raw 바이트입니다. 200 응답 시 단건 조회와 동일하게 read 자동 마킹됩니다 (?mark_read=false opt-out 가능). mu·mblaze 등 외부 MUA 도구로 직접 파싱하거나, 파싱 실패로 parts 가 비어있는 메일을 재파싱할 때 사용합니다.

curl -fsS "https://store.parklab.work/v1/messages/019f.../raw" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -o message.eml

첨부 다운로드 — GET/v1/messages/{id}/attachments/{ordinal}

ordinal 은 MIME tree depth-first 순서이며, 단건 조회 응답의 attachments[].ordinal 로 얻습니다. 응답 헤더는 Content-Type: <원본 type>, Content-Disposition: attachment; filename="..." 입니다. 200 응답 시 read 자동 마킹됩니다.

curl -fsS "https://store.parklab.work/v1/messages/019f.../attachments/2" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -o attachment.pdf

read 수동 토글 — POST/v1/messages/{id}/read · DELETE/v1/messages/{id}/read

목록만 보고 "이 메일들은 처리 불필요"로 일괄 표시하거나, 재처리를 위해 안 읽음으로 되돌릴 때 사용합니다. 둘 다 응답 204.

# 읽음 표시 (idempotent — 이미 읽은 메일은 시각 안 바뀜)
curl -fsS -X POST "https://store.parklab.work/v1/messages/019f.../read" \
  -H "Authorization: Bearer $STORE_TOKEN"

# 안 읽음으로 되돌리기 (재처리 필요 시)
curl -fsS -X DELETE "https://store.parklab.work/v1/messages/019f.../read" \
  -H "Authorization: Bearer $STORE_TOKEN"

삭제 — DELETE/v1/messages/{id}

영구 삭제입니다. parts·attachments·라벨 부착 모두 CASCADE 됩니다. 휴지통은 없습니다. 응답 204.

curl -fsS -X DELETE "https://store.parklab.work/v1/messages/019f..." \
  -H "Authorization: Bearer $STORE_TOKEN"

라벨 — /v1/labels · /v1/messages/{id}/labels

라벨은 account-scoped 자유 태그입니다. 에이전트가 처리 상태(processed:done, processed:failed), 분류(finance, 읽을것), 출처(sender:nature) 등으로 자유롭게 부착합니다.

엔드포인트동작
POST /v1/labels{"name","color?"} → 라벨 생성. 이름은 case-insensitive 고유. 응답 201.
GET /v1/labels내 account 의 라벨 목록
PATCH /v1/labels/{id}이름·색 변경
DELETE /v1/labels/{id}라벨 삭제 (message_labels CASCADE, 메일 보존). 응답 204.
POST /v1/messages/{id}/labels{"label_id"} 또는 {"name"} — name 지정 시 라벨 없으면 즉시 생성 후 부착. 응답 201.
DELETE /v1/messages/{id}/labels/{label_id}부착 해제. 응답 204.
# 라벨 생성
curl -fsS -X POST https://store.parklab.work/v1/labels \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"processed:done","color":"#2b5d3a"}'
# 201  {"id":"019e...","name":"processed:done","color":"#2b5d3a"}

# 메일에 name 으로 부착 (라벨 없으면 자동 생성)
curl -fsS -X POST "https://store.parklab.work/v1/messages/019f.../labels" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"processed:done"}'

# 라벨 필터링
curl -fsS "https://store.parklab.work/v1/messages?label=processed%3Adone&label=finance" \
  -H "Authorization: Bearer $STORE_TOKEN"

워크플로 — 에이전트가 메일을 polling 하는 방법

  1. 미처리 메일 목록 가져오기: GET /v1/messages?unread=true&limit=20 — read 마킹 안 됩니다.
  2. 각 메일에 대해 단건 조회: GET /v1/messages/{id} — 200 이면 read 자동 마킹됩니다.
  3. 응답의 partsattachments 를 직접 파싱·처리합니다.
  4. 후속 작업 성공 시: POST /v1/messages/{id}/labels {"name":"processed:done"}, 실패 시 {"name":"processed:failed"}.
  5. 재처리가 필요하면: DELETE /v1/messages/{id}/read 로 unread 복귀 → 다음 polling 회차에서 재시도.
store 는 처리 상태(processed)를 별도 컬럼으로 관리하지 않습니다. 라벨로 표현하세요. read_at 은 "본문을 가져갔는지"의 신호이고, 라벨이 "의도적으로 어떤 상태로 분류했는지"의 신호입니다.

한계와 동작 규칙

16기능 제안 (feature suggestions)

/v1/suggestions모든 account 가 공유하는 공개 게시판입니다. 노드·엣지처럼 account 단위로 격리되지 않고, 누구나 읽을 수 있는 cross-account 도메인입니다 (ADR-0013). 사용 의도: 누군가 "이런 기능 어때?" 라고 제안하면 운영자가 보안·다양한 관점·실용성·철학적 정합성 (헌법 §3 비목표 등) 측면에서 검토합니다. 채택이 보장되지 않으며, 사용자가 진짜로 필요하다고 판단될 때 구현합니다.

읽기는 Bearer 불필요 (공개), 쓰기 (제안 작성·댓글·투표·삭제)는 Bearer 필요 (저자 식별 + abuse 추적).

제안한다고 자동 구현되지 않습니다. 보안·관점·실용성·철학적 정합성을 검토한 후 채택 여부를 결정합니다. 사용자가 진짜 필요로 하면 구현합니다.

16.1 제안 작성 — POST/v1/suggestions

인증 필요. Content-Type: application/json.

필드설명
title제안 제목. 1..200자. 필수.
body본문. 1..8000자. 필수.

응답 201:

{
  "id": "019f...",
  "author": "acc_a1b2c3d4",
  "title": "단건 노드 export (JSON-LD) 지원",
  "body": "...",
  "upvote_count": 0,
  "downvote_count": 0,
  "comment_count": 0,
  "created_at": "2026-05-25T00:00:00Z"
}

에러: 400 (title·body 길이 위반), 401, 415 (Content-Type 미설정).

curl -fsS -X POST https://store.parklab.work/v1/suggestions \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "단건 노드 export (JSON-LD) 지원",
    "body": "동기: 외부 도구와 연동할 때 노드 하나를 JSON-LD 형식으로 가져올 수 없어 수작업 변환이 필요합니다.\n사용 시나리오: GET /v1/nodes/{id}?format=jsonld 로 호출 시 @context 포함 JSON-LD 응답.\n고려사항: 헌법 §3 비목표에 \"범용 export 파이프라인\"이 언급되지 않으므로 충돌 없음."
  }'
좋은 제안의 본문 구성: 동기 (왜 필요한가) + 사용 시나리오 (구체 예시 1-2개) + 고려사항 (보안·격리·헌법 §3 비목표 등 관련 있으면 언급).

16.2 제안 목록 — GET/v1/suggestions

공개 (Bearer 없이도 200). hidden 된 제안은 응답에 포함되지 않습니다 (16.8 참조). soft-deleted 제안은 body 가 [deleted] 로 치환된 채 시간순 보존됩니다 (피드 연속성).

쿼리 파라미터설명
cursor페이지네이션 — 응답의 next_cursor 를 그대로 전달
limit1..100, 기본 50
curl -fsS "https://store.parklab.work/v1/suggestions?limit=20"
# {
#   "items": [
#     {
#       "id": "019f...", "author": "acc_a1b2c3d4",
#       "title": "단건 노드 export (JSON-LD) 지원",
#       "body": "...", "upvote_count": 3, "downvote_count": 0,
#       "comment_count": 1, "created_at": "2026-05-25T00:00:00Z"
#     }
#   ],
#   "next_cursor": "eyJ..."
# }

16.3 피드 — GET/v1/suggestions/feed

제안과 댓글을 시간순으로 섞어 반환하는 활동 타임라인입니다. 공개. hidden 된 제안과 그 댓글은 피드에서 제외됩니다. deleted 는 body 가 [deleted] 로 치환된 채 보존됩니다.

응답: {"events": [...], "next_cursor": "..."}. 각 이벤트의 type"suggestion" 또는 "comment".

curl -fsS "https://store.parklab.work/v1/suggestions/feed?limit=30"
# {
#   "events": [
#     {
#       "type": "comment", "id": "019f...",
#       "suggestion_id": "019e...",
#       "author": "acc_b5c6d7e8",
#       "body": "이 아이디어 좋네요. 엣지 타입 필터도 함께면 더 좋겠습니다.",
#       "created_at": "2026-05-25T01:00:00Z"
#     },
#     {
#       "type": "suggestion", "id": "019e...",
#       "title": "단건 노드 export (JSON-LD) 지원",
#       "author": "acc_a1b2c3d4",
#       "body": "...", "upvote_count": 3, "downvote_count": 0,
#       "comment_count": 1, "created_at": "2026-05-25T00:00:00Z"
#     }
#   ],
#   "next_cursor": "eyJ..."
# }

16.4 단건 조회 — GET/v1/suggestions/{id}

공개. 응답에 댓글 배열(comments)이 포함됩니다 (오래된 순). hidden 된 제안은 404. deleted 댓글은 body 가 [deleted] 로 치환된 채 포함됩니다.

curl -fsS "https://store.parklab.work/v1/suggestions/019e..."
# {
#   "id": "019e...", "author": "acc_a1b2c3d4",
#   "title": "단건 노드 export (JSON-LD) 지원",
#   "body": "...", "upvote_count": 3, "downvote_count": 0,
#   "comment_count": 1, "created_at": "2026-05-25T00:00:00Z",
#   "comments": [
#     {
#       "id": "019f...", "author": "acc_b5c6d7e8",
#       "body": "이 아이디어 좋네요.",
#       "created_at": "2026-05-25T01:00:00Z"
#     }
#   ]
# }

16.5 제안 삭제 — DELETE/v1/suggestions/{id}

인증 필요. 본인 제안만 삭제 가능합니다. 다른 사람 제안 삭제 시도 → 403. soft delete 이므로 피드에서는 body 가 [deleted] 로 치환된 채 시간순 보존됩니다. 응답 204.

curl -fsS -X DELETE "https://store.parklab.work/v1/suggestions/019e..." \
  -H "Authorization: Bearer $STORE_TOKEN"

16.6 댓글 — POST/v1/suggestions/{id}/comments · DELETE/v1/suggestions/{id}/comments/{cid}

작성: 인증 필요. body {"body": "..."} (1..4000자) → 201. hidden 또는 deleted 된 제안에는 댓글 작성 불가 (404). 삭제: 본인 댓글만, soft delete, 응답 204.

# 댓글 작성
curl -fsS -X POST "https://store.parklab.work/v1/suggestions/019e.../comments" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "JSON-LD 외에 Turtle 형식도 함께 지원하면 어떨까요?"}'
# 201  {"id":"019f...","author":"acc_b5c6d7e8","body":"...","created_at":"..."}

# 댓글 삭제 (본인 것만)
curl -fsS -X DELETE "https://store.parklab.work/v1/suggestions/019e.../comments/019f..." \
  -H "Authorization: Bearer $STORE_TOKEN"
# 204

16.7 투표 — POST/v1/suggestions/{id}/vote · DELETE/v1/suggestions/{id}/vote

인증 필요. 한 account 는 한 제안에 한 표만. kind 를 바꿔 다시 POST 하면 표가 flip 됩니다 (idempotent). 자기 제안에도 투표 가능. hidden 또는 deleted 된 제안 → 404.

메서드설명
POSTbody {"kind": "up"} 또는 {"kind": "down"} → 200 + 현재 집계
DELETE본인 표 취소 → 200 + 현재 집계. 표가 없어도 idempotent.
# 추천 (up)
curl -fsS -X POST "https://store.parklab.work/v1/suggestions/019e.../vote" \
  -H "Authorization: Bearer $STORE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"up"}'
# 200  {"suggestion_id":"019e...","your_vote":"up","upvote_count":4,"downvote_count":0,"hidden":false}

# 표 취소
curl -fsS -X DELETE "https://store.parklab.work/v1/suggestions/019e.../vote" \
  -H "Authorization: Bearer $STORE_TOKEN"
# 200  {"suggestion_id":"019e...","your_vote":null,"upvote_count":3,"downvote_count":0,"hidden":false}
투표는 운영자 채택 판단에 참고 자료입니다. 단순 다수결이 아니라 다양성·논점 + 운영자 검토를 종합해 결정합니다.

16.8 자동 hidden 규칙

투표 집계가 변경될 때마다 다음 조건을 평가하고, 만족하면 hidden_at = now() 가 자동으로 설정됩니다.

hidden 된 제안은 목록·피드·단건 조회 모두에서 제외됩니다 (404). 의도: 명백히 부적합한 제안은 가시성에서 빠지되, DB 행 자체는 보존됩니다. 조건이 해소되면 (votes 재계산 후 비율이 내려가면) hidden_at 이 자동으로 NULL 로 돌아옵니다. 운영자 수동 unhide 는 별도 admin 경로 (현재는 미구현, 필요 시 추가).

16.9 저자 / account 삭제 처리

저자 account 가 삭제되면 해당 저자의 제안·댓글은 soft-delete 됩니다 (피드에서 body 가 [deleted] 로 치환되어 시간순 보존). 해당 저자의 투표는 모두 삭제되고 영향받은 제안들의 카운트가 재계산됩니다. 과거 행에서 author handle 은 acc_deleted 로 표시됩니다 (privacy 보호 + audit trail 보존).

16.10 워크플로 — 좋은 제안을 올리는 법

  1. 비슷한 제안이 이미 있나 확인: GET /v1/suggestions 로 목록 조회 (next_cursor 로 페이지).
  2. 새 제안 작성: POST /v1/suggestions. title 은 한 문장 요약, body 에는 다음을 포함합니다.
    • 동기: 이 기능이 왜 필요한가.
    • 사용 시나리오: 구체 예시 1-2개.
    • 고려사항: 보안·격리·헌법 §3 비목표 등에 영향이 있다면 언급.
  3. 댓글로 논점 추가 후 운영자 검토 응답을 기다립니다.
  4. 운영자가 채택 → 별도 ADR + 구현.
  5. 운영자가 거부 → 사유와 함께 댓글 또는 close.
에이전트가 사용자 대신 제안을 올릴 수도 있습니다 (사용자 토큰으로 인증). 단, 사용자가 본인 의도로 인지하고 있어야 합니다.

16.11 응답 상태표

엔드포인트200201204400401403404415
POST /v1/suggestions생성됨길이 위반미인증Content-Type 오류
GET /v1/suggestions목록cursor 오류
GET /v1/suggestions/feed피드cursor 오류
GET /v1/suggestions/{id}단건+댓글없음·hidden
DELETE /v1/suggestions/{id}삭제됨미인증타인 제안없음·이미삭제
POST /v1/suggestions/{id}/comments생성됨길이 위반미인증없음·hidden·deletedContent-Type 오류
DELETE /v1/suggestions/{id}/comments/{cid}삭제됨미인증타인 댓글없음·이미삭제
POST /v1/suggestions/{id}/vote현재 집계kind 오류미인증없음·hidden·deletedContent-Type 오류
DELETE /v1/suggestions/{id}/vote현재 집계미인증없음·hidden·deleted

17현재 한계와 다음

식별자 정책 (참고)