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 +166 -0
- package/README.md +119 -7
- package/dist/cli.js +62 -11
- package/dist/connect-base.umd.js +5 -5
- package/dist/index.d.mts +969 -22
- package/dist/index.d.ts +969 -22
- package/dist/index.js +679 -7
- package/dist/index.mjs +678 -7
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1637,20 +1637,48 @@ var AuthAPI = class {
|
|
|
1637
1637
|
});
|
|
1638
1638
|
}
|
|
1639
1639
|
/**
|
|
1640
|
-
* 앱 멤버 회원가입 (
|
|
1640
|
+
* 앱 멤버 회원가입 (아이디/이메일 + 비밀번호 기반)
|
|
1641
1641
|
* 앱에 새로운 멤버를 등록합니다.
|
|
1642
1642
|
*
|
|
1643
|
+
* `login_id` 와 `email` 중 **최소 하나**는 있어야 합니다. 어느 쪽을 주느냐에 따라
|
|
1644
|
+
* 만들어지는 로그인 수단이 달라집니다 — 표는 {@link MemberSignUpRequest} 참고.
|
|
1645
|
+
*
|
|
1646
|
+
* `email` 을 함께 저장해야 나중에 {@link AuthAPI.requestPasswordReset} 로 비밀번호를
|
|
1647
|
+
* 복구할 수 있습니다. `login_id` 만으로 가입한 멤버는 복구 수단이 없습니다.
|
|
1648
|
+
*
|
|
1649
|
+
* 이메일을 넘겨도 **인증 메일이 자동 발송되지는 않습니다.** 발신 주소가 플랫폼 단일
|
|
1650
|
+
* 주소라 앱이 요청하지 않은 메일을 보내지 않는다는 정책입니다 — 필요하면 가입 직후
|
|
1651
|
+
* {@link AuthAPI.sendEmailVerification} 을 직접 호출하세요.
|
|
1652
|
+
*
|
|
1643
1653
|
* @example
|
|
1644
1654
|
* ```typescript
|
|
1655
|
+
* // 기존 방식 — 아이디만 (동작 그대로, 단 비밀번호 복구 불가)
|
|
1645
1656
|
* const result = await client.auth.signUpMember({
|
|
1646
1657
|
* login_id: 'myuser123',
|
|
1647
1658
|
* password: 'password123',
|
|
1648
1659
|
* nickname: 'John'
|
|
1649
1660
|
* })
|
|
1650
|
-
*
|
|
1661
|
+
*
|
|
1662
|
+
* // 아이디 + 복구용 이메일 (로그인은 여전히 login_id 로)
|
|
1663
|
+
* await client.auth.signUpMember({
|
|
1664
|
+
* login_id: 'myuser123',
|
|
1665
|
+
* email: 'john@example.com',
|
|
1666
|
+
* password: 'password123'
|
|
1667
|
+
* })
|
|
1668
|
+
*
|
|
1669
|
+
* // 이메일만 — 이메일이 곧 로그인 아이디
|
|
1670
|
+
* await client.auth.signUpMember({
|
|
1671
|
+
* email: 'john@example.com',
|
|
1672
|
+
* password: 'password123'
|
|
1673
|
+
* })
|
|
1651
1674
|
* ```
|
|
1652
1675
|
*/
|
|
1653
1676
|
async signUpMember(data) {
|
|
1677
|
+
if (!data.login_id && !data.email) {
|
|
1678
|
+
throw new Error(
|
|
1679
|
+
"signUpMember requires at least one of login_id or email."
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1654
1682
|
const response = await this.http.post(
|
|
1655
1683
|
"/v1/public/app-members/signup",
|
|
1656
1684
|
data,
|
|
@@ -1670,19 +1698,34 @@ var AuthAPI = class {
|
|
|
1670
1698
|
return response;
|
|
1671
1699
|
}
|
|
1672
1700
|
/**
|
|
1673
|
-
* 앱 멤버 로그인 (
|
|
1701
|
+
* 앱 멤버 로그인 (아이디 또는 이메일 + 비밀번호)
|
|
1674
1702
|
* 기존 멤버로 로그인합니다.
|
|
1675
1703
|
*
|
|
1704
|
+
* `login_id` 가 1순위 조회 키이고, `email` 은 이메일로 가입한 멤버를 위한 별칭입니다.
|
|
1705
|
+
* 서버는 USERNAME 을 먼저 찾고 없을 때만 EMAIL 로 폴백하므로 **기존 `login_id` 호출은
|
|
1706
|
+
* 동작이 그대로**입니다. 둘 다 비어 있으면 400 입니다.
|
|
1707
|
+
*
|
|
1676
1708
|
* @example
|
|
1677
1709
|
* ```typescript
|
|
1710
|
+
* // 아이디로 로그인 (기존 방식)
|
|
1678
1711
|
* const result = await client.auth.signInMember({
|
|
1679
1712
|
* login_id: 'myuser123',
|
|
1680
1713
|
* password: 'password123'
|
|
1681
1714
|
* })
|
|
1682
|
-
*
|
|
1715
|
+
*
|
|
1716
|
+
* // 이메일로 로그인
|
|
1717
|
+
* await client.auth.signInMember({
|
|
1718
|
+
* email: 'john@example.com',
|
|
1719
|
+
* password: 'password123'
|
|
1720
|
+
* })
|
|
1683
1721
|
* ```
|
|
1684
1722
|
*/
|
|
1685
1723
|
async signInMember(data) {
|
|
1724
|
+
if (!data.login_id && !data.email) {
|
|
1725
|
+
throw new Error(
|
|
1726
|
+
"signInMember requires at least one of login_id or email."
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1686
1729
|
const response = await this.http.post(
|
|
1687
1730
|
"/v1/public/app-members/signin",
|
|
1688
1731
|
data,
|
|
@@ -1701,6 +1744,169 @@ var AuthAPI = class {
|
|
|
1701
1744
|
this.notifyVisitorTracker(response.member_id);
|
|
1702
1745
|
return response;
|
|
1703
1746
|
}
|
|
1747
|
+
// ── 자격증명 복구/변경 ────────────────────────────────────────────────────
|
|
1748
|
+
//
|
|
1749
|
+
// # 메일 링크는 "앱이 운영하는 페이지"를 거친다
|
|
1750
|
+
//
|
|
1751
|
+
// 재설정/인증 메일의 링크는 이 API 를 직접 열지 않습니다. 브라우저 주소창으로는
|
|
1752
|
+
// `X-Public-Key` 헤더를 붙일 수 없기 때문입니다. 링크는 앱의 페이지(= SDK 가 이미
|
|
1753
|
+
// 초기화돼 있는 곳)로 가고, 그 페이지가 쿼리에서 `token` 을 꺼내 아래 confirm 메서드를
|
|
1754
|
+
// 호출하는 구조입니다.
|
|
1755
|
+
//
|
|
1756
|
+
// 그래서 `redirect_url` 로 넘길 주소는 **앱에 등록된 도메인이어야 합니다**
|
|
1757
|
+
// (자세한 규칙은 {@link MemberRedirectOption}). 도메인을 등록하지 않으면 링크가
|
|
1758
|
+
// 플랫폼 기본 페이지로 폴백합니다.
|
|
1759
|
+
/**
|
|
1760
|
+
* 비밀번호 재설정 메일을 보냅니다. (로그인 불필요)
|
|
1761
|
+
*
|
|
1762
|
+
* # 계정 열거 방지 — 응답으로는 아무것도 알 수 없다
|
|
1763
|
+
*
|
|
1764
|
+
* 가입되지 않은 이메일이든, 소셜 전용 계정(비밀번호 없음)이든, 정지된 멤버든
|
|
1765
|
+
* **똑같은 200 과 똑같은 문구**가 돌아옵니다. 메일이 실제로 나갔는지 여부는 응답으로
|
|
1766
|
+
* 판별할 수 없습니다 — "가입 여부 확인" 용도로 쓸 수 없다는 뜻이며, 의도된 설계입니다.
|
|
1767
|
+
* UI 에서도 "가입된 계정이라면 메일을 보냈습니다" 처럼 안내하세요.
|
|
1768
|
+
*
|
|
1769
|
+
* 메일 링크의 토큰은 **1시간** 만료이고 **1회만** 쓸 수 있습니다.
|
|
1770
|
+
*
|
|
1771
|
+
* @example
|
|
1772
|
+
* ```typescript
|
|
1773
|
+
* // 앱이 자체 재설정 페이지를 운영하는 경우 (도메인 등록 선행 필수)
|
|
1774
|
+
* await client.auth.requestPasswordReset({
|
|
1775
|
+
* email: 'john@example.com',
|
|
1776
|
+
* redirect_url: 'https://myapp.com/reset-password'
|
|
1777
|
+
* })
|
|
1778
|
+
* toast('가입된 계정이라면 재설정 링크를 보냈습니다')
|
|
1779
|
+
* ```
|
|
1780
|
+
*/
|
|
1781
|
+
async requestPasswordReset(data) {
|
|
1782
|
+
return this.http.post(
|
|
1783
|
+
"/v1/public/app-members/password-reset/request",
|
|
1784
|
+
data,
|
|
1785
|
+
{ skipAuth: true }
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* 재설정 토큰으로 새 비밀번호를 확정합니다. (로그인 불필요)
|
|
1790
|
+
*
|
|
1791
|
+
* `redirect_url` 페이지의 `?token=` 쿼리 값을 그대로 넘기면 됩니다.
|
|
1792
|
+
*
|
|
1793
|
+
* 성공하면 **그 멤버의 모든 세션이 서버에서 폐기**됩니다. 다른 기기 로그인까지 전부
|
|
1794
|
+
* 끊기며, 이 SDK 인스턴스에 남아 있던 토큰도 함께 정리하므로 새 비밀번호로 다시
|
|
1795
|
+
* 로그인해야 합니다.
|
|
1796
|
+
*
|
|
1797
|
+
* 토큰이 만료(1시간)됐거나 이미 사용됐거나 위조/타앱 토큰이면 400 이 납니다. 사유는
|
|
1798
|
+
* 구분되지 않습니다 — 토큰을 탐색하는 단서를 주지 않기 위해서입니다.
|
|
1799
|
+
*
|
|
1800
|
+
* @example
|
|
1801
|
+
* ```typescript
|
|
1802
|
+
* const token = new URLSearchParams(location.search).get('token')
|
|
1803
|
+
* if (!token) return
|
|
1804
|
+
* try {
|
|
1805
|
+
* await client.auth.confirmPasswordReset({ token, new_password: 'newpass123' })
|
|
1806
|
+
* location.href = '/login'
|
|
1807
|
+
* } catch (e) {
|
|
1808
|
+
* // 만료, 이미 사용됨, 잘못된 링크가 모두 여기로 온다
|
|
1809
|
+
* alert('링크가 만료되었거나 이미 사용되었습니다. 재설정을 다시 요청해 주세요.')
|
|
1810
|
+
* }
|
|
1811
|
+
* ```
|
|
1812
|
+
*/
|
|
1813
|
+
async confirmPasswordReset(data) {
|
|
1814
|
+
const response = await this.http.post(
|
|
1815
|
+
"/v1/public/app-members/password-reset/confirm",
|
|
1816
|
+
data,
|
|
1817
|
+
{ skipAuth: true }
|
|
1818
|
+
);
|
|
1819
|
+
this.http.clearTokens();
|
|
1820
|
+
this.notifyVisitorTracker(null);
|
|
1821
|
+
return response;
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* 로그인한 멤버가 현재 비밀번호를 확인하고 새 비밀번호로 바꿉니다. (AppMember 토큰 필요)
|
|
1825
|
+
*
|
|
1826
|
+
* `confirmPasswordReset` 과 마찬가지로 성공 시 **전 세션이 폐기**되고 로컬 토큰도
|
|
1827
|
+
* 정리됩니다. 호출한 클라이언트도 재로그인이 필요합니다.
|
|
1828
|
+
*
|
|
1829
|
+
* @example
|
|
1830
|
+
* ```typescript
|
|
1831
|
+
* await client.auth.changePassword({
|
|
1832
|
+
* current_password: 'oldpass123',
|
|
1833
|
+
* new_password: 'newpass123'
|
|
1834
|
+
* })
|
|
1835
|
+
* // 세션이 끊겼으므로 로그인 화면으로
|
|
1836
|
+
* location.href = '/login'
|
|
1837
|
+
* ```
|
|
1838
|
+
*/
|
|
1839
|
+
async changePassword(data) {
|
|
1840
|
+
const response = await this.http.post(
|
|
1841
|
+
"/v1/public/app-members/me/password",
|
|
1842
|
+
data
|
|
1843
|
+
);
|
|
1844
|
+
this.http.clearTokens();
|
|
1845
|
+
this.notifyVisitorTracker(null);
|
|
1846
|
+
return response;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* 로그인한 멤버에게 이메일 인증 메일을 보냅니다. (AppMember 토큰 필요)
|
|
1850
|
+
*
|
|
1851
|
+
* 가입 시 자동 발송되지 않으므로, 인증이 필요한 앱은 이 메서드를 **직접** 불러야 합니다
|
|
1852
|
+
* (예: 가입 직후, 또는 설정 화면의 "인증 메일 다시 보내기" 버튼).
|
|
1853
|
+
*
|
|
1854
|
+
* 메일 링크의 토큰은 **24시간** 만료이고 **1회만** 쓸 수 있습니다.
|
|
1855
|
+
*
|
|
1856
|
+
* 이메일 인증은 **로그인의 전제 조건이 아닙니다** — 인증하지 않아도 로그인은 됩니다.
|
|
1857
|
+
* 인증 여부로 기능을 제한할지는 앱이 `getMe()` 등으로 확인해 스스로 정합니다.
|
|
1858
|
+
*
|
|
1859
|
+
* @example
|
|
1860
|
+
* ```typescript
|
|
1861
|
+
* await client.auth.sendEmailVerification({
|
|
1862
|
+
* redirect_url: 'https://myapp.com/verify-email'
|
|
1863
|
+
* })
|
|
1864
|
+
*
|
|
1865
|
+
* // redirect_url 없이 — 플랫폼 기본 페이지 링크로 발송된다
|
|
1866
|
+
* await client.auth.sendEmailVerification()
|
|
1867
|
+
* ```
|
|
1868
|
+
*/
|
|
1869
|
+
async sendEmailVerification(data) {
|
|
1870
|
+
return this.http.post(
|
|
1871
|
+
"/v1/public/app-members/me/email-verification/request",
|
|
1872
|
+
data ?? {}
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* 인증 토큰으로 이메일 인증을 확정합니다. (로그인 불필요)
|
|
1877
|
+
*
|
|
1878
|
+
* 메일 링크가 연 페이지에서 `?token=` 을 꺼내 그대로 넘깁니다. 링크를 누른 사람이 그
|
|
1879
|
+
* 앱에 로그인해 있지 않아도(다른 기기의 메일 앱에서 열어도) 동작합니다.
|
|
1880
|
+
*
|
|
1881
|
+
* 만료(24시간), 이미 사용됨, 위조 토큰은 모두 400 이며 사유가 구분되지 않습니다.
|
|
1882
|
+
*
|
|
1883
|
+
* @example
|
|
1884
|
+
* ```typescript
|
|
1885
|
+
* const token = new URLSearchParams(location.search).get('token')
|
|
1886
|
+
* if (!token) return
|
|
1887
|
+
* try {
|
|
1888
|
+
* const r = await client.auth.confirmEmailVerification({ token })
|
|
1889
|
+
* console.log(r.email, r.is_email_verified) // true
|
|
1890
|
+
* } catch {
|
|
1891
|
+
* alert('인증 링크가 만료되었거나 이미 사용되었습니다.')
|
|
1892
|
+
* }
|
|
1893
|
+
* ```
|
|
1894
|
+
*/
|
|
1895
|
+
async confirmEmailVerification(data) {
|
|
1896
|
+
const response = await this.http.post(
|
|
1897
|
+
"/v1/public/app-members/email-verification/confirm",
|
|
1898
|
+
data,
|
|
1899
|
+
{ skipAuth: true }
|
|
1900
|
+
);
|
|
1901
|
+
assertShape(
|
|
1902
|
+
response,
|
|
1903
|
+
{
|
|
1904
|
+
member_id: { type: "string-or-number" }
|
|
1905
|
+
},
|
|
1906
|
+
"auth.confirmEmailVerification"
|
|
1907
|
+
);
|
|
1908
|
+
return response;
|
|
1909
|
+
}
|
|
1704
1910
|
/**
|
|
1705
1911
|
* 현재 로그인한 멤버 정보 조회
|
|
1706
1912
|
* custom_data를 포함한 멤버 정보를 반환합니다.
|
|
@@ -1864,6 +2070,109 @@ var AuthAPI = class {
|
|
|
1864
2070
|
this.notifyVisitorTracker(null);
|
|
1865
2071
|
}
|
|
1866
2072
|
}
|
|
2073
|
+
// ── 활성 세션(로그인된 기기) 관리 ────────────────────────────────────────
|
|
2074
|
+
//
|
|
2075
|
+
// 대상은 언제나 **로그인한 본인**이다. 세 메서드 모두 사용자 식별자를 받지 않는다 —
|
|
2076
|
+
// 누구의 세션인지는 서버가 검증된 토큰에서 정하고, 요청은 정할 수 없다.
|
|
2077
|
+
//
|
|
2078
|
+
// 폐기는 되돌릴 수 없다. 다만 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하므로,
|
|
2079
|
+
// 끊긴 기기가 곧바로 모든 호출에 실패하지는 않는다. 즉시 차단이 필요하면 앱이 서버
|
|
2080
|
+
// 측에서 회원을 정지시켜야 한다.
|
|
2081
|
+
/**
|
|
2082
|
+
* 로그인된 기기(활성 세션) 목록을 조회합니다.
|
|
2083
|
+
*
|
|
2084
|
+
* 현재 세션이 맨 앞에 오고, 나머지는 최근 사용 순입니다. 응답에는 토큰도 평문 IP 도
|
|
2085
|
+
* 담기지 않습니다.
|
|
2086
|
+
*
|
|
2087
|
+
* **`current` 를 화면에 반드시 표시하세요.** 구분이 없으면 사용자가 자기가 쓰고 있는
|
|
2088
|
+
* 기기를 끊고 영문도 모른 채 로그아웃됩니다. refresh 쿠키가 없는 환경에서는 서버가
|
|
2089
|
+
* 현재 세션을 특정하지 못해 `current` 가 어느 항목에도 붙지 않을 수 있습니다.
|
|
2090
|
+
*
|
|
2091
|
+
* @example
|
|
2092
|
+
* ```typescript
|
|
2093
|
+
* const { sessions } = await cb.auth.listSessions()
|
|
2094
|
+
* for (const s of sessions) {
|
|
2095
|
+
* console.log(s.browser, s.os, s.location, s.current ? '(이 기기)' : '')
|
|
2096
|
+
* }
|
|
2097
|
+
* ```
|
|
2098
|
+
*/
|
|
2099
|
+
async listSessions() {
|
|
2100
|
+
const response = await this.http.get(
|
|
2101
|
+
"/v1/public/app-members/me/sessions"
|
|
2102
|
+
);
|
|
2103
|
+
assertShape(
|
|
2104
|
+
response,
|
|
2105
|
+
{
|
|
2106
|
+
sessions: { type: "array" }
|
|
2107
|
+
},
|
|
2108
|
+
"auth.listSessions"
|
|
2109
|
+
);
|
|
2110
|
+
return response;
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* 기기 하나를 로그아웃시킵니다. `sessionId` 는 `listSessions()` 가 돌려준 `session_id`.
|
|
2114
|
+
*
|
|
2115
|
+
* 남의 세션 식별자를 넣으면 404 입니다 — 존재 여부조차 구분해 알려주지 않습니다.
|
|
2116
|
+
* 이미 끝난 세션도 같은 404 입니다.
|
|
2117
|
+
*
|
|
2118
|
+
* **현재 세션을 지목하면 그 자리에서 로그아웃됩니다.** 서버가 refresh 쿠키를 함께
|
|
2119
|
+
* 만료시키므로 로컬 토큰도 정리합니다 — 이 경우 앱은 로그인 화면으로 보내야 합니다.
|
|
2120
|
+
* 그렇지 않은 기기를 끊는 것은 현재 세션에 영향을 주지 않습니다.
|
|
2121
|
+
*
|
|
2122
|
+
* @example
|
|
2123
|
+
* ```typescript
|
|
2124
|
+
* const target = sessions.find((s) => !s.current)
|
|
2125
|
+
* if (target) await cb.auth.revokeSession(target.session_id)
|
|
2126
|
+
* ```
|
|
2127
|
+
*/
|
|
2128
|
+
async revokeSession(sessionId) {
|
|
2129
|
+
const current = await this.isCurrentSession(sessionId);
|
|
2130
|
+
const response = await this.http.delete(
|
|
2131
|
+
`/v1/public/app-members/me/sessions/${encodeURIComponent(sessionId)}`
|
|
2132
|
+
);
|
|
2133
|
+
if (current) {
|
|
2134
|
+
this.http.clearTokens();
|
|
2135
|
+
this.notifyVisitorTracker(null);
|
|
2136
|
+
}
|
|
2137
|
+
return response;
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* **현재 기기만 남기고** 나머지 기기에서 모두 로그아웃합니다.
|
|
2141
|
+
*
|
|
2142
|
+
* 자기 세션까지 포함한 전체 로그아웃은 `signOut()` 입니다 — 자격증명 유출 대응처럼
|
|
2143
|
+
* 모든 기기를 내보내야 하는 상황에서는 그쪽을 쓰세요.
|
|
2144
|
+
*
|
|
2145
|
+
* refresh 쿠키가 없어 서버가 현재 세션을 특정하지 못하면 이 호출은 **실패합니다**
|
|
2146
|
+
* (400). 기준점 없이 진행하면 사용자가 의도하지 않은 자기 세션 종료가 함께 일어나기
|
|
2147
|
+
* 때문입니다. 그 경우 `signOut()` 후 재로그인을 안내하세요.
|
|
2148
|
+
*
|
|
2149
|
+
* 반환되는 `revoked` 는 토큰 수가 아니라 **끊긴 기기 수** 입니다.
|
|
2150
|
+
*
|
|
2151
|
+
* @example
|
|
2152
|
+
* ```typescript
|
|
2153
|
+
* const { revoked } = await cb.auth.revokeOtherSessions()
|
|
2154
|
+
* toast(`${revoked}개 기기에서 로그아웃했습니다`)
|
|
2155
|
+
* ```
|
|
2156
|
+
*/
|
|
2157
|
+
async revokeOtherSessions() {
|
|
2158
|
+
return this.http.delete(
|
|
2159
|
+
"/v1/public/app-members/me/sessions"
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* 폐기 대상이 현재 세션인지 미리 확인한다. 목록 조회가 실패하면 false 로 떨어져
|
|
2164
|
+
* 폐기 자체는 진행한다 — 판정 실패로 사용자의 로그아웃 요청을 막을 이유가 없다.
|
|
2165
|
+
*/
|
|
2166
|
+
async isCurrentSession(sessionId) {
|
|
2167
|
+
try {
|
|
2168
|
+
const { sessions } = await this.listSessions();
|
|
2169
|
+
return sessions.some(
|
|
2170
|
+
(s) => s.session_id === sessionId && s.current === true
|
|
2171
|
+
);
|
|
2172
|
+
} catch {
|
|
2173
|
+
return false;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
1867
2176
|
};
|
|
1868
2177
|
|
|
1869
2178
|
// src/api/database.ts
|
|
@@ -6582,6 +6891,199 @@ var OAuthAPI = class {
|
|
|
6582
6891
|
}
|
|
6583
6892
|
};
|
|
6584
6893
|
|
|
6894
|
+
// src/api/organizations.ts
|
|
6895
|
+
var OrganizationsAPI = class {
|
|
6896
|
+
constructor(http) {
|
|
6897
|
+
this.http = http;
|
|
6898
|
+
/** 조직 라우트는 `/v1/public` 아래에만 있습니다. */
|
|
6899
|
+
this.prefix = "/v1/public/organizations";
|
|
6900
|
+
}
|
|
6901
|
+
// =====================
|
|
6902
|
+
// 조직
|
|
6903
|
+
// =====================
|
|
6904
|
+
/**
|
|
6905
|
+
* 조직을 만듭니다. **만든 사람이 자동으로 `owner`** 가 됩니다.
|
|
6906
|
+
*
|
|
6907
|
+
* @example
|
|
6908
|
+
* ```typescript
|
|
6909
|
+
* const org = await cb.organizations.create({ name: '우리 팀' })
|
|
6910
|
+
* // slug 를 생략하면 name 에서 파생되고, 충돌하면 짧은 접미사가 붙는다
|
|
6911
|
+
* ```
|
|
6912
|
+
*/
|
|
6913
|
+
async create(data) {
|
|
6914
|
+
return this.http.post(this.prefix, data);
|
|
6915
|
+
}
|
|
6916
|
+
/**
|
|
6917
|
+
* 내가 속한 조직 목록. 조직 전환 UI 를 그릴 때 씁니다.
|
|
6918
|
+
*
|
|
6919
|
+
* 각 항목의 `my_role` 이 채워집니다. `member_count` 는 목록에서는 실리지 않습니다
|
|
6920
|
+
* (조직마다 COUNT 를 돌면 N+1 이라 상세 조회에서만 채워집니다).
|
|
6921
|
+
*/
|
|
6922
|
+
async listMine() {
|
|
6923
|
+
const res = await this.http.get(
|
|
6924
|
+
this.prefix
|
|
6925
|
+
);
|
|
6926
|
+
return res.organizations ?? [];
|
|
6927
|
+
}
|
|
6928
|
+
/**
|
|
6929
|
+
* 조직 상세. `my_role` 과 `member_count` 가 함께 실립니다.
|
|
6930
|
+
*
|
|
6931
|
+
* 내가 속하지 않은 조직은 **403 이 아니라 404** 입니다 — 403 을 주면 그것만으로 그 조직이
|
|
6932
|
+
* 실재한다는 사실이 새어 조직 ID 열거가 가능해지기 때문입니다.
|
|
6933
|
+
*/
|
|
6934
|
+
async get(organizationId) {
|
|
6935
|
+
return this.http.get(`${this.prefix}/${organizationId}`);
|
|
6936
|
+
}
|
|
6937
|
+
/**
|
|
6938
|
+
* 조직 정보 수정.
|
|
6939
|
+
*
|
|
6940
|
+
* **`slug` 는 바꿀 수 없습니다** — 변경을 허용하면 조직 URL 과 외부 참조가 조용히 깨지고,
|
|
6941
|
+
* 놓아준 slug 를 다른 조직이 선점해 다른 조직의 페이지가 열립니다.
|
|
6942
|
+
*/
|
|
6943
|
+
async update(organizationId, data) {
|
|
6944
|
+
return this.http.patch(
|
|
6945
|
+
`${this.prefix}/${organizationId}`,
|
|
6946
|
+
data
|
|
6947
|
+
);
|
|
6948
|
+
}
|
|
6949
|
+
/** 조직 삭제. */
|
|
6950
|
+
async delete(organizationId) {
|
|
6951
|
+
return this.http.delete(
|
|
6952
|
+
`${this.prefix}/${organizationId}`
|
|
6953
|
+
);
|
|
6954
|
+
}
|
|
6955
|
+
// =====================
|
|
6956
|
+
// 멤버
|
|
6957
|
+
// =====================
|
|
6958
|
+
/**
|
|
6959
|
+
* 조직 멤버 목록.
|
|
6960
|
+
*
|
|
6961
|
+
* 닉네임/이메일 같은 **프로필은 실리지 않습니다** — 조직에 초대되기만 하면 다른 구성원의
|
|
6962
|
+
* 이메일을 전부 수집할 수 있게 되기 때문입니다. 프로필이 필요하면 `member_id` 로
|
|
6963
|
+
* `cb.appMembers.*` 를 조회하세요.
|
|
6964
|
+
*/
|
|
6965
|
+
async listMembers(organizationId, params) {
|
|
6966
|
+
const query = new URLSearchParams();
|
|
6967
|
+
if (params?.limit !== void 0) query.set("limit", String(params.limit));
|
|
6968
|
+
if (params?.offset !== void 0)
|
|
6969
|
+
query.set("offset", String(params.offset));
|
|
6970
|
+
const qs = query.toString();
|
|
6971
|
+
return this.http.get(
|
|
6972
|
+
`${this.prefix}/${organizationId}/members${qs ? `?${qs}` : ""}`
|
|
6973
|
+
);
|
|
6974
|
+
}
|
|
6975
|
+
/**
|
|
6976
|
+
* 멤버의 조직 역할 변경.
|
|
6977
|
+
*
|
|
6978
|
+
* 소유자는 별도 컬럼이 아니라 `role === 'owner'` 인 행에서 파생되므로, 마지막 owner 의
|
|
6979
|
+
* 역할을 내리는 것은 거부됩니다 (403).
|
|
6980
|
+
*/
|
|
6981
|
+
async updateMemberRole(organizationId, memberId, role) {
|
|
6982
|
+
return this.http.patch(
|
|
6983
|
+
`${this.prefix}/${organizationId}/members/${memberId}`,
|
|
6984
|
+
{ role }
|
|
6985
|
+
);
|
|
6986
|
+
}
|
|
6987
|
+
/**
|
|
6988
|
+
* 멤버 제거. **본인 ID 를 넘기면 조직 탈퇴**입니다.
|
|
6989
|
+
*
|
|
6990
|
+
* 마지막 owner 는 제거할 수 없습니다 (403) — 소유자 없는 조직은 아무도 삭제할 수 없게
|
|
6991
|
+
* 되기 때문입니다.
|
|
6992
|
+
*/
|
|
6993
|
+
async removeMember(organizationId, memberId) {
|
|
6994
|
+
return this.http.delete(
|
|
6995
|
+
`${this.prefix}/${organizationId}/members/${memberId}`
|
|
6996
|
+
);
|
|
6997
|
+
}
|
|
6998
|
+
// =====================
|
|
6999
|
+
// 초대
|
|
7000
|
+
// =====================
|
|
7001
|
+
/**
|
|
7002
|
+
* 초대 생성.
|
|
7003
|
+
*
|
|
7004
|
+
* **평문 토큰은 이 응답에서만 볼 수 있습니다** (DB 에는 SHA-256 만 남습니다).
|
|
7005
|
+
* **초대 메일 발송은 플랫폼이 대신하지 않습니다** — 앱마다 문안과 발송 채널이 다르므로,
|
|
7006
|
+
* 받은 토큰을 초대 대상에게 전달하는 것은 앱의 몫입니다.
|
|
7007
|
+
*
|
|
7008
|
+
* @example
|
|
7009
|
+
* ```typescript
|
|
7010
|
+
* const { invitation, token } = await cb.organizations.createInvitation(orgId, {
|
|
7011
|
+
* email: 'teammate@example.com',
|
|
7012
|
+
* role: 'member',
|
|
7013
|
+
* })
|
|
7014
|
+
* // token 을 담은 초대 링크를 앱이 직접 발송한다
|
|
7015
|
+
* await sendMyInviteMail(invitation.email, `https://myapp.com/join?token=${token}`)
|
|
7016
|
+
* ```
|
|
7017
|
+
*/
|
|
7018
|
+
async createInvitation(organizationId, data) {
|
|
7019
|
+
return this.http.post(
|
|
7020
|
+
`${this.prefix}/${organizationId}/invitations`,
|
|
7021
|
+
data
|
|
7022
|
+
);
|
|
7023
|
+
}
|
|
7024
|
+
/** 조직의 초대 목록. 평문 토큰은 실리지 않습니다. */
|
|
7025
|
+
async listInvitations(organizationId) {
|
|
7026
|
+
const res = await this.http.get(`${this.prefix}/${organizationId}/invitations`);
|
|
7027
|
+
return res.invitations ?? [];
|
|
7028
|
+
}
|
|
7029
|
+
/** 초대 취소. */
|
|
7030
|
+
async revokeInvitation(organizationId, invitationId) {
|
|
7031
|
+
return this.http.delete(
|
|
7032
|
+
`${this.prefix}/${organizationId}/invitations/${invitationId}`
|
|
7033
|
+
);
|
|
7034
|
+
}
|
|
7035
|
+
/**
|
|
7036
|
+
* 초대 토큰으로 조직에 합류합니다. 조직 ID 를 몰라도 됩니다.
|
|
7037
|
+
*
|
|
7038
|
+
* 수락자의 이메일은 **로그인한 회원 토큰의 클레임**에서 가져오므로, 초대 대상과 다른
|
|
7039
|
+
* 계정으로 로그인한 상태면 거부됩니다 (403).
|
|
7040
|
+
* 없는 토큰/만료/이미 사용됨/취소됨은 **구분하지 않고 404** 입니다 (토큰 추측 방지).
|
|
7041
|
+
*/
|
|
7042
|
+
async acceptInvitation(token) {
|
|
7043
|
+
return this.http.post(
|
|
7044
|
+
`${this.prefix}/invitations/accept`,
|
|
7045
|
+
{ token }
|
|
7046
|
+
);
|
|
7047
|
+
}
|
|
7048
|
+
// =====================
|
|
7049
|
+
// 조직 컨텍스트 (조직 전환)
|
|
7050
|
+
// =====================
|
|
7051
|
+
/**
|
|
7052
|
+
* 조직을 전환합니다 — 활성 조직이 실린 새 액세스 토큰을 받아 **SDK 에 자동 적용**합니다.
|
|
7053
|
+
*
|
|
7054
|
+
* 데이터베이스 보안 규칙(RLS)의 `auth.org_id` / `auth.org_role` 을 채우는 **유일한
|
|
7055
|
+
* 경로**이며, 호출할 때마다 소속과 역할을 DB 에서 다시 확인합니다.
|
|
7056
|
+
*
|
|
7057
|
+
* ## 반드시 주기적으로 다시 호출하세요
|
|
7058
|
+
*
|
|
7059
|
+
* 조직 컨텍스트의 수명은 반환값의 `org_context_expires_in`(기본 600초) 입니다. 액세스
|
|
7060
|
+
* 토큰(1시간)과 독립적이라, 만료되어도 401 이 나지 않고 조직 규칙만 조용히 거부됩니다.
|
|
7061
|
+
*
|
|
7062
|
+
* ## 세션을 연장하지 않습니다
|
|
7063
|
+
*
|
|
7064
|
+
* 새 토큰의 수명은 `min(1시간, 지금 토큰의 남은 수명)` 입니다. 전환으로 세션을 무한
|
|
7065
|
+
* 연장할 수 없게 하기 위한 제약이며, refresh token 은 건드리지 않습니다. 토큰 회전이
|
|
7066
|
+
* 일어나면 조직 컨텍스트는 사라지므로 다시 호출해야 합니다.
|
|
7067
|
+
*
|
|
7068
|
+
* @example
|
|
7069
|
+
* ```typescript
|
|
7070
|
+
* const ctx = await cb.organizations.switchTo(orgId)
|
|
7071
|
+
* console.log(ctx.role, ctx.org_context_expires_in)
|
|
7072
|
+
* // 이 시점부터 DB 요청은 auth.org_id 가 채워진 상태로 나간다
|
|
7073
|
+
* ```
|
|
7074
|
+
*/
|
|
7075
|
+
async switchTo(organizationId) {
|
|
7076
|
+
const ctx = await this.http.post(
|
|
7077
|
+
`${this.prefix}/${organizationId}/context`,
|
|
7078
|
+
{}
|
|
7079
|
+
);
|
|
7080
|
+
if (ctx?.access_token) {
|
|
7081
|
+
this.http.setAccessToken(ctx.access_token);
|
|
7082
|
+
}
|
|
7083
|
+
return ctx;
|
|
7084
|
+
}
|
|
7085
|
+
};
|
|
7086
|
+
|
|
6585
7087
|
// src/api/payment.ts
|
|
6586
7088
|
var PaymentAPI = class {
|
|
6587
7089
|
constructor(http) {
|
|
@@ -6887,6 +7389,35 @@ var PublicKeyAPI = class {
|
|
|
6887
7389
|
this.ensureServerAuth("deletePublicKey");
|
|
6888
7390
|
await this.http.delete(`/v1/apps/${appId}/public-keys/${keyId}`);
|
|
6889
7391
|
}
|
|
7392
|
+
/**
|
|
7393
|
+
* Public Key 를 **무중단 회전**합니다 (management_scope: `publickey:manage`)
|
|
7394
|
+
*
|
|
7395
|
+
* 새 키를 발급하고 옛 키는 유예 기간 동안 함께 유효하게 둔 뒤 만료시킵니다. 옛 키의
|
|
7396
|
+
* 스코프와 `payment_mode` 는 새 키가 물려받습니다 — 리셋되면 읽기 전용 키가 회전 한 번에
|
|
7397
|
+
* 전권이 되기 때문입니다.
|
|
7398
|
+
*
|
|
7399
|
+
* **반환되는 `new_key.key` 는 이 응답에서만 볼 수 있습니다.**
|
|
7400
|
+
*
|
|
7401
|
+
* @param appId 앱 ID
|
|
7402
|
+
* @param keyId 회전할 Public Key ID
|
|
7403
|
+
* @param data 유예 기간(기본 24시간, 상한 720시간)과 새 키 이름
|
|
7404
|
+
*
|
|
7405
|
+
* @example
|
|
7406
|
+
* ```typescript
|
|
7407
|
+
* const rotated = await cb.publicKey.rotatePublicKey('app-id', 'key-id', {
|
|
7408
|
+
* grace_period_hours: 72,
|
|
7409
|
+
* })
|
|
7410
|
+
* // 1. rotated.new_key.key 를 배포 파이프라인에 반영
|
|
7411
|
+
* // 2. rotated.previous_key_expires_at 전에 모든 클라이언트 배포를 끝낸다
|
|
7412
|
+
* ```
|
|
7413
|
+
*/
|
|
7414
|
+
async rotatePublicKey(appId, keyId, data) {
|
|
7415
|
+
this.ensureServerAuth("rotatePublicKey");
|
|
7416
|
+
return this.http.post(
|
|
7417
|
+
`/v1/apps/${appId}/public-keys/${keyId}/rotate`,
|
|
7418
|
+
data ?? {}
|
|
7419
|
+
);
|
|
7420
|
+
}
|
|
6890
7421
|
};
|
|
6891
7422
|
|
|
6892
7423
|
// src/api/push.ts
|
|
@@ -7265,13 +7796,13 @@ var QueueAPI = class {
|
|
|
7265
7796
|
this.http = http;
|
|
7266
7797
|
}
|
|
7267
7798
|
/**
|
|
7268
|
-
* 메시지 발행
|
|
7799
|
+
* 메시지 발행 — 엔드유저 동작. 퍼블릭 키만으로 동작합니다.
|
|
7269
7800
|
*/
|
|
7270
7801
|
async publish(queueID, data) {
|
|
7271
7802
|
return this.http.post(`/v1/public/queues/${queueID}/messages`, data);
|
|
7272
7803
|
}
|
|
7273
7804
|
/**
|
|
7274
|
-
* 배치 메시지 발행 (최대 100개)
|
|
7805
|
+
* 배치 메시지 발행 (최대 100개) — 엔드유저 동작. 퍼블릭 키만으로 동작합니다.
|
|
7275
7806
|
*/
|
|
7276
7807
|
async publishBatch(queueID, data) {
|
|
7277
7808
|
return this.http.post(`/v1/public/queues/${queueID}/messages/batch`, data);
|
|
@@ -7279,6 +7810,9 @@ var QueueAPI = class {
|
|
|
7279
7810
|
/**
|
|
7280
7811
|
* 메시지 소비 (Pull 방식)
|
|
7281
7812
|
*
|
|
7813
|
+
* **워커 동작 — Secret Key 필수.** `new ConnectBase({ publicKey, secretKey })` 로 만든
|
|
7814
|
+
* 서버사이드 클라이언트로 호출하세요. 퍼블릭 키만이면 401 `SECRET_KEY_REQUIRED`.
|
|
7815
|
+
*
|
|
7282
7816
|
* auto_ack=false(기본값): 명시적 Ack 필요. 응답의 ack_token을 ack() 호출 시 전달.
|
|
7283
7817
|
* auto_ack=true: 즉시 자동 Ack (at-most-once, 유실 가능)
|
|
7284
7818
|
*/
|
|
@@ -7306,6 +7840,8 @@ var QueueAPI = class {
|
|
|
7306
7840
|
/**
|
|
7307
7841
|
* 메시지 처리 완료 확인 (Ack)
|
|
7308
7842
|
*
|
|
7843
|
+
* **워커 동작 — Secret Key 필수.** 퍼블릭 키만이면 401 `SECRET_KEY_REQUIRED`.
|
|
7844
|
+
*
|
|
7309
7845
|
* ackToken: auto_ack=false 소비 시 반환된 ack_token. 생략 시 message_ids로만 확인.
|
|
7310
7846
|
*/
|
|
7311
7847
|
async ack(queueID, messageIds, ackToken) {
|
|
@@ -7317,6 +7853,8 @@ var QueueAPI = class {
|
|
|
7317
7853
|
}
|
|
7318
7854
|
/**
|
|
7319
7855
|
* 메시지 재시도 요청 (Nack)
|
|
7856
|
+
*
|
|
7857
|
+
* **워커 동작 — Secret Key 필수.** 퍼블릭 키만이면 401 `SECRET_KEY_REQUIRED`.
|
|
7320
7858
|
*/
|
|
7321
7859
|
async nack(queueID, messageId, options) {
|
|
7322
7860
|
return this.http.post(
|
|
@@ -7325,7 +7863,9 @@ var QueueAPI = class {
|
|
|
7325
7863
|
);
|
|
7326
7864
|
}
|
|
7327
7865
|
/**
|
|
7328
|
-
* 큐 정보 조회
|
|
7866
|
+
* 큐 정보 조회 (큐 목록/깊이 등 운영 정보)
|
|
7867
|
+
*
|
|
7868
|
+
* **워커 동작 — Secret Key 필수.** 퍼블릭 키만이면 401 `SECRET_KEY_REQUIRED`.
|
|
7329
7869
|
*/
|
|
7330
7870
|
async getInfo(queueID) {
|
|
7331
7871
|
return this.http.get(`/v1/public/queues/${queueID}`);
|
|
@@ -9090,6 +9630,10 @@ var StorageAPI = class {
|
|
|
9090
9630
|
/**
|
|
9091
9631
|
* 파일 목록 조회
|
|
9092
9632
|
*
|
|
9633
|
+
* 접근 수준이 `private` 인 스토리지에서는 **자기가 올린 파일만** 반환하며, 멤버 토큰 없이
|
|
9634
|
+
* 호출하면 401 이다 (빈 배열이 아니라 에러다 — 빈 목록은 "스토리지가 비었다" 와 구분되지
|
|
9635
|
+
* 않아 원인을 찾을 수 없기 때문). `shared` / `public_read` 는 종전대로 전체를 반환한다.
|
|
9636
|
+
*
|
|
9093
9637
|
* @param storageId - 파일 스토리지 ID
|
|
9094
9638
|
* @param parentId - 부모 폴더 ID. 지정 시 해당 폴더의 **직계 자식만** 반환한다.
|
|
9095
9639
|
* 미지정 시 스토리지 **전체 파일 트리**를 flat 배열로 반환한다.
|
|
@@ -9116,6 +9660,12 @@ var StorageAPI = class {
|
|
|
9116
9660
|
*
|
|
9117
9661
|
* 서버를 거치지 않고 Object Storage에 직접 업로드합니다.
|
|
9118
9662
|
*
|
|
9663
|
+
* `Authorization: Bearer <AppMember 토큰>` 을 함께 보내면 업로드한 파일이 **그 멤버에게
|
|
9664
|
+
* 귀속**되어, 이후 그 멤버(와 앱 관리자)만 지울 수 있다. 토큰 없이 올린 파일은 귀속이 없어
|
|
9665
|
+
* `shared` 스토리지에서는 누구나 지울 수 있으므로, 사용자 업로드를 받는 앱이라면 로그인
|
|
9666
|
+
* 세션과 함께 호출하는 것을 권장한다. 접근 수준이 `private` 인 스토리지는 멤버 토큰이 없으면
|
|
9667
|
+
* 401 이다.
|
|
9668
|
+
*
|
|
9119
9669
|
* 3번째 인자는 부모 폴더 ID(문자열) 또는 옵션 객체를 받습니다. 옵션으로
|
|
9120
9670
|
* `onProgress` 콜백을 넘기면 업로드 진행률(%)을 실시간으로 받을 수 있습니다
|
|
9121
9671
|
* (브라우저 환경). 기존 `uploadFile(id, file, 'folder-id')` 호출과 하위 호환됩니다.
|
|
@@ -9185,6 +9735,10 @@ var StorageAPI = class {
|
|
|
9185
9735
|
}
|
|
9186
9736
|
/**
|
|
9187
9737
|
* 폴더 생성
|
|
9738
|
+
*
|
|
9739
|
+
* 업로드와 같은 귀속 규칙이 적용된다 — 멤버 토큰과 함께 만들면 그 멤버 소유가 된다.
|
|
9740
|
+
* 폴더 삭제는 CASCADE 라, 폴더 안에 **남이 올린 파일이 하나라도 있으면** 폴더 소유자라도
|
|
9741
|
+
* 지울 수 없다(403).
|
|
9188
9742
|
*/
|
|
9189
9743
|
async createFolder(storageId, data) {
|
|
9190
9744
|
const prefix = this.getPublicPrefix();
|
|
@@ -9195,6 +9749,13 @@ var StorageAPI = class {
|
|
|
9195
9749
|
}
|
|
9196
9750
|
/**
|
|
9197
9751
|
* 파일/폴더 삭제
|
|
9752
|
+
*
|
|
9753
|
+
* **업로더 본인 또는 앱 관리자만** 지울 수 있다. 남의 파일이면 403
|
|
9754
|
+
* (`이 파일의 업로더만 수정/삭제할 수 있습니다`), 애초에 볼 수 없는 파일이면 404 다.
|
|
9755
|
+
*
|
|
9756
|
+
* 운영 도구처럼 앱의 모든 파일을 지워야 하는 코드라면 `Authorization: Bearer cb_sk_...`
|
|
9757
|
+
* (Secret Key)를 함께 보낸다. Secret Key 는 브라우저 번들에 넣으면 안 되므로 서버 쪽에서만
|
|
9758
|
+
* 사용한다.
|
|
9198
9759
|
*/
|
|
9199
9760
|
async deleteFile(storageId, fileId) {
|
|
9200
9761
|
const prefix = this.getPublicPrefix();
|
|
@@ -9252,6 +9813,10 @@ var StorageAPI = class {
|
|
|
9252
9813
|
* 같은 경로에 파일이 이미 존재하면 덮어쓰기합니다.
|
|
9253
9814
|
* URL이 변경되지 않아 고정 URL이 필요한 경우에 유용합니다.
|
|
9254
9815
|
*
|
|
9816
|
+
* **덮어쓰기는 삭제와 같은 권한을 요구한다.** URL 은 그대로 둔 채 내용만 바뀌므로, 소유권
|
|
9817
|
+
* 검사가 없으면 사이트가 참조 중인 이미지를 임의 콘텐츠로 갈아치울 수 있기 때문이다.
|
|
9818
|
+
* 기존 파일의 업로더 본인이나 앱 관리자가 아니면 403 이다.
|
|
9819
|
+
*
|
|
9255
9820
|
* @example
|
|
9256
9821
|
* ```ts
|
|
9257
9822
|
* // 프로필 이미지 업로드 (항상 같은 URL 유지)
|
|
@@ -9564,7 +10129,29 @@ var SubscriptionAPI = class {
|
|
|
9564
10129
|
/**
|
|
9565
10130
|
* 빌링키 삭제
|
|
9566
10131
|
*
|
|
10132
|
+
* **PG 쪽 등록까지 지워졌는지는 프로바이더마다 다릅니다.** 반환값의 `provider_revoked` 를
|
|
10133
|
+
* 반드시 확인하세요 — `false` 면 Connect Base 기록만 지워졌고 PG 등록은 남아 있습니다.
|
|
10134
|
+
*
|
|
10135
|
+
* | 프로바이더 | PG 해지 | 동작 |
|
|
10136
|
+
* |-----------|---------|------|
|
|
10137
|
+
* | `toss` | O | 토스 빌링키 삭제 API 호출. PG 삭제가 실패하면 DB 행도 지우지 않고 에러 |
|
|
10138
|
+
* | `payapp` / `paypal` / `paddle` / `stripe` | X | "결제수단 삭제" API 가 없어 기록만 삭제. `provider_revoked: false` + `provider_revoke_note` |
|
|
10139
|
+
*
|
|
10140
|
+
* 살아 있는 구독(`active` / `trial` / `paused` / `past_due`)이 그 빌링키를 물고 있으면
|
|
10141
|
+
* PG 해지 경로가 없는 프로바이더에서는 `409 billing_key_bound_to_subscription` 으로
|
|
10142
|
+
* 거절됩니다 (구독을 먼저 해지하세요). **toss 는 이 검사를 하지 않고 그대로 진행합니다** —
|
|
10143
|
+
* PG 등록이 실제로 사라지므로 청구도 함께 멈추기 때문입니다.
|
|
10144
|
+
*
|
|
9567
10145
|
* @param billingKeyId - 빌링키 ID
|
|
10146
|
+
* @returns 삭제 결과 (PG 해지 여부 포함)
|
|
10147
|
+
*
|
|
10148
|
+
* @example
|
|
10149
|
+
* ```typescript
|
|
10150
|
+
* const result = await client.subscription.deleteBillingKey(billingKeyId)
|
|
10151
|
+
* if (!result.provider_revoked) {
|
|
10152
|
+
* alert(result.provider_revoke_note) // PG 등록이 남아 있음을 사용자에게 알린다
|
|
10153
|
+
* }
|
|
10154
|
+
* ```
|
|
9568
10155
|
*/
|
|
9569
10156
|
async deleteBillingKey(billingKeyId) {
|
|
9570
10157
|
const prefix = this.getPublicPrefix();
|
|
@@ -10579,6 +11166,19 @@ var VideoAPI = class {
|
|
|
10579
11166
|
}
|
|
10580
11167
|
/**
|
|
10581
11168
|
* Update video details
|
|
11169
|
+
*
|
|
11170
|
+
* ## 소유권 (2026-09 도입)
|
|
11171
|
+
*
|
|
11172
|
+
* **영상을 올린 멤버 본인 또는 앱 관리자만** 수정할 수 있다. 퍼블릭 키(`cb_pk_*`)는
|
|
11173
|
+
* 브라우저 번들에 실려 배포되는 공개값이라 그것만으로는 남의 영상을 다룰 수 없다.
|
|
11174
|
+
*
|
|
11175
|
+
* - `Authorization: Bearer <AppMember 토큰>` — 그 멤버가 올린 영상만
|
|
11176
|
+
* - `Authorization: Bearer cb_sk_...` (Secret Key, 서버 전용) — 앱의 모든 영상
|
|
11177
|
+
*
|
|
11178
|
+
* 남의 영상이면 403 (`NOT_RESOURCE_OWNER`) 이다.
|
|
11179
|
+
*
|
|
11180
|
+
* 업로더 정보가 없는 **기존 영상**(멤버 토큰 없이 업로드된 것)은 스토리지 접근 수준이
|
|
11181
|
+
* `shared` 일 때 종전대로 수정할 수 있다 — 운영 중인 앱의 동작을 바꾸지 않기 위한 하위호환.
|
|
10582
11182
|
*/
|
|
10583
11183
|
async update(videoId, data) {
|
|
10584
11184
|
const prefix = this.getPublicPrefix();
|
|
@@ -10586,6 +11186,8 @@ var VideoAPI = class {
|
|
|
10586
11186
|
}
|
|
10587
11187
|
/**
|
|
10588
11188
|
* Delete a video
|
|
11189
|
+
*
|
|
11190
|
+
* `update()` 와 동일한 소유권 규칙이 적용된다 — 업로더 본인 또는 앱 관리자만 삭제할 수 있다.
|
|
10589
11191
|
*/
|
|
10590
11192
|
async delete(videoId) {
|
|
10591
11193
|
const prefix = this.getPublicPrefix();
|
|
@@ -11729,6 +12331,7 @@ function fetchCredentialsForPath(url) {
|
|
|
11729
12331
|
return path.startsWith("/v1/public/") || path.startsWith("/v1/proxy/") ? "omit" : "include";
|
|
11730
12332
|
}
|
|
11731
12333
|
var TOKEN_STORAGE_KEY = "cb_auth_tokens";
|
|
12334
|
+
var NO_COOKIE_SESSION_COOLDOWN_MS = 6e4;
|
|
11732
12335
|
function gatewayCodeFromStatus(status) {
|
|
11733
12336
|
switch (status) {
|
|
11734
12337
|
case 502:
|
|
@@ -11873,6 +12476,7 @@ var HttpClient = class {
|
|
|
11873
12476
|
this.config.refreshToken = refreshToken;
|
|
11874
12477
|
this.persistTokens();
|
|
11875
12478
|
this.markSessionHint();
|
|
12479
|
+
this.clearNoCookieSessionMark();
|
|
11876
12480
|
}
|
|
11877
12481
|
clearTokens() {
|
|
11878
12482
|
this.config.accessToken = void 0;
|
|
@@ -11880,6 +12484,20 @@ var HttpClient = class {
|
|
|
11880
12484
|
this.removePersistedTokens();
|
|
11881
12485
|
this.clearSessionHint();
|
|
11882
12486
|
}
|
|
12487
|
+
/**
|
|
12488
|
+
* Access Token 만 교체한다 (refresh token 은 건드리지 않는다).
|
|
12489
|
+
*
|
|
12490
|
+
* 조직 컨텍스트 발급(`POST /v1/public/organizations/:orgID/context`)처럼 **새 액세스
|
|
12491
|
+
* 토큰만 돌려주는** 엔드포인트를 위한 것이다. 그 엔드포인트는 refresh token 을 회전시키지
|
|
12492
|
+
* 않으므로 `setTokens()` 로 빈 refresh token 을 덮어쓰면 세션 복구가 깨진다.
|
|
12493
|
+
*
|
|
12494
|
+
* 세션이 새로 생기는 것이 아니므로 세션 힌트/쿠키 없음 마커는 건드리지 않는다 — 그 두
|
|
12495
|
+
* 마커는 로그인/재발급이 소유한다.
|
|
12496
|
+
*/
|
|
12497
|
+
setAccessToken(accessToken) {
|
|
12498
|
+
this.config.accessToken = accessToken;
|
|
12499
|
+
this.persistTokens();
|
|
12500
|
+
}
|
|
11883
12501
|
// ===== 세션 힌트 =====
|
|
11884
12502
|
//
|
|
11885
12503
|
// "이 origin 에서 이 앱으로 로그인한 적이 있다"는 boolean 마커 (토큰 아님 — 민감정보 없음).
|
|
@@ -11916,6 +12534,45 @@ var HttpClient = class {
|
|
|
11916
12534
|
return true;
|
|
11917
12535
|
}
|
|
11918
12536
|
}
|
|
12537
|
+
// ===== cookie 세션 없음 마커 (platform-issue 01a0237f) =====
|
|
12538
|
+
//
|
|
12539
|
+
// `refreshLockedUntil` 백오프는 인스턴스 안에서만 산다. 앱이 클라이언트를 새로 만들 때마다
|
|
12540
|
+
// 초기화되므로, 쿠키가 없는 브라우저에서는 인증 호출마다 re-issue 401 이 새로 나간다.
|
|
12541
|
+
// 이 마커는 같은 사실을 localStorage 에 짧게 남겨 인스턴스 경계를 넘어 공유한다.
|
|
12542
|
+
buildNoCookieSessionKey() {
|
|
12543
|
+
return `${this.storageKey}:no_cookie_session`;
|
|
12544
|
+
}
|
|
12545
|
+
markNoCookieSession() {
|
|
12546
|
+
if (typeof window === "undefined") return;
|
|
12547
|
+
try {
|
|
12548
|
+
localStorage.setItem(this.buildNoCookieSessionKey(), String(Date.now()));
|
|
12549
|
+
} catch {
|
|
12550
|
+
}
|
|
12551
|
+
}
|
|
12552
|
+
clearNoCookieSessionMark() {
|
|
12553
|
+
if (typeof window === "undefined") return;
|
|
12554
|
+
try {
|
|
12555
|
+
localStorage.removeItem(this.buildNoCookieSessionKey());
|
|
12556
|
+
} catch {
|
|
12557
|
+
}
|
|
12558
|
+
}
|
|
12559
|
+
/** 쿠키 전용 refresh 를 지금 건너뛰어야 하는지 (최근에 401/403 을 받았는지). */
|
|
12560
|
+
isNoCookieSessionMarkFresh() {
|
|
12561
|
+
if (typeof window === "undefined") return false;
|
|
12562
|
+
try {
|
|
12563
|
+
const raw = localStorage.getItem(this.buildNoCookieSessionKey());
|
|
12564
|
+
if (!raw) return false;
|
|
12565
|
+
const at = Number.parseInt(raw, 10);
|
|
12566
|
+
if (!Number.isFinite(at)) return false;
|
|
12567
|
+
if (Date.now() - at >= NO_COOKIE_SESSION_COOLDOWN_MS) {
|
|
12568
|
+
this.clearNoCookieSessionMark();
|
|
12569
|
+
return false;
|
|
12570
|
+
}
|
|
12571
|
+
return true;
|
|
12572
|
+
} catch {
|
|
12573
|
+
return false;
|
|
12574
|
+
}
|
|
12575
|
+
}
|
|
11919
12576
|
/**
|
|
11920
12577
|
* OAuth redirect callback 직후 호출되어 HttpOnly cookie 를 부트스트랩한다.
|
|
11921
12578
|
*
|
|
@@ -12101,6 +12758,11 @@ var HttpClient = class {
|
|
|
12101
12758
|
this.config.onAuthError?.(error);
|
|
12102
12759
|
throw error;
|
|
12103
12760
|
}
|
|
12761
|
+
if (!this.config.refreshToken && this.isNoCookieSessionMarkFresh()) {
|
|
12762
|
+
throw new AuthError(
|
|
12763
|
+
"No cookie session (recently rejected). Skipping token refresh."
|
|
12764
|
+
);
|
|
12765
|
+
}
|
|
12104
12766
|
this.isRefreshing = true;
|
|
12105
12767
|
if (!this.config.refreshToken && typeof window === "undefined") {
|
|
12106
12768
|
this.isRefreshing = false;
|
|
@@ -12192,6 +12854,7 @@ var HttpClient = class {
|
|
|
12192
12854
|
});
|
|
12193
12855
|
this.refreshFailureCount = 0;
|
|
12194
12856
|
this.refreshLockedUntil = 0;
|
|
12857
|
+
this.clearNoCookieSessionMark();
|
|
12195
12858
|
return data.access_token;
|
|
12196
12859
|
} catch (e) {
|
|
12197
12860
|
const baseMsg = e instanceof Error ? e.message : "Token refresh failed";
|
|
@@ -12201,6 +12864,9 @@ var HttpClient = class {
|
|
|
12201
12864
|
3e4
|
|
12202
12865
|
);
|
|
12203
12866
|
this.refreshLockedUntil = Date.now() + backoffMs;
|
|
12867
|
+
if (failureKind === "permanent" && !this.config.refreshToken) {
|
|
12868
|
+
this.markNoCookieSession();
|
|
12869
|
+
}
|
|
12204
12870
|
if (failureKind === "permanent") {
|
|
12205
12871
|
if (!silent) {
|
|
12206
12872
|
this.clearTokens();
|
|
@@ -12301,6 +12967,9 @@ var HttpClient = class {
|
|
|
12301
12967
|
} else if (!config?.skipAuth && this.config.publicKey && this.config.secretKey && !headers.has("Authorization")) {
|
|
12302
12968
|
headers.set("Authorization", `Bearer ${this.config.secretKey}`);
|
|
12303
12969
|
}
|
|
12970
|
+
if (!config?.skipAuth && this.config.secretKey) {
|
|
12971
|
+
headers.set("X-User-Secret-Key", this.config.secretKey);
|
|
12972
|
+
}
|
|
12304
12973
|
if (config?.headers) {
|
|
12305
12974
|
Object.entries(config.headers).forEach(([key, value]) => {
|
|
12306
12975
|
headers.set(key, value);
|
|
@@ -13391,6 +14060,7 @@ var ConnectBase = class {
|
|
|
13391
14060
|
this.oauth = new OAuthAPI(this.http);
|
|
13392
14061
|
this.payment = new PaymentAPI(this.http);
|
|
13393
14062
|
this.subscription = new SubscriptionAPI(this.http);
|
|
14063
|
+
this.organizations = new OrganizationsAPI(this.http);
|
|
13394
14064
|
this.push = new PushAPI(this.http);
|
|
13395
14065
|
this.roles = new RolesAPI(this.http);
|
|
13396
14066
|
this.appMembers = new AppMembersAPI(this.http);
|
|
@@ -13502,6 +14172,7 @@ export {
|
|
|
13502
14172
|
GameRoom,
|
|
13503
14173
|
GameRoomTransport,
|
|
13504
14174
|
NativeAPI,
|
|
14175
|
+
OrganizationsAPI,
|
|
13505
14176
|
RolesAPI,
|
|
13506
14177
|
SessionManager,
|
|
13507
14178
|
VideoProcessingError,
|