Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/convenience-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ CONVENIENCE_ACCESS_CLIENT_SECRET=your_access_client_secret

Cloudflare Tunnel에서 `/v1/convenience/.*` 경로를 4320 서버로 전달하고 기존 Access 정책을 적용합니다. Access를 사용하지 않는 로컬 실행에서는 두 Access 변수를 모두 생략합니다. 실제 토큰·키는 `.env`, 비공개 런타임 환경, Worker secrets에만 보관합니다.

GS25 재고는 별도 공식 앱 인증키가 필요합니다. `GS25_API_KEY`를 Worker 또는 Mac 중계 환경에 설정합니다. Worker 키는 인증된 HTTPS 중계의 본문으로 전달되고 공식 API의 `Api-Key` 헤더에만 사용됩니다. Mac 환경 키가 있으면 우선합니다. 키 없음과 원본 401/403은 인증 오류이며, 중계 혼잡·할당량 장애와 구분합니다. 키가 없으면 GS25 상품 검색은 사용할 수 있지만 재고 수량은 복구되지 않습니다.
GS25 재고는 Mac 중계의 `GS25_AUTH_SESSION_FILE`로 정상 로그인 세션을 사용할 수 있습니다. 파일은 현재 사용자 소유의 비공개 권한(0600)으로 보관하고 `accessToken`, `refreshToken`, `deviceId`만 저장합니다. GS 통합회원 또는 네이버 등 정상 로그인으로 GS가 발급한 토큰을 사용하며, 네이버 토큰을 직접 재고 API에 보내지 않습니다. GS 세션은 Worker·응답·로그에 전달하지 않고 고정 GS 재고 API의 Bearer 및 appinfo 헤더에만 사용합니다. 브라우저나 가상기기를 재고 조회마다 실행할 필요가 없습니다.

Access 토큰 만료1분 전 또는 재고401/403 응답 시 정상 `tokenReissue` API로 갱신합니다. 동시 갱신은1회 공유하고 각 caller의 취소는 다른 caller의 갱신을 끊지 않습니다. 갱신의 자체 기한은10초이며 원래 조회의15초 기한도 유지합니다. 갱신한 두 토큰은 같은 디렉터리의0600 임시파일을 원자교체해 저장합니다. 저장 실패·인증 철회·refresh 만료·재시도 후 인증거절은 실패로 전달하고 재고0을 만들지 않습니다. 인증 철회 후에는 정상 재로그인으로 파일을 교체하고 중계를 재기동합니다. 정상 조회 예산은 기존 원장에 기록하며 갱신은 별도 인증 요청입니다.

세션파일을 설정하지 않은 기존 `GS25_API_KEY` 방식도 유지합니다. 키를 Worker 또는 Mac 중계 환경에 설정하면 공식 API의 `Api-Key` 헤더에만 사용하며 Mac 환경 키가 우선합니다. 세션파일을 설정했으면 세션 인증을 우선하고 키로 자동 폴백하지 않습니다. 유효 세션·키 없음과 원본401/403은 인증 오류이며 중계 혼잡·할당량 장애와 구분합니다. 상품 검색은 별도 공개 경로입니다.

## 고정 작업

Expand Down
10 changes: 10 additions & 0 deletions docs/superpowers/plans/2026-10-05-gs25-direct-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# GS25 정상 로그인 세션 직접 API 연결

사용자 승인: 운영 반영과 직접 API 인증. 네이버 정상 로그인으로 GS 토큰을 발급했고 실제 앱과 같은 코카콜라캔350ML(8801094017200) 재고16/7/10/0 및 토큰 갱신 후 재조회200을 확인했다. 가상기기를 재고 요청마다 실행하지 않는다.

