store
다수 사람·LLM 에이전트가 공유하는 노트·개념·관계 그래프 백엔드. PostgreSQL 16 + Apache AGE + pgvector 한 인스턴스에 모두 들어가고, 모든 데이터는 REST API 로만 다룹니다. account 단위로 그래프가 분리되어 다른 사람의 데이터에 절대 접근할 수 없습니다.
01무엇을 하는가
store 는 LLM 이나 사람이 만든 노트·개념·아이디어를 영구 저장하고, 노드 사이의 관계를 그래프로 연결하는 두뇌 보조 서비스입니다. 단일 도메인 https://store.parklab.work 에서 REST API 로 동작하며, 다음을 약속합니다.
- account 격리: 발급된 service account 토큰은 자기 계정의 노드와 그래프에만 접근할 수 있습니다.
- 그래프 + 의미 임베딩: 노드는 관계형 row 와 Apache AGE 그래프 vertex 가 동시에 만들어지며, 이후 단계에서 BGE-M3 임베딩(1024차원)이 자동으로 채워집니다.
- 운영 단순성: 서비스 사용자는 토큰 하나만 들고 다니면 되고, 관리자는 admin 토큰으로 service account 를 발급/회수 합니다.
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"
}
}
/v1/signup으로 만든 계정에 노드를 한 개도 만들지 않은 채 24시간 (24 hours) 이 지나면 그 계정은 삭제됩니다.- 어떤 토큰으로도 30일 (30 days) 동안 한 번도 접근(insert · update · delete · select 모두 포함)되지 않은 계정도 같은 hourly 배치에 의해 삭제됩니다.
한 줄 사용 예 (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 보유자)에게 다음을 요청합니다.
- 본인용 service account 생성
- 해당 account 에 발급한 Bearer 토큰 전달
관리자는 관리자 엔드포인트 절차로 발급해 줍니다. 받은 토큰은 한 번만 화면에 보이는 plaintext 이므로 즉시 안전한 곳에 보관하세요.
토큰 사용
export STORE_TOKEN='<발급받은_토큰>'
curl -fsS https://store.parklab.work/v1/nodes \
-H "Authorization: Bearer $STORE_TOKEN"
04노드 CRUD
노드 한 개는 노트·개념·엔터티 등 어떤 단위든 됩니다. kind 와 title 은 필수, 나머지는 선택입니다. 모든 노드는 호출자 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/batch | 200 + per-item results (개별 status 201/400/413/500) · 400 빈 배열 · 413 1000개 초과 · 415 Content-Type |
| DELETE /v1/nodes/batch | 200 + 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 를 모르면 코퍼스를 스캔할 수 없습니다.
종류별 렌더링:
Paper— 제목·저자·연도·venue·DOI/PMID/arXiv 외부 링크Note·Synthesis·Concept— 제목·본문(<pre>escape) + meta 강조 필드Synthesis—meta.cited_paper_node_ids의 각 UUID 를/n/<uuid>링크로 자동 변환.meta.gist_url외부 anchor.Concept·MasterIndex—meta.children(배열 of{node_id, kind, label}) 가 있으면 outline list 로 렌더 → hub 패턴 (Park Lab Compass 등) 직접 지원.- 모든 종류 —
meta.mesh_refs가 있으면 chip 으로 표시. footer 에/v1/nodes/{id}JSON 링크.
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 토큰 필요 없음)
05검색 (자동 임베딩 + 벡터)
노드를 만들거나 본문을 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 필터. 빈 배열·미지정이면 모든 종류. |
응답 distance | cosine 거리 (작을수록 가까움). |
임베딩 서버가 일시적으로 다운이면 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/ontologies | 201 + ontology · 400 schema 위반 · 409 이름 중복 · 415 |
| GET /v1/ontologies | 200 {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}/violations | 200 + 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).
store 에 저장되는 모든 의학·보건·생명과학 개념(질환·증상·약물·해부·시술·검사 등)은 반드시 MeSH 코드를 함께 기록합니다. 자유 텍스트만 단독 저장 금지.
- 노드
meta.refs에vocab:mesh:<UI>형식으로 1개 이상 포함 (예:vocab:mesh:D006973) - 한국어/영어 표시 라벨은
title또는meta.local_label에 두되, MeSH UI 가 진실의 원천 - 적합한 단일 MeSH 가 없으면 여러 개 조합으로 표현하고
meta.mesh_combination: true - MeSH 에 없는 신규/지역 개념은
meta.refs에local:<ns>:<code>로 저장 +meta.mesh_closest에 가장 가까운 MeSH UI - 다른 의학 온톨로지(ICD-10 등)는 병기는 가능, MeSH 대체 불가
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 에 올리기 전 권장 절차:
- 매핑 검색:
GET /v1/vocabularies/mesh/concepts?q=<영문 키워드>로 preferred term 매칭. 한국어 입력은 한 차례 영문 번역 후 검색 (예: "심방세동" → "Atrial Fibrillation") - 매칭 검증: 응답 단건이 의도한 개념인지 확인. 첫 결과가 동의어·관련 약물(예:
metformin검색 시 Phenformin 류)일 수 있으니 description 까지 확인 - 특이성 우선: 가장 구체적인 descriptor 선택. 예:
D001281(Atrial Fibrillation) >D001145(Arrhythmias, Cardiac) - 노드 작성:
meta.refs에vocab: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/vocabularies | 200 + items · 401 |
| GET /v1/vocabularies/{name} | 200 + 단건 · 401 · 404 |
| GET /v1/vocabularies/{name}/concepts | 200 + 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 |
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 입니다 (기존 동작 호환).
10노드 간 참조 — wikilink + 엣지
노드 본문 안에서 다른 노드를 가리키려면 Obsidian/Roam 호환 wikilink 문법을 씁니다.
[[uuid]]— 단순 참조[[uuid|보이는 이름]]— alias 옵션 (서버는 alias 무시, 표시는 클라이언트 책임)
노드를 만들거나 본문을 패치할 때, 서버가 본문에서 wikilink 를 추출해 kind: "MENTIONS" 엣지를 자동으로 만듭니다.
- 같은 account 의 활성 노드가 아닌 ID 는 무시 (다른 account 의 ID 도 누설 안 됨).
- 자기 자신을 가리키는
[[self]]는 무시. - 같은 (from, to, MENTIONS) 자동 엣지는 idempotent — 본문 안에 같은 ID 가 두 번 나와도 한 엣지.
- 본문이 PATCH 로 변경되어 wikilink 가 빠지면, 자동 MENTIONS (
meta.auto == true) 만 함께 사라집니다. 사용자가 별도로 만든 같은 (from, to, 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/edges | 201 + edge JSON · 400 잘못된 입력 / cross-account · 409 중복 · 415 Content-Type |
| POST /v1/edges/batch | 200 + 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}/edges | 200 + outgoing/incoming · 400 · 404 (노드 없거나 다른 account) |
12account 격리
account A 의 토큰으로 account B 의 노드 id 에 접근하면 무조건 404 가 돌아옵니다 — 존재 자체를 알려주지 않습니다. 격리는 두 층에서 강제됩니다.
- application 레이어: 모든 SQL 에
WHERE account_id = $auth.account_id가 들어갑니다. - 그래프 레이어: 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)에는 id 와 graph_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/accounts | 201 · 400 잘못된 body · 409 이름 중복 · 415 Content-Type |
| DELETE /v1/admin/accounts/{id} | 204 · 400 잘못된 id · 404 없음 |
| POST /v1/admin/accounts/{id}/tokens | 201 · 400 · 404 account 없음 · 415 |
| DELETE /v1/admin/tokens/{id} | 204 · 400 · 404 |
모든 admin 엔드포인트는 Authorization: Bearer $ADMIN_TOKEN 미일치 시 401.
14헬스체크 & 시스템 버전
| endpoint | 의미 | 인증 |
|---|---|---|
| GET /healthz | 프로세스 살아 있음. | 없음 |
| GET /readyz | DB 핑 성공 시 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_version | semver 문자열 — CHANGELOG.md의 최신 항목과 일치 |
| commit | 빌드된 Git commit SHA (ldflags 미주입 시 "dev") |
| built_at | Docker 빌드 시각 ISO 8601 (ldflags 미주입 시 "unknown") |
| schema_version | 현재 적용된 DB 마이그레이션 번호 |
| schema_dirty | 마이그레이션이 dirty 상태일 때만 true로 노출 (정상 시 필드 생략) |
| started_at | api 프로세스 시작 시각 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 존재 누설 방지).
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, 모두 가진 메일만) |
unread | true / false |
since, until | RFC3339 (received_at 기준) |
from | 발신자 주소 ILIKE 부분 일치 |
q | 제목(subject) ILIKE 부분 일치 |
cursor | 페이지네이션 — 응답의 next_cursor 를 그대로 전달 |
limit | 1..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 하는 방법
- 미처리 메일 목록 가져오기:
GET /v1/messages?unread=true&limit=20— read 마킹 안 됩니다. - 각 메일에 대해 단건 조회:
GET /v1/messages/{id}— 200 이면 read 자동 마킹됩니다. - 응답의
parts와attachments를 직접 파싱·처리합니다. - 후속 작업 성공 시:
POST /v1/messages/{id}/labels {"name":"processed:done"}, 실패 시{"name":"processed:failed"}. - 재처리가 필요하면:
DELETE /v1/messages/{id}/read로 unread 복귀 → 다음 polling 회차에서 재시도.
read_at 은 "본문을 가져갔는지"의 신호이고, 라벨이 "의도적으로 어떤 상태로 분류했는지"의 신호입니다.
한계와 동작 규칙
- 25 MB 한도 (Cloudflare). 초과 시 발신측에 bounce.
- 미등록 alias 로 오는 메일은 조용히 폐기됩니다 — bounce 없음, alias 존재 누설 방지.
- 중복 ingest 허용: 동일
message_id_header로 여러 번 전달돼도 모두 저장됩니다 (Cloudflare retry·forwarding 시뮬레이션 대응). dedup 은 외부 에이전트가 처리합니다. - outbound 없음: store 는 수신 전용입니다. 발신 기능은 없습니다.
- real-time push 없음: webhook·WebSocket 은 지원하지 않습니다. polling 으로만 사용합니다.
- 파싱 실패 시: raw_eml 과 헤더만 저장하고 parts·attachments 를 비웁니다. 에이전트가
/raw엔드포인트로 직접 받아 파싱할 수 있습니다.
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 파이프라인\"이 언급되지 않으므로 충돌 없음."
}'
16.2 제안 목록 — GET/v1/suggestions
공개 (Bearer 없이도 200). hidden 된 제안은 응답에 포함되지 않습니다 (16.8 참조). soft-deleted 제안은 body 가 [deleted] 로 치환된 채 시간순 보존됩니다 (피드 연속성).
| 쿼리 파라미터 | 설명 |
|---|---|
cursor | 페이지네이션 — 응답의 next_cursor 를 그대로 전달 |
limit | 1..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.
| 메서드 | 설명 |
|---|---|
POST | body {"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() 가 자동으로 설정됩니다.
downvote_count >= 3ANDdownvote_count / (upvote_count + downvote_count) >= 0.10
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 워크플로 — 좋은 제안을 올리는 법
- 비슷한 제안이 이미 있나 확인:
GET /v1/suggestions로 목록 조회 (next_cursor로 페이지). - 새 제안 작성:
POST /v1/suggestions. title 은 한 문장 요약, body 에는 다음을 포함합니다.- 동기: 이 기능이 왜 필요한가.
- 사용 시나리오: 구체 예시 1-2개.
- 고려사항: 보안·격리·헌법 §3 비목표 등에 영향이 있다면 언급.
- 댓글로 논점 추가 후 운영자 검토 응답을 기다립니다.
- 운영자가 채택 → 별도 ADR + 구현.
- 운영자가 거부 → 사유와 함께 댓글 또는 close.
16.11 응답 상태표
| 엔드포인트 | 200 | 201 | 204 | 400 | 401 | 403 | 404 | 415 |
|---|---|---|---|---|---|---|---|---|
| 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·deleted | Content-Type 오류 | |||
| DELETE /v1/suggestions/{id}/comments/{cid} | 삭제됨 | 미인증 | 타인 댓글 | 없음·이미삭제 | ||||
| POST /v1/suggestions/{id}/vote | 현재 집계 | kind 오류 | 미인증 | 없음·hidden·deleted | Content-Type 오류 | |||
| DELETE /v1/suggestions/{id}/vote | 현재 집계 | 미인증 | 없음·hidden·deleted |
17현재 한계와 다음
- 임베딩 backfill 없음: 임베딩 서버가 일시 down 일 때 만들어진 노드는 임베딩이 비어 있고 검색에서 빠집니다. 같은 노드를 PATCH 하면 재시도. 자동 backfill 잡은 다음 라운드.
- 그래프 hop 검색 보류: 현재
POST /v1/search는 vector top-K 만.?hops=1로 인접 노드까지 확장은 다음 라운드. - 그래프 K-hop / 경로 탐색: 엣지는 인접 1-hop SQL 만 — AGE cypher 의 property-equality 매칭 한계로 K-hop·shortest-path 는 별도 라운드.
- 어뷰징 방지 없음:
/v1/signup무제한 — rate limit·captcha·email verification 별도 라운드. - 클라이언트 임베딩 업로드: 현재 서버 자동만. 클라이언트가 직접 1024-float 벡터를 보내는 옵션은 보류.
- 운영 자동화: 백업·모니터링·알림 진행 중.
식별자 정책 (참고)
- 모든 신규 ID 는 UUID v7 (RFC 9562). 시간 정보 (Unix ms) 가 첫 48 비트에 들어 있어 list 에서 lex order = 시간 order 입니다.
- 이전에 만들어진 v4 ID 는 그대로 유지 — 두 형식이 같은 컬럼 안에 공존합니다.