Remove client-side Seoul subway key setup

Route Seoul subway arrival lookups through k-skill-proxy so the
hosted proxy owns the Seoul Open Data upstream key and end users
only need the proxy base URL. Add proxy route coverage, update
skill/docs guidance, and align setup materials with the hosted
proxy flow used for fine dust.

Constraint: Must keep the proxy public, read-only, and dependency-free
Constraint: Must satisfy TDD-first verification and ship on feature/#35 targeting dev
Rejected: Add a separate client helper package | unnecessary extra layer for a single proxy route
Rejected: Keep SEOUL_OPEN_API_KEY as an end-user requirement | defeats the issue goal
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the Seoul subway proxy surface limited to station-arrival passthrough unless tests/docs expand the contract
Tested: npm run ci; local proxy runtime on 127.0.0.1:4120 for /health and /v1/seoul-subway/arrival on 2026-03-31 with an invalid upstream key
Not-tested: Live success response with a valid Seoul Open API key
Related: #35
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-31 10:25:55 +09:00
commit fbc004af9d
12 changed files with 290 additions and 43 deletions

View file

@ -21,7 +21,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
| SRT 예매 | 열차 조회, 예약, 예약 확인, 취소 | 필요 | [SRT 예매 가이드](docs/features/srt-booking.md) |
| KTX 예매 | Dynapath anti-bot 대응 helper 로 KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | 역 기준 실시간 도착 예정 열차 확인 | 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 지하철 도착정보 조회 | `k-skill-proxy` 경유로 역 기준 실시간 도착 예정 열차 확인 | 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |

View file

@ -16,6 +16,7 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수
@ -27,6 +28,7 @@ client/skill -> k-skill-proxy -> upstream public API
프록시 서버 쪽:
- `AIR_KOREA_OPEN_API_KEY=...`
- `SEOUL_OPEN_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
## PM2 + cloudflared
@ -54,6 +56,13 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
--data-urlencode 'regionHint=서울 강남구'
```
서울 지하철 도착정보 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
AirKorea passthrough endpoint:
```bash

View file

@ -5,23 +5,25 @@
- 역 기준 실시간 도착 예정 열차 조회
- 상/하행 또는 외/내선 정보 확인
- 첫 번째/두 번째 도착 메시지 확인
- 개인 OpenAPI key 없이 `k-skill-proxy` 경유 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- 서울 열린데이터 광장 API key
- optional override: `KSKILL_PROXY_BASE_URL`
## 필요한 환경변수
- `SEOUL_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL` (optional override, 기본값 `https://k-skill-proxy.nomadamas.org`)
### Credential resolution order
사용자가 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요가 없다. hosted proxy가 upstream key를 서버에서만 관리한다.
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
### Proxy resolution order
1. **`KSKILL_PROXY_BASE_URL` 가 있으면** 그 값을 사용합니다.
2. **없으면** hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key 설정을 별도로 구성합니다.
## 입력값
@ -30,14 +32,24 @@
## 기본 흐름
1. `SEOUL_OPEN_API_KEY` 가 없으면 credential resolution order에 따라 확보합니다.
3. 역명 기준으로 실시간 도착정보를 조회합니다.
4. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
1. `KSKILL_PROXY_BASE_URL` 가 있으면 그 값을 사용하고, 없으면 hosted proxy를 기본값으로 사용합니다.
2. `/v1/seoul-subway/arrival?stationName=...` 로 역명 기준 실시간 도착정보를 조회합니다.
3. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
## 예시
```bash
curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/realtimeStationArrival/0/8/강남"
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
범위를 줄이거나 늘리고 싶으면:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=서울역' \
--data-urlencode 'startIndex=0' \
--data-urlencode 'endIndex=4'
```
## 주의할 점
@ -45,3 +57,4 @@ curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/real
- 실시간 데이터라 몇 초 단위로 바뀔 수 있습니다.
- 역명 표기가 다르면 결과가 비어 있을 수 있습니다.
- 일일 호출 제한이나 quota 초과 가능성이 있습니다.
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 봅니다.

View file

@ -24,7 +24,6 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
```
@ -58,8 +57,9 @@ KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
- `KSKILL_SRT_PASSWORD`
- `KSKILL_KTX_ID`
- `KSKILL_KTX_PASSWORD`
- `SEOUL_OPEN_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL`
프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 서울 지하철 도착정보 조회, 미세먼지 조회)을 사용하려면 이 절차를 진행하면 된다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key)을 사용하려면 이 절차를 진행하면 된다.
## Credential resolution order
@ -24,7 +24,6 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
EOF
@ -52,7 +51,7 @@ bash scripts/check-setup.sh
| --- | --- |
| SRT 예매 | `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` |
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
| 서울 지하철 도착정보 조회 | `SEOUL_OPEN_API_KEY` |
| 서울 지하철 도착정보 조회 | `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
## 다음에 볼 문서

View file

@ -2,5 +2,5 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org

View file

@ -67,8 +67,8 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
EOF
chmod 0600 ~/.config/k-skill/secrets.env
```
@ -83,8 +83,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
- 서울 지하철: `SEOUL_OPEN_API_KEY`
- 사용자 위치 미세먼지 조회: `AIR_KOREA_OPEN_API_KEY`
- 서울 지하철: `KSKILL_PROXY_BASE_URL`
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.

View file

@ -1,15 +1,17 @@
# k-skill-proxy
`k-skill`용 Fastify 기반 프록시 서버입니다. 지금은 AirKorea 미세먼지 조회를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회와 서울 지하철 실시간 도착정보를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
## 현재 제공 엔드포인트
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
@ -26,6 +28,13 @@ node packages/k-skill-proxy/src/server.js
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
서울 지하철 도착정보 예시:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
## PM2 실행
루트의 `ecosystem.config.cjs` + `scripts/run-k-skill-proxy.sh` 조합을 사용하면 재부팅 이후에도 같은 환경변수로 다시 올라옵니다.

View file

@ -1,7 +1,8 @@
const crypto = require("node:crypto");
const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const ALLOWED_AIRKOREA_ROUTES = new Map([
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
@ -40,6 +41,7 @@ function buildConfig(env = process.env) {
port: parseInteger(env.KSKILL_PROXY_PORT, 4020),
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -120,6 +122,26 @@ function normalizeFineDustQuery(query) {
};
}
function normalizeSeoulSubwayQuery(query) {
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
if (!stationName) {
throw new Error("Provide stationName.");
}
const startIndex = parseInteger(query.startIndex ?? query.start_index, 0);
const endIndex = parseInteger(query.endIndex ?? query.end_index, 8);
if (startIndex < 0 || endIndex < startIndex) {
throw new Error("Provide valid startIndex and endIndex.");
}
return {
stationName,
startIndex,
endIndex
};
}
function isAllowedAirKoreaRoute(service, operation) {
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
}
@ -147,7 +169,7 @@ async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fet
};
}
const url = new URL(`${UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
const url = new URL(`${AIR_KOREA_UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
for (const [key, value] of Object.entries(query || {})) {
if (value === undefined || value === null || value === "" || key === "serviceKey") {
continue;
@ -172,6 +194,40 @@ async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fet
};
}
async function proxySeoulSubwayRequest({
stationName,
startIndex = 0,
endIndex = 8,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "SEOUL_OPEN_API_KEY is not configured on the proxy server."
})
};
}
const encodedStationName = encodeURIComponent(stationName);
const url = new URL(
`${SEOUL_OPEN_API_BASE_URL}/api/subway/${apiKey}/json/realtimeStationArrival/${startIndex}/${endIndex}/${encodedStationName}`
);
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
function buildServer({ env = process.env, provider = null } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
@ -202,7 +258,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
service: config.proxyName,
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey)
airKoreaConfigured: Boolean(config.airKoreaApiKey),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey)
},
auth: {
tokenRequired: false
@ -284,6 +341,39 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/seoul-subway/arrival", async (request, reply) => {
let normalized;
try {
normalized = normalizeSeoulSubwayQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const upstream = await proxySeoulSubwayRequest({
...normalized,
apiKey: config.seoulOpenApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.proxy = {
name: config.proxyName,
requested_at: new Date().toISOString()
};
return payload;
});
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
@ -324,6 +414,8 @@ module.exports = {
buildConfig,
buildServer,
normalizeFineDustQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxySeoulSubwayRequest,
startServer
};

View file

@ -1,7 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildServer, proxyAirKoreaRequest } = require("../src/server");
const { buildServer, proxyAirKoreaRequest, proxySeoulSubwayRequest } = require("../src/server");
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
@ -24,6 +24,7 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
assert.equal(body.ok, true);
assert.equal(body.auth.tokenRequired, false);
assert.equal(body.upstreams.airKoreaConfigured, false);
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
@ -176,3 +177,90 @@ test("public AirKorea passthrough route forwards allowed upstream responses", as
assert.equal(response.statusCode, 200);
assert.match(response.body, /resultCode/);
});
test("seoul subway endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(
JSON.stringify({
errorMessage: {
status: 200,
code: "INFO-000",
message: "정상 처리되었습니다."
},
realtimeArrivalList: [
{
statnNm: "강남",
trainLineNm: "2호선",
updnLine: "내선",
arvlMsg2: "전역 출발",
arvlMsg3: "역삼",
barvlDt: "60"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().realtimeArrivalList[0].statnNm, "강남");
assert.match(calledUrl, /realtimeStationArrival\/0\/8\/%EA%B0%95%EB%82%A8$/);
});
test("seoul subway endpoint returns 503 when proxy server lacks Seoul API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxySeoulSubwayRequest injects API key and preserves index/station params", async () => {
let calledUrl;
const result = await proxySeoulSubwayRequest({
stationName: "강남",
startIndex: "2",
endIndex: "5",
apiKey: "test-seoul-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});

View file

@ -157,6 +157,40 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
assert.match(install, /--skill kakaotalk-mac/);
});
test("seoul subway docs default to the public proxy-backed flow", () => {
const readme = read("README.md");
const setup = read(path.join("docs", "setup.md"));
const install = read(path.join("docs", "install.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const skill = read(path.join("seoul-subway-arrival", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "seoul-subway-arrival.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
const secretsExample = read(path.join("examples", "secrets.env.example"));
assert.match(readme, /\| 서울 지하철 도착정보 조회 \| .* \| 불필요 \|/);
assert.match(setup, /\| 서울 지하철 도착정보 조회 \| `KSKILL_PROXY_BASE_URL` \|/);
assert.match(install, /--skill seoul-subway-arrival/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
assert.match(doc, /\/v1\/seoul-subway\/arrival/);
assert.match(doc, /사용자가 .*OpenAPI key.*직접.*필요가? 없다|개인 API key 없이/i);
assert.doesNotMatch(doc, /SEOUL_OPEN_API_KEY/);
assert.doesNotMatch(doc, /swopenAPI\.seoul\.go\.kr\/api\/subway\/\$\{SEOUL_OPEN_API_KEY\}/);
}
assert.match(proxyDoc, /GET \/v1\/seoul-subway\/arrival/);
assert.match(proxyDoc, /SEOUL_OPEN_API_KEY/);
assert.match(proxyReadme, /GET \/v1\/seoul-subway\/arrival/);
assert.match(proxyReadme, /SEOUL_OPEN_API_KEY/);
assert.match(security, /KSKILL_PROXY_BASE_URL/);
assert.match(setupSkill, /서울 지하철: `KSKILL_PROXY_BASE_URL`/);
assert.doesNotMatch(secretsExample, /SEOUL_OPEN_API_KEY/);
assert.match(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/k-skill-proxy\.nomadamas\.org/);
});
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");

View file

@ -12,7 +12,7 @@ metadata:
## What this skill does
서울 열린데이터 광장의 실시간 지하철 도착정보 Open API 역 기준 도착 예정 열차 정보를 요약한다.
서울 열린데이터 광장의 실시간 지하철 도착정보 Open API`k-skill-proxy` 경유로 조회해 역 기준 도착 예정 열차 정보를 요약한다.
## When to use
@ -22,21 +22,22 @@ metadata:
## Prerequisites
- 서울 열린데이터 광장 API key
- optional: `jq`
- optional override: `KSKILL_PROXY_BASE_URL`
## Required environment variables
- `SEOUL_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL` (optional override, 기본값 `https://k-skill-proxy.nomadamas.org`)
### Credential resolution order
사용자가 개인 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요가 없다. 기본 hosted proxy가 서버 쪽에 upstream key를 보관한다.
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
### Proxy resolution order
기본 경로에 저장하는 것은 fallback일 뿐, 강제가 아니다.
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용한다.
2. **없으면** 기본 hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용한다.
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key 설정을 별도로 구성한다.
클라이언트/사용자 쪽에서 upstream key를 직접 다루지 않는다.
## Inputs
@ -45,20 +46,21 @@ metadata:
## Workflow
### 1. Ensure credentials are available
### 1. Resolve the proxy base URL
`SEOUL_OPEN_API_KEY` 환경변수가 설정되어 있는지 확인한다. 없으면 위 credential resolution order에 따라 확보한다.
시크릿이 없다는 이유로 비공식 미러 API나 다른 출처로 자동 우회하지 않는다.
`KSKILL_PROXY_BASE_URL` 이 있으면 그 값을 쓰고, 없으면 hosted proxy를 기본값으로 사용한다.
### 2. Query the official station arrival endpoint
서울 실시간 지하철 API는 역명 기준 실시간 도착 정보를 JSON/XML로 제공한다. 기본 질의 예시는 다음 패턴을 쓴다.
proxy는 서울 실시간 지하철 API key를 서버에서 주입하고, 역명 기준 실시간 도착정보만 공개 read-only endpoint로 노출한다.
```bash
curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/realtimeStationArrival/0/8/강남"
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
필요하면 `startIndex`, `endIndex` 로 응답 범위를 조정할 수 있다.
### 3. Summarize the response
가능하면 아래 항목만 먼저 요약한다.
@ -77,15 +79,16 @@ curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/real
- 요청 역의 도착 예정 열차가 정리되어 있다
- live data 기준 시점이 명시되어 있다
- key가 노출되지 않았다
- upstream key가 클라이언트에 노출되지 않았다
## Failure modes
- API key 미설정
- proxy upstream key 미설정
- quota 초과
- 역명 표기 불일치
## Notes
- 서울 열린데이터 광장 가이드는 실시간 지하철 Open API에 일일 호출 제한이 있을 수 있다고 안내한다
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다
- endpoint path는 API 버전 변경 가능성이 있으므로 실패 시 dataset console의 최신 샘플 URL을 다시 확인한다