1. 로컬 중계 전용 세션파일(0600)의 accessToken/refreshToken/deviceId를 읽는 전송기를 TDD로 구현한다. GS 고정 재고 URL에만 Bearer/appinfo를 적용하고 Worker/로그/응답에 토큰을 전달하지 않는다.
2. 만료1분 전 갱신, 동일 동시 갱신 공유, 인증거절 시1회 갱신·재조회, 원본/갱신의 취소와 기한, 갱신토큰 원자저장 및 실패시 재고0 위장 방지를 검증한다. 기존 API키 방식은 세션파일 미설정 때 유지한다.
3. GS25_AUTH_SESSION_FILE을 Mac 중계 시작 설정에 연결한다. 기존 용량/원장/조회캐시 정책을 유지한다. 별도 auth API를 외부에 노출하지 않는다.
4. 관련 테스트·전체check/coverage/build·독립 리뷰 후 PR 병합한다. 운영 파일 백업·중계 유휴 상태 확인 후 코드/세션파일 설정 적용, 원장 보존·실제 REST/MCP/CLI 재고 및 타서비스 smoke를 검증한다.

동일 상품·지역의 실재고가 앱과 일치하는 증거와 갱신 성공은 scratch의 private 인증 파일과 공개 검증 JSON에 분리되어 있다. 사용자 자격증명/회원정보/토큰은 커밋하지 않는다. 정상 refresh 만료 또는 계정 인증 철회 시 재로그인이 필요하며 오류로 전달한다.
9 changes: 5 additions & 4 deletions scripts/relay/convenience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as z from 'zod';
import { CU_API } from '../../src/services/cu/api.js';
import { SEVENELEVEN_API } from '../../src/services/seveneleven/api.js';
import { GS25_API } from '../../src/services/gs25/api.js';
import type { Gs25SessionTransport } from './gs25-session.js';
import {
createHttpRelay,
RelayError,
Expand Down Expand Up @@ -245,7 +246,7 @@ const paths: Record<Operation, [string, string, 'GET' | 'POST', z.ZodType]> = {
};
export function createConvenienceRelay(
token: string,
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'> & { gs25ApiKey?: string },
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'> & { gs25ApiKey?: string; gs25Session?: Gs25SessionTransport },
) {
return createHttpRelay(token, {
...options,
Expand Down Expand Up @@ -276,14 +277,14 @@ export function createConvenienceRelay(
}
if (operation === 'gs25-stock') {
const key = options.gs25ApiKey?.trim() || body.apiKey;
if (!key) throw new RelayError(403);
headers['Api-Key'] = String(key);
if (!options.gs25Session && !key) throw new RelayError(403);
if (!options.gs25Session) headers['Api-Key'] = String(key);
delete body.apiKey;
}
if (method === 'GET' || operation === 'seven-popwords')
url.search = new URLSearchParams(body as Record<string, string>).toString();
const response = await abortable(
fetcher(url, {
(operation === 'gs25-stock' && options.gs25Session ? options.gs25Session : fetcher)(url, {
method,
headers,
redirect: 'manual',
Expand Down
7 changes: 6 additions & 1 deletion scripts/relay/dtryx-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createFileQuota, assertSeparateQuotaDirectories } from './quota.js';
import { createConvenienceRelay } from './convenience.js';
import { createGs25SessionTransport } from './gs25-session.js';
import { createDtryxRelay } from './dtryx.js';

let logger: ReturnType<typeof createRelayLogger> | undefined;
Expand Down Expand Up @@ -39,7 +40,11 @@ async function main() {
await assertSeparateQuotaDirectories(stateDir, convenienceDir);
convenienceLog = createRelayLogger(join(convenienceDir, 'logs'), { service: 'convenience' });
const quota = await createFileQuota(join(convenienceDir, 'quota.json'));
const convenienceHandler = createConvenienceRelay(convenienceToken, { takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY });
const sessionFile = process.env.GS25_AUTH_SESSION_FILE?.trim();
const convenienceHandler = createConvenienceRelay(convenienceToken, {
takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY,
gs25Session: sessionFile ? createGs25SessionTransport(sessionFile) : undefined,
});
convenience = observeRelay(async (request) => {
const response = await convenienceHandler(request);
if (response.ok && new URL(request.url).pathname === '/v1/convenience/health') return Response.json({ ...(await response.json()), quota: quota.status(), logging: convenienceLog!.status() });
Expand Down
134 changes: 134 additions & 0 deletions scripts/relay/gs25-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/** GS 정상 로그인 토큰을 로컬에만 보관하고 고정 재고 API에 사용합니다. */
import { randomUUID } from 'node:crypto';
import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { GS25_API } from '../../src/services/gs25/api.js';
import { abortable, readJson, RelayError } from './http-relay.js';

interface Session { accessToken: string; refreshToken: string; deviceId: string }
export type Gs25SessionTransport = (url: URL, init: RequestInit) => Promise<Response>;

function expiration(token: unknown): number {
if (typeof token !== 'string' || !/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token)) throw new RelayError(403);
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString('utf8'));
if (!Number.isFinite(payload.exp) || payload.exp <= 0) throw Error();
return payload.exp * 1000;
} catch { throw new RelayError(403); }
}

function parseSession(value: unknown): Session {
if (!value || typeof value !== 'object') throw new RelayError(403);
const session = value as Session;
expiration(session.accessToken);
expiration(session.refreshToken);
if (typeof session.deviceId !== 'string' || !/^[\w-]{1,100}$/.test(session.deviceId)) throw new RelayError(403);
return { accessToken: session.accessToken, refreshToken: session.refreshToken, deviceId: session.deviceId };
}

function appHeaders(session: Session): Headers {
return new Headers({
Accept: 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'woodongs-app/5.3.61 (Android; 14; sdk_gphone64_arm64)',
request_id: randomUUID(), appinfo_device_id: session.deviceId,
appinfo_model_name: 'sdk_gphone64_arm64', appinfo_os_version: '14',
appinfo_app_version: '5.3.61', appinfo_app_build_number: '2132', appinfo_os_type: 'android',
});
}

export function createGs25SessionTransport(
file: string,
options: { fetcher?: typeof fetch; now?: () => number; refreshTimeoutMs?: number } = {},
): Gs25SessionTransport {
const fetcher = options.fetcher ?? fetch;
const now = options.now ?? Date.now;
let loading: Promise<Session> | undefined;
let current: Session | undefined;
let refreshing: Promise<Session> | undefined;
let revoked = false;

async function load(): Promise<Session> {
if (revoked) throw new RelayError(403);
if (current) return current;
loading ??= (async () => {
try {
const info = await stat(file);
if (!info.isFile() || info.size > 16384 || (info.mode & 0o077) !== 0 || info.uid !== process.getuid!()) throw Error();
current = parseSession(JSON.parse(await readFile(file, 'utf8')));
return current;
} catch { throw new RelayError(403); }
})();
return loading;
}

async function renew(session: Session): Promise<Session> {
if (expiration(session.refreshToken) <= now()) { revoked = true; throw new RelayError(403); }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.refreshTimeoutMs ?? 10000);
const temporary = `${file}.${randomUUID()}.tmp`;
try {
const headers = appHeaders(session);
headers.set('Refresh', session.refreshToken);
const response = await abortable(fetcher(new URL('/api/bff/v4/auth/tokenReissue', GS25_API.BFF_BASE_URL), {
method: 'POST', headers, redirect: 'manual', signal: controller.signal,
}), controller.signal);
if (!response.ok) {
void response.body?.cancel().catch(() => undefined);
if ([401, 403].includes(response.status)) { revoked = true; throw new RelayError(403); }
throw new RelayError(502);
}
const value = await readJson(response.body, 131072, controller.signal, 502) as { resultCode?: string; data?: { access?: string; refresh?: string } };
if (value.resultCode !== '0000' || !value.data?.access || !value.data.refresh) throw new RelayError(502);
let next: Session;
try {
next = parseSession({ accessToken: value.data.access.replace(/^Bearer /, ''), refreshToken: value.data.refresh.replace(/^Bearer /, ''), deviceId: session.deviceId });
if (expiration(next.accessToken) <= now() + 60000 || expiration(next.refreshToken) <= now()) throw Error();
} catch { throw new RelayError(502); }
// 회전된 refresh 토큰은 파일 저장이 성공한 뒤 사용합니다.
try {
await abortable((async () => {
await writeFile(temporary, JSON.stringify(next), { mode: 0o600, flag: 'wx', signal: controller.signal });
if (controller.signal.aborted) throw new RelayError(504);
await rename(temporary, file);
if (controller.signal.aborted) throw new RelayError(504);
})(), controller.signal);
} catch { revoked = true; throw new RelayError(502); }
current = next;
return next;
} catch (error) {
if (controller.signal.aborted) throw new RelayError(504);
if (error instanceof RelayError) throw error;
throw new RelayError(502);
} finally {
clearTimeout(timer);
void unlink(temporary).catch(() => undefined);
}
}

async function ready(forceFor?: string): Promise<Session> {
const session = await load();
if ((!forceFor && expiration(session.accessToken) > now() + 60000) || (forceFor && session.accessToken !== forceFor)) return session;
if (!refreshing) refreshing = renew(session).finally(() => { refreshing = undefined; });
return refreshing;
}

return async (url, init) => {
if (url.origin !== GS25_API.BFF_BASE_URL || url.pathname !== GS25_API.STORE_STOCK_PATH || url.username || url.password) throw new RelayError(400);
const signal = init.signal ?? new AbortController().signal;
if (signal.aborted) throw new RelayError(499);
const request = async (session: Session) => {
const headers = new Headers(init.headers);
headers.delete('Api-Key');
appHeaders(session).forEach((value, key) => headers.set(key, value));
headers.set('Authorization', `Bearer ${session.accessToken}`);
return abortable(fetcher(url, { ...init, method: 'GET', body: undefined, headers, redirect: 'manual', signal }), signal);
};
const session = await abortable(ready(), signal);
const response = await request(session);
if (![401, 403].includes(response.status)) return response;
void response.body?.cancel().catch(() => undefined);
const next = await abortable(ready(session.accessToken), signal);
const retry = await request(next);
if ([401, 403].includes(retry.status)) revoked = true;
return retry;
};
}
26 changes: 26 additions & 0 deletions tests/relay/convenience-gs25-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, it, vi } from 'vitest';
import { createConvenienceRelay } from '../../scripts/relay/convenience.js';
import { RelayError } from '../../scripts/relay/http-relay.js';

const request = () => new Request('http://localhost/v1/convenience/gs25-stock', {
method: 'POST', headers: { Authorization: 'Bearer convenience' },
body: JSON.stringify({ serviceCode: '01', realTimeStockYn: 'Y', itemCode: '1' }),
});
it('세션 전송이 있으면 Worker API키 없이 재고를 조회한다', async () => {
const fetcher = vi.fn<typeof fetch>();
const session = vi.fn(async () => Response.json({ stores: [{ realStockQuantity: '16' }] }));
const quota = vi.fn(async () => true);
const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: quota, gs25Session: session });
expect((await relay(request())).status).toBe(200);
expect((await relay(request())).status).toBe(200);
expect(fetcher).not.toHaveBeenCalled();
expect(session).toHaveBeenCalledTimes(1);
expect(quota).toHaveBeenCalledTimes(1);
});
it('세션 갱신 오류는 캐시하거나 재고0으로 바꾸지 않는다', async () => {
const session = vi.fn(async () => { throw new RelayError(403); });
const relay = createConvenienceRelay('convenience', { takeQuota: async () => true, gs25Session: session });
expect((await relay(request())).status).toBe(403);
expect((await relay(request())).status).toBe(403);
expect(session).toHaveBeenCalledTimes(2);
});
Loading
Loading