connectbase-client 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,172 @@
3
3
  본 SDK 의 모든 주요 변경사항을 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 형식으로 기록합니다.
4
4
  버전은 [Semantic Versioning](https://semver.org/lang/ko/) 을 따릅니다.
5
5
 
6
+ ## [6.2.0] - 2026-09-06
7
+
8
+ ### Added — 웹 스토리지 관리 API 에 Secret Key 전송
9
+
10
+ `deploy`, `promote`, `releases` 와 `init` 의 스토리지 목록/생성이 이제 Secret Key(`cb_sk_*`)를
11
+ 함께 보냅니다. `.connectbaserc` 의 `secretKey` 또는 `CONNECTBASE_SECRET_KEY` 환경변수에서
12
+ 자동으로 읽습니다. **`init` 을 이미 실행했다면 할 일이 없습니다.**
13
+
14
+ **왜 필요한가.** Public Key(`cb_pk_*`)는 비밀이 아닙니다. 브라우저 번들에 실려 배포되는 공개
15
+ 식별자이고, 실제로 운영 중인 사이트의 JS 에서 그대로 추출됩니다. 그런데 이 값 하나로 배포와
16
+ production 승격이 가능했습니다 — 공개된 사이트에서 키를 뽑은 제3자가 그 사이트를 통째로
17
+ 교체할 수 있었다는 뜻입니다. Public Key 는 "어느 앱인가" 를 말할 뿐 "이 앱을 다룰 자격이
18
+ 있는가" 를 증명하지 못하므로, 쓰기 동작의 자격증명이 될 수 없습니다.
19
+
20
+ **서버는 곧 이를 요구합니다.** 현재는 기존 배포 파이프라인이 끊기지 않도록 유예 중이고,
21
+ 이 버전이 충분히 퍼진 뒤 강제로 전환합니다. 그때부터 Secret Key 없는 호출은 401
22
+ `SECRET_KEY_REQUIRED` 로 거부됩니다.
23
+
24
+ **이 버전은 아무것도 깨뜨리지 않습니다.** Secret Key 가 없으면 경고만 하고 그대로 진행합니다.
25
+ CLI 가 서버보다 먼저 조이면, 우리가 생성해 드리는 GitHub Actions 워크플로처럼 버전을 고정하지
26
+ 않는(`npx --yes connectbase deploy`) 환경이 서버 변경 전에 깨지기 때문입니다.
27
+
28
+ **지금 해두실 것.** CI 를 쓰신다면 저장소 시크릿에 `CONNECTBASE_SECRET_KEY` 를 추가하고
29
+ 워크플로 env 에 넣으세요. 워크플로 파일에 값을 직접 적으면 저장소에 커밋됩니다.
30
+
31
+ ```yaml
32
+ env:
33
+ CONNECTBASE_PUBLIC_KEY: ${{ secrets.CONNECTBASE_PUBLIC_KEY }}
34
+ CONNECTBASE_SECRET_KEY: ${{ secrets.CONNECTBASE_SECRET_KEY }}
35
+ ```
36
+
37
+ `page-metas` 계열 API 는 **변경 없습니다** — 브라우저 SDK 의 정규 경로라 그대로 둡니다.
38
+
39
+ ### Added — `X-User-Secret-Key` 전용 헤더
40
+
41
+ Secret Key 를 `Authorization` 대신 전용 헤더로도 보냅니다.
42
+
43
+ `Authorization` 자리는 AppMember 세션 토큰이 먼저 차지합니다. 종전 SDK 는 그 헤더가 이미
44
+ 있으면 Secret Key 를 붙이지 않았기 때문에, **로그인 상태 브라우저에서는 `secretKey` 를 설정해도
45
+ 전송되지 않았습니다.** 두 자격증명은 역할이 다릅니다 — 멤버 토큰은 "누가 이 앱의 회원인가",
46
+ Secret Key 는 "이 호출자가 앱을 관리할 자격이 있는가". 한 헤더를 두고 다투게 두면 관리 동작이
47
+ 로그인 상태에서만 조용히 실패합니다.
48
+
49
+ 서버는 전용 헤더를 `Authorization` 보다 우선해 읽습니다. 기존 `Authorization` 경로도 그대로
50
+ 보내므로 구버전 서버와 호환됩니다.
51
+
52
+ > Secret Key 는 서버사이드 자격증명입니다. 브라우저 번들에 넣지 마세요.
53
+
54
+ ### Fixed — 대용량 파일 배포 시 원인 불명 500
55
+
56
+ 파일당 저장 한도(약 6 MiB, 바이너리는 base64 로 약 1.33배)를 넘으면 서버가 원시 DB 오류를
57
+ 500 으로 뭉개 반환했습니다. 이제 **413** 과 함께 초과 파일 목록, 원본 크기 추정치, presign
58
+ 경로 안내가 응답에 담깁니다. CLI 는 종전대로 대용량 바이너리를 자동으로 오프로드합니다.
59
+
60
+ ## [6.1.0] - 2026-09-01
61
+
62
+ ### Added — `cb.organizations.*`: 조직/팀(워크스페이스)
63
+
64
+ 앱의 **엔드유저가 만드는 조직**을 다루는 모듈입니다. Connect Base 콘솔의 협업자 RBAC
65
+ (`cb.roles.*`)와는 완전히 별개 시스템입니다.
66
+
67
+ | 메서드 | 설명 |
68
+ |---|---|
69
+ | `create` / `listMine` / `get` / `update` / `delete` | 조직 CRUD (`slug` 는 생성 후 변경 불가) |
70
+ | `listMembers` / `updateMemberRole` / `removeMember` | 멤버 관리 (본인 ID 로 `removeMember` 하면 탈퇴) |
71
+ | `createInvitation` / `listInvitations` / `revokeInvitation` / `acceptInvitation` | 초대 |
72
+ | `switchTo` | 조직 전환 — 조직 컨텍스트가 실린 새 액세스 토큰을 받아 SDK 에 자동 적용 |
73
+
74
+ 모든 조직 API 는 **로그인한 회원 토큰**을 요구합니다. 퍼블릭 키만으로 호출하면 401 입니다 —
75
+ 퍼블릭 키는 설계상 클라이언트 번들에 노출되는 값이라, 그것만으로 목록을 열면 앱의 조직 구조가
76
+ 통째로 덤프되기 때문입니다.
77
+
78
+ > #### 조직 컨텍스트는 10분마다 갱신해야 합니다
79
+ >
80
+ > `switchTo()` 가 심는 RLS 조직 컨텍스트(`auth.org_id` / `auth.org_role`)의 수명은 액세스
81
+ > 토큰(1시간)과 **독립적인 10분**입니다. 만료되어도 토큰 자체는 유효해 401 이 나지 않고,
82
+ > 조직 규칙만 fail-closed 로 거부되어 **"멀쩡하던 조회가 갑자기 전부 막히는"** 증상으로만
83
+ > 드러납니다. 반환값의 `org_context_expires_in`(초) 보다 먼저 다시 호출하세요.
84
+ > 토큰 회전(refresh)이 일어나도 조직 컨텍스트는 의도적으로 사라지므로 다시 호출해야 합니다.
85
+
86
+ ```typescript
87
+ let timer: ReturnType<typeof setTimeout> | undefined
88
+
89
+ async function activate(orgId: string) {
90
+ const ctx = await cb.organizations.switchTo(orgId)
91
+ clearTimeout(timer)
92
+ timer = setTimeout(
93
+ () => activate(orgId),
94
+ Math.max(ctx.org_context_expires_in - 60, 30) * 1000,
95
+ )
96
+ return ctx
97
+ }
98
+ ```
99
+
100
+ ### Changed — `subscription.deleteBillingKey()` 가 삭제 결과를 반환합니다
101
+
102
+ 반환 타입이 `Promise<void>` 에서 `Promise<DeleteBillingKeyResponse>` 로 바뀌었습니다.
103
+ 서버는 이전부터 이 본문을 보내고 있었지만 타입에 노출되지 않아, **"PG 등록이 남아 있다"는
104
+ 경고를 SDK 사용자가 볼 수 없었습니다.**
105
+
106
+ ```typescript
107
+ const result = await cb.subscription.deleteBillingKey(billingKeyId)
108
+ if (!result.provider_revoked) {
109
+ // Connect Base 기록만 지워졌고 PG 등록은 그대로 남아 있다
110
+ alert(result.provider_revoke_note)
111
+ }
112
+ ```
113
+
114
+ | 프로바이더 | PG 해지 | 동작 |
115
+ |---|---|---|
116
+ | `toss` | O | 토스 빌링키 삭제 API 호출. PG 삭제가 실패하면 DB 행도 지우지 않고 에러 |
117
+ | `payapp` / `paypal` / `paddle` / `stripe` | X | "결제수단 삭제" API 가 없어 기록만 삭제. `provider_revoked: false` + `provider_revoke_note` |
118
+
119
+ 반환값을 쓰지 않던 기존 호출은 그대로 동작합니다 (하위호환).
120
+
121
+ ### Added — `DeleteBillingKeyResponse` 타입
122
+
123
+ `message` / `provider` / `provider_revoked` / `provider_revoke_note?`.
124
+
125
+ ### Added — 퍼블릭 키 스코프와 무중단 회전
126
+
127
+ - `cb.publicKey.createPublicKey()` 가 `scopes` 를 받습니다. **비우면 전권**이고(기존 키가 전부
128
+ 그렇습니다), 어휘는 새로 만든 것이 아니라 콘솔 RBAC 권한 이름과 service_role
129
+ `management_scopes` 의 합집합입니다.
130
+ - `PublicKeyItem` / `CreatePublicKeyResponse` / `UpdatePublicKeyResponse` 에 `scopes` 가 실립니다.
131
+ `PublicKeyItem` 에는 발급 출처 감사 필드(`created_by_kind`, `created_by_user_id`,
132
+ `created_ip_masked`, `created_user_agent`)도 추가됐습니다.
133
+ - **`cb.publicKey.rotatePublicKey(appId, keyId, { grace_period_hours?, name? })`** 신설 —
134
+ 새 키를 발급하고 옛 키는 유예 기간(기본 24시간, `0` = 즉시, 상한 720시간) 뒤 만료됩니다.
135
+ 옛 키의 스코프와 `payment_mode` 는 새 키가 물려받습니다. 반환값의 `new_key.key` 는 이때만
136
+ 볼 수 있고, `previous_key_expires_at` 전에 클라이언트 배포를 끝내야 합니다.
137
+
138
+ ```typescript
139
+ const rotated = await cb.publicKey.rotatePublicKey('app-id', 'key-id', { grace_period_hours: 72 })
140
+ console.log(rotated.new_key.key, rotated.previous_key_expires_at)
141
+ ```
142
+
143
+ ### Added — `HttpClient.setAccessToken()`
144
+
145
+ refresh token 을 건드리지 않고 액세스 토큰만 교체합니다. 조직 컨텍스트 발급처럼 **새 액세스
146
+ 토큰만 돌려주는** 엔드포인트를 위한 것으로, `cb.organizations.switchTo()` 가 내부에서 씁니다.
147
+
148
+ ---
149
+
150
+ ## [6.0.1] - 2026-08-22
151
+
152
+ ### Fixed — 로그인 세션이 없을 때 `/v1/auth/re-issue` 401 이 반복해서 나가던 문제
153
+
154
+ 쿠키 세션이 없는 브라우저(제3자 쿠키 차단, 쿠키 삭제, 만료 등)에서 인증이 필요한 API 를
155
+ 호출하면 SDK 가 매번 "쿠키로 세션을 복구해 보자"며 `/v1/auth/re-issue` 를 한 번씩 더
156
+ 보냈고, 그 요청이 401 로 떨어졌습니다. 앱이 폴링을 하거나 화면/훅마다 클라이언트를 새로
157
+ 만드는 구조면 이 401 이 수 초 간격으로 계속 쌓입니다 (실측: 5~6초 간격으로 13분 이상 연속,
158
+ platform-issue `01a0237f`).
159
+
160
+ 동시 refresh 를 하나로 합치는 처리(single-flight)는 이전부터 있었지만 **클라이언트 인스턴스
161
+ 하나가 살아 있는 동안만** 유효해서, 인스턴스가 새로 만들어지면 백오프까지 함께 초기화되는
162
+ 것이 원인이었습니다.
163
+
164
+ 이제 쿠키만으로 시도한 재발급이 401/403 을 받으면 "지금 이 브라우저에는 쓸 수 있는 refresh
165
+ 쿠키가 없다"를 60초 동안 `localStorage` 에 기록하고, 그동안은 **인스턴스가 달라도** 재발급
166
+ 요청을 보내지 않습니다. 로그인(`setTokens`)이나 성공한 재발급은 이 기록을 즉시 지우므로
167
+ 세션이 생기는 즉시 정상 동작합니다. 메모리에 refresh token 을 들고 있는 호출은 자격이
168
+ 다르므로 영향받지 않습니다.
169
+
170
+ 동작 변화는 "불필요한 401 요청이 사라지는 것"뿐입니다 — 세션 복구 성공 조건은 그대로입니다.
171
+
6
172
  ## [6.0.0] - 2026-08-20
7
173
 
8
174
  ### BREAKING — `realtime.isConnected()` 가 SSE 폴백에서 더 이상 true 를 반환하지 않습니다
package/README.md CHANGED
@@ -27,13 +27,19 @@ Connect Base provides **two types** of Keys. Use the right key for your use case
27
27
  |---------|----------|---------|
28
28
  | Frontend SDK (`new ConnectBase()`) | **Public Key** (`cb_pk_`) | Web/app: DB queries, auth, file uploads |
29
29
  | `.env` file (`VITE_CONNECTBASE_PUBLIC_KEY`) | **Public Key** (`cb_pk_`) | React, Vue, etc. |
30
- | CLI deploy (`.connectbaserc`) | **Public Key** (`cb_pk_`) | `npx connectbase deploy` |
30
+ | CLI deploy (`.connectbaserc`) | **Public Key + Secret Key** (`cb_pk_` + `cb_sk_`) | `npx connectbase deploy` |
31
31
  | MCP server (AI tools) | **Secret Key** (`cb_sk_`) | Claude, Cursor, Windsurf |
32
32
  | Server-side admin tasks | **Secret Key** (`cb_sk_`) | Backend full data access |
33
33
 
34
34
  > ⚠️ **MCP server rejects Public Keys** — you must use a Secret Key (`cb_sk_`).
35
35
  >
36
36
  > ⚠️ **Never use Secret Keys in frontend code** — RLS is bypassed, exposing all data.
37
+ >
38
+ > 🔐 **Web storage deploys need both keys.** A Public Key ships inside your browser bundle, so
39
+ > anyone can read it out of a deployed site's JS — it cannot prove you may replace that site.
40
+ > The web storage list/create/deploy/promote routes therefore require
41
+ > `Authorization: Bearer cb_sk_*` **in addition to** `X-Public-Key`; without it they return
42
+ > **401 `SECRET_KEY_REQUIRED`**. The CLI sends it for you — see [CLI](#cli).
37
43
 
38
44
  Create Keys in the Console under **Settings > API tab**. Choose Public or Secret type when creating. The full key is shown **only once** at creation time.
39
45
 
@@ -113,6 +119,7 @@ try {
113
119
  - **Push Notifications**: Cross-platform push notification support
114
120
  - **WebRTC**: Real-time audio/video communication
115
121
  - **Payments**: Subscription and one-time payment support
122
+ - **Organizations**: End-user workspaces/teams with invitations and an RLS organization context (`cb.organizations.*`)
116
123
  - **AI Streaming**: Real-time AI text generation via WebSocket (multi-provider: Gemini, OpenAI, Claude, Ollama, LM Studio, OpenAI-compatible)
117
124
  - **Knowledge Base (RAG)**: Document indexing + BM25 search with nori 한국어 형태소. PDF / DOCX / text file upload via `addDocumentFromFile`
118
125
  - **Endpoint**: Call your own GPU models on your own PC through one `cb_pk_*` key — ConnectBase forwards the payload as-is (dumb pipe)
@@ -124,6 +131,17 @@ try {
124
131
 
125
132
  Deploy your web application to Connect Base Web Storage with a single command.
126
133
 
134
+ > 🔐 **Deploy commands require a Secret Key (`cb_sk_*`).** The web storage deploy, promote,
135
+ > create and list routes require `Authorization: Bearer cb_sk_*` on top of `X-Public-Key`,
136
+ > because a Public Key is a public identifier that ships in your browser bundle and cannot
137
+ > prove deploy permission. The CLI reads `secretKey` from `.connectbaserc` or the
138
+ > `CONNECTBASE_SECRET_KEY` environment variable and sends it automatically — **just update to
139
+ > the latest version** (`npm i -g connectbase-client@latest`). Older CLIs fail with
140
+ > **401 `SECRET_KEY_REQUIRED`**.
141
+ >
142
+ > **Never commit a Secret Key.** `init` adds `.connectbaserc` to `.gitignore`; in CI, inject it
143
+ > from a repository secret instead.
144
+
127
145
  ### Quick Start
128
146
 
129
147
  ```bash
@@ -135,9 +153,10 @@ npm run deploy
135
153
  ```
136
154
 
137
155
  The `init` command will:
138
- - Ask for your Public Key
156
+ - Ask for your Secret Key (`cb_sk_`) — or take it from `--secret-key` / `CONNECTBASE_SECRET_KEY`
157
+ - Issue a Public Key for the app
139
158
  - List existing web storages or create a new one automatically
140
- - Create a `.connectbaserc` config file
159
+ - Create a `.connectbaserc` config file (holds both keys)
141
160
  - Add `.connectbaserc` to `.gitignore`
142
161
  - Add a `deploy` script to `package.json` (includes `build` if available)
143
162
 
@@ -154,7 +173,14 @@ The `init` command will:
154
173
  If you prefer not to use `init`, you can pass options directly:
155
174
 
156
175
  ```bash
157
- npx connectbase deploy ./dist -s <storage-id> -k <public-key>
176
+ npx connectbase deploy ./dist -s <storage-id> -k <public-key> --secret-key <secret-key>
177
+ ```
178
+
179
+ The Secret Key can also come from `CONNECTBASE_SECRET_KEY` — preferred in CI, so it never
180
+ appears in shell history or CI logs:
181
+
182
+ ```bash
183
+ CONNECTBASE_SECRET_KEY=cb_sk_... npx connectbase deploy ./dist -s <storage-id> -k <public-key>
158
184
  ```
159
185
 
160
186
  ### Options
@@ -162,7 +188,8 @@ npx connectbase deploy ./dist -s <storage-id> -k <public-key>
162
188
  | Option | Alias | Description |
163
189
  |--------|-------|-------------|
164
190
  | `--storage <id>` | `-s` | Storage ID |
165
- | `--public-key <key>` | `-k` | API Key |
191
+ | `--public-key <key>` | `-k` | Public Key (`cb_pk_`) — identifies the app |
192
+ | `--secret-key <key>` | | Secret Key (`cb_sk_`) — required by `deploy` / `promote` / storage list & create. Prefer `CONNECTBASE_SECRET_KEY` in CI |
166
193
  | `--base-url <url>` | `-u` | Custom server URL |
167
194
  | `--timeout <sec>` | `-t` | Tunnel request timeout in seconds (tunnel only) |
168
195
  | `--max-body <MB>` | | Tunnel max body size in MB (tunnel only) |
@@ -228,20 +255,37 @@ The `init` command creates `.connectbaserc` automatically. You can also create i
228
255
 
229
256
  ```json
230
257
  {
231
- "publicKey": "your-public-key",
258
+ "publicKey": "cb_pk_your-public-key",
259
+ "secretKey": "cb_sk_your-secret-key",
232
260
  "storageId": "your-storage-id",
233
261
  "deployDir": "./dist"
234
262
  }
235
263
  ```
236
264
 
265
+ > ⚠️ `.connectbaserc` now holds a Secret Key — keep it in `.gitignore` (`init` adds it) and never
266
+ > commit it. On a shared machine or in CI, drop `secretKey` from the file and pass
267
+ > `CONNECTBASE_SECRET_KEY` instead.
268
+
237
269
  ### Environment Variables
238
270
 
239
271
  ```bash
240
- export CONNECTBASE_PUBLIC_KEY=your-public-key
272
+ export CONNECTBASE_PUBLIC_KEY=cb_pk_your-public-key
273
+ export CONNECTBASE_SECRET_KEY=cb_sk_your-secret-key # required by deploy / promote
241
274
  export CONNECTBASE_STORAGE_ID=your-storage-id
242
275
  npx connectbase deploy ./dist
243
276
  ```
244
277
 
278
+ In CI, read the Secret Key from a repository secret:
279
+
280
+ ```yaml
281
+ # GitHub Actions
282
+ - run: npx connectbase deploy ./dist
283
+ env:
284
+ CONNECTBASE_PUBLIC_KEY: ${{ vars.CONNECTBASE_PUBLIC_KEY }}
285
+ CONNECTBASE_SECRET_KEY: ${{ secrets.CONNECTBASE_SECRET_KEY }}
286
+ CONNECTBASE_STORAGE_ID: ${{ vars.CONNECTBASE_STORAGE_ID }}
287
+ ```
288
+
245
289
  ### Requirements
246
290
 
247
291
  - `index.html` must exist in the root of the deploy directory
@@ -1330,6 +1374,20 @@ call.disconnect() // voice 는 그대로 유지됩니다
1330
1374
 
1331
1375
  ### Payments & Subscriptions
1332
1376
 
1377
+ > **빌링키 API 6종(발급/확인/목록/상세/수정/삭제)은 로그인한 회원 토큰이 필요합니다.**
1378
+ > `X-Public-Key` 단독 호출은 `401` 입니다. 로그인 없이 빌링키를 만들던 게스트 체크아웃 흐름은
1379
+ > 회원 로그인 후 호출로 바꿔야 합니다. 서버에서는 `cb_sk_*` 또는 `management_scopes` 에
1380
+ > `payment:read` / `subscription:manage` 를 opt-in 한 `service_role` 함수를 쓰세요.
1381
+
1382
+ ```typescript
1383
+ // 결제수단 삭제 — PG 등록까지 지워졌는지는 프로바이더마다 다르다 (v6.1.0+)
1384
+ const deleted = await cb.subscription.deleteBillingKey('billing-key-1')
1385
+ if (!deleted.provider_revoked) {
1386
+ // toss 외 프로바이더: Connect Base 기록만 지워졌고 PG 등록은 남아 있다
1387
+ console.warn(deleted.provider_revoke_note)
1388
+ }
1389
+ ```
1390
+
1333
1391
  ```typescript
1334
1392
  // Create a subscription (정기 결제)
1335
1393
  const subscription = await cb.subscription.create({
@@ -1384,6 +1442,60 @@ export async function handler(payload, ctx) {
1384
1442
  얹힙니다. payapp/paypal 은 PG 가 결제일 변경 API 를 주지 않아 400 `next_billing_date_unsupported`
1385
1443
  입니다 (로컬만 미루면 원래 날짜에 그대로 출금되므로 조용히 처리하지 않습니다).
1386
1444
 
1445
+ ### Organizations (Teams / Workspaces)
1446
+
1447
+ 앱의 **엔드유저가 만드는 조직**. Connect Base 콘솔의 협업자 RBAC(`cb.roles.*`)와는 별개
1448
+ 시스템이다. **모든 조직 API 는 로그인한 회원 토큰을 요구한다** — 퍼블릭 키 단독 호출은 401 이다.
1449
+
1450
+ ```typescript
1451
+ // 조직 만들기 (만든 사람이 owner)
1452
+ const org = await cb.organizations.create({ name: '우리 팀' })
1453
+
1454
+ // 초대 — 평문 토큰은 이 응답에서만 볼 수 있고, 메일 발송은 앱이 직접 한다
1455
+ const { token } = await cb.organizations.createInvitation(org.id, {
1456
+ email: 'teammate@example.com',
1457
+ role: 'member', // owner | admin | member
1458
+ })
1459
+
1460
+ // 초대 수락 (초대받은 계정으로 로그인한 상태에서)
1461
+ await cb.organizations.acceptInvitation(token)
1462
+
1463
+ // 내 조직 목록 (조직 전환 UI)
1464
+ const mine = await cb.organizations.listMine()
1465
+ ```
1466
+
1467
+ #### 조직 컨텍스트는 10분마다 갱신해야 한다
1468
+
1469
+ 데이터베이스 보안 규칙(RLS)의 `auth.org_id` / `auth.org_role` 은 **토큰에 실린 조직 컨텍스트**
1470
+ 에서 온다. 이 컨텍스트의 수명은 액세스 토큰(1시간)과 **독립적인 10분**이다. 만료되어도 토큰
1471
+ 자체는 유효해 401 이 나지 않고, 조직 규칙만 fail-closed 로 거부되어 **"멀쩡하던 조회가 갑자기
1472
+ 전부 막히는"** 증상으로만 드러난다.
1473
+
1474
+ ```typescript
1475
+ let timer: ReturnType<typeof setTimeout> | undefined
1476
+
1477
+ async function activate(orgId: string) {
1478
+ // 새 액세스 토큰을 받아 SDK 에 자동 적용한다
1479
+ const ctx = await cb.organizations.switchTo(orgId)
1480
+ clearTimeout(timer)
1481
+ // 만료 60초 전에 갱신
1482
+ timer = setTimeout(() => activate(orgId), Math.max(ctx.org_context_expires_in - 60, 30) * 1000)
1483
+ return ctx
1484
+ }
1485
+ ```
1486
+
1487
+ 토큰 회전(refresh)이 일어나면 조직 컨텍스트는 의도적으로 사라지므로 `switchTo()` 를 다시
1488
+ 호출해야 한다. 내가 속하지 않은 조직은 403 이 아니라 **404** 다 (조직 ID 열거 방지).
1489
+
1490
+ RLS 규칙에서 쓸 수 있는 축:
1491
+
1492
+ | 축 | 조직 컨텍스트가 없을 때 | 용도 |
1493
+ |---|---|---|
1494
+ | `auth.org_id` / `auth.org_role` | **에러 (fail-closed)** | `data.org_id == auth.org_id` 같은 스칼라 비교 |
1495
+ | `auth.has_org` | `false` (에러 안 남) | 조직 유무로 분기 |
1496
+ | `hasOrgRole('owner')` | `false` (에러 안 남) | 조직 역할 판정 (앱 전역 `hasRole()` 과 다름) |
1497
+ | `inOrg('<uuid>')` | `false` (에러 안 남) | 특정 조직인지, 인자 생략 시 컨텍스트 유무 |
1498
+
1387
1499
  ### Support (End-user Issue Reporting)
1388
1500
 
1389
1501
  End-user 가 앱 운영자에게 직접 버그·질문·요청을 발행하는 채널. 운영자 콘솔의 inbox 에 들어가며, AI 가 자동으로 요약·긴급도·카테고리를 분류한다 (운영자가 AI config 등록 시).
package/dist/cli.js CHANGED
@@ -41,7 +41,8 @@ __export(cli_exports, {
41
41
  normalizeRelativePath: () => normalizeRelativePath,
42
42
  parseArgs: () => parseArgs,
43
43
  registerEndpointBinding: () => registerEndpointBinding,
44
- sha256Hex: () => sha256Hex
44
+ sha256Hex: () => sha256Hex,
45
+ storageAdminHeaders: () => storageAdminHeaders
45
46
  });
46
47
  module.exports = __toCommonJS(cli_exports);
47
48
  var crypto = __toESM(require("crypto"));
@@ -295,6 +296,41 @@ function normalizeRelativePath(relativePath) {
295
296
  if (!p.startsWith("/")) p = `/${p}`;
296
297
  return p;
297
298
  }
299
+ function storageAdminHeaders(config) {
300
+ const headers = {
301
+ "X-Public-Key": config.publicKey
302
+ };
303
+ if (config.secretKey) {
304
+ headers["X-User-Secret-Key"] = config.secretKey;
305
+ headers.Authorization = `Bearer ${config.secretKey}`;
306
+ return headers;
307
+ }
308
+ warn("Secret Key(cb_sk_)\uAC00 \uC124\uC815\uB3FC \uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
309
+ log(
310
+ " Public Key \uB294 \uBE0C\uB77C\uC6B0\uC800 \uBC88\uB4E4\uC5D0 \uACF5\uAC1C\uB418\uB294 \uAC12\uC774\uB77C \uBC30\uD3EC/\uC2B9\uACA9\uC758 \uC790\uACA9\uC99D\uBA85\uC774 \uB420 \uC218 \uC5C6\uC5B4,"
311
+ );
312
+ log(" \uACE7 \uC11C\uBC84\uAC00 Secret Key \uB97C \uC694\uAD6C\uD569\uB2C8\uB2E4. \uC9C0\uAE08 \uC124\uC815\uD574 \uB450\uC138\uC694.");
313
+ log(
314
+ ` ${colors.cyan}npx connectbase init${colors.reset} \uC774 \uBC1C\uAE09\uD574 .connectbaserc \uC5D0 \uC800\uC7A5\uD569\uB2C8\uB2E4.`
315
+ );
316
+ log(
317
+ ` \uC774\uBBF8 \uD0A4\uAC00 \uC788\uB2E4\uBA74 ${colors.cyan}CONNECTBASE_SECRET_KEY${colors.reset} \uD658\uACBD\uBCC0\uC218\uB85C \uC9C0\uC815\uD558\uC138\uC694.`
318
+ );
319
+ log(" CI \uC5D0\uC11C\uB294 \uC800\uC7A5\uC18C \uC2DC\uD06C\uB9BF\uC5D0 \uB123\uC73C\uC138\uC694 \u2014 \uC800\uC7A5\uC18C\uC5D0 \uCEE4\uBC0B\uD558\uBA74 \uC548 \uB429\uB2C8\uB2E4.");
320
+ return headers;
321
+ }
322
+ function reportDeployAuthError(status, data) {
323
+ if (status !== 401) return false;
324
+ const detail = data?.error;
325
+ if (detail?.code !== "SECRET_KEY_REQUIRED") return false;
326
+ error(detail.message || "\uC774 \uC694\uCCAD\uC5D0\uB294 Secret Key \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.");
327
+ if (detail.suggestion) {
328
+ log("");
329
+ log(` ${detail.suggestion}`);
330
+ }
331
+ log("");
332
+ return true;
333
+ }
298
334
  function loadConfig() {
299
335
  const config = {
300
336
  publicKey: process.env.CONNECTBASE_PUBLIC_KEY,
@@ -670,7 +706,7 @@ async function deploy(directory, config, isDev = false, deployOpts = {}) {
670
706
  );
671
707
  }
672
708
  const baseStorageUrl = `${config.baseUrl}/v1/public/storages/webs/${config.storageId}`;
673
- const headers = { "X-Public-Key": config.publicKey };
709
+ const headers = storageAdminHeaders(config);
674
710
  const timeoutMs = computeDeployTimeout(totalSize, deployOpts.timeoutMs);
675
711
  log(
676
712
  `${colors.dim}\uD0C0\uC784\uC544\uC6C3: ${Math.round(timeoutMs / 1e3)}\uCD08${colors.reset}`
@@ -897,13 +933,15 @@ ${colors.cyan}URL: ${data.url}${colors.reset}
897
933
  } else {
898
934
  const data = response.data;
899
935
  const errorMsg = typeof data === "object" && data !== null ? data.message || data.error || JSON.stringify(data) : typeof data === "string" ? data : `HTTP ${response.status}`;
900
- error(`\uBC30\uD3EC \uC2E4\uD328: ${errorMsg}`);
936
+ if (!reportDeployAuthError(response.status, response.data)) {
937
+ error(`\uBC30\uD3EC \uC2E4\uD328: ${errorMsg}`);
938
+ }
901
939
  process.exit(1);
902
940
  }
903
941
  }
904
942
  async function promote(config, deploymentId) {
905
943
  const baseStorageUrl = `${config.baseUrl}/v1/public/storages/webs/${config.storageId}`;
906
- const headers = { "X-Public-Key": config.publicKey };
944
+ const headers = storageAdminHeaders(config);
907
945
  info(
908
946
  deploymentId ? `release ${deploymentId} \uB97C production \uC73C\uB85C \uC2B9\uACA9\uD569\uB2C8\uB2E4...` : "\uCD5C\uC2E0 \uBC30\uD3EC\uB97C production \uC73C\uB85C \uC2B9\uACA9\uD569\uB2C8\uB2E4..."
909
947
  );
@@ -925,7 +963,9 @@ async function promote(config, deploymentId) {
925
963
  if (res.status < 200 || res.status >= 300) {
926
964
  const data2 = res.data;
927
965
  const msg = typeof data2 === "object" && data2 !== null ? data2.error || data2.message || JSON.stringify(data2) : `HTTP ${res.status}`;
928
- error(`promote \uC2E4\uD328: ${msg}`);
966
+ if (!reportDeployAuthError(res.status, res.data)) {
967
+ error(`promote \uC2E4\uD328: ${msg}`);
968
+ }
929
969
  process.exit(1);
930
970
  }
931
971
  const data = res.data;
@@ -938,7 +978,7 @@ ${colors.cyan}Production: ${data.production_url}${colors.reset}
938
978
  }
939
979
  async function listReleases(config) {
940
980
  const baseStorageUrl = `${config.baseUrl}/v1/public/storages/webs/${config.storageId}`;
941
- const headers = { "X-Public-Key": config.publicKey };
981
+ const headers = storageAdminHeaders(config);
942
982
  const res = await makeRequest(
943
983
  `${baseStorageUrl}/deployments`,
944
984
  "GET",
@@ -947,7 +987,9 @@ async function listReleases(config) {
947
987
  if (res.status < 200 || res.status >= 300) {
948
988
  const data2 = res.data;
949
989
  const msg = typeof data2 === "object" && data2 !== null ? data2.error || data2.message || JSON.stringify(data2) : `HTTP ${res.status}`;
950
- error(`\uBC30\uD3EC \uC774\uB825 \uC870\uD68C \uC2E4\uD328: ${msg}`);
990
+ if (!reportDeployAuthError(res.status, res.data)) {
991
+ error(`\uBC30\uD3EC \uC774\uB825 \uC870\uD68C \uC2E4\uD328: ${msg}`);
992
+ }
951
993
  process.exit(1);
952
994
  }
953
995
  const data = res.data;
@@ -1391,7 +1433,11 @@ ${colors.blue}?${colors.reset} \uC120\uD0DD (\uBC88\uD638): `
1391
1433
  const listRes = await makeRequest(
1392
1434
  `${DEFAULT_BASE_URL}/v1/public/storages/webs`,
1393
1435
  "GET",
1394
- { "X-Public-Key": publicKeyForSdk }
1436
+ {
1437
+ "X-Public-Key": publicKeyForSdk,
1438
+ "X-User-Secret-Key": secretKey,
1439
+ Authorization: `Bearer ${secretKey}`
1440
+ }
1395
1441
  );
1396
1442
  if (listRes.status === 200) {
1397
1443
  const listData = listRes.data;
@@ -1429,7 +1475,11 @@ ${colors.blue}?${colors.reset} \uC120\uD0DD (\uBC88\uD638): `
1429
1475
  const createRes = await makeRequest(
1430
1476
  `${DEFAULT_BASE_URL}/v1/public/storages/webs`,
1431
1477
  "POST",
1432
- { "X-Public-Key": publicKeyForSdk },
1478
+ {
1479
+ "X-Public-Key": publicKeyForSdk,
1480
+ "X-User-Secret-Key": secretKey,
1481
+ Authorization: `Bearer ${secretKey}`
1482
+ },
1433
1483
  JSON.stringify({ name })
1434
1484
  );
1435
1485
  if (createRes.status !== 200) {
@@ -3434,7 +3484,7 @@ ${colors.yellow}\uBE60\uB978 \uC2DC\uC791:${colors.reset}
3434
3484
 
3435
3485
  ${colors.yellow}\uD658\uACBD\uBCC0\uC218:${colors.reset}
3436
3486
  CONNECTBASE_PUBLIC_KEY Public Key (cb_pk_*)
3437
- CONNECTBASE_SECRET_KEY Secret Key (cb_sk_*, \uD130\uB110\uC6A9)
3487
+ CONNECTBASE_SECRET_KEY Secret Key (cb_sk_*) \u2014 \uBC30\uD3EC/\uC2B9\uACA9/\uD130\uB110\uC5D0 \uD544\uC218
3438
3488
  CONNECTBASE_PUBLIC_KEY (deprecated) \uB808\uAC70\uC2DC \u2014 publicKey \uB610\uB294 secretKey \uB85C \uC0AC\uC6A9
3439
3489
  CONNECTBASE_STORAGE_ID \uC2A4\uD1A0\uB9AC\uC9C0 ID
3440
3490
  CONNECTBASE_BASE_URL \uC11C\uBC84 URL
@@ -3672,5 +3722,6 @@ main().catch((err) => {
3672
3722
  normalizeRelativePath,
3673
3723
  parseArgs,
3674
3724
  registerEndpointBinding,
3675
- sha256Hex
3725
+ sha256Hex,
3726
+ storageAdminHeaders
3676
3727
  });