@sayren/store-sdk 0.1.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/dist/index.d.mts +5338 -0
- package/dist/index.mjs +2300 -0
- package/dist/server.d.mts +59 -0
- package/dist/server.mjs +125 -0
- package/dist/webhook-headers-D6QmPdEJ.d.mts +22 -0
- package/dist/webhook-headers-ap_exsb9.mjs +22 -0
- package/package.json +48 -0
- package/src/base64.ts +28 -0
- package/src/client.ts +877 -0
- package/src/http.ts +194 -0
- package/src/index.ts +35 -0
- package/src/oauth.ts +132 -0
- package/src/schemas/api-client.ts +121 -0
- package/src/schemas/api-log.ts +104 -0
- package/src/schemas/auth.ts +81 -0
- package/src/schemas/category.ts +98 -0
- package/src/schemas/claim.ts +89 -0
- package/src/schemas/common.ts +61 -0
- package/src/schemas/misc.ts +298 -0
- package/src/schemas/order.ts +233 -0
- package/src/schemas/payment.ts +582 -0
- package/src/schemas/product.ts +272 -0
- package/src/schemas/scope.ts +75 -0
- package/src/schemas/store.ts +143 -0
- package/src/schemas/upload.ts +23 -0
- package/src/schemas/webhook-event.ts +145 -0
- package/src/server.ts +24 -0
- package/src/session.ts +249 -0
- package/src/webhook-headers.ts +21 -0
- package/src/webhook-signature.ts +177 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-D6QmPdEJ.mjs";
|
|
2
|
+
//#region src/webhook-signature.d.ts
|
|
3
|
+
interface SignWebhookPayloadOptions {
|
|
4
|
+
/** 요청 본문 원문. 전송하는 바이트와 정확히 같아야 한다 */
|
|
5
|
+
payload: string;
|
|
6
|
+
/** 서명 시크릿. 여러 개면 순서대로 v1을 싣는다(현재 시크릿 먼저) */
|
|
7
|
+
secret: string | readonly string[];
|
|
8
|
+
/** 서명 시각 (unix 초). 생략하면 현재 시각 */
|
|
9
|
+
timestamp?: number;
|
|
10
|
+
}
|
|
11
|
+
/** `Sayren-Signature` 헤더 값을 만든다 */
|
|
12
|
+
export declare function signWebhookPayload(options: SignWebhookPayloadOptions): Promise<string>;
|
|
13
|
+
interface ParsedWebhookSignature {
|
|
14
|
+
/** 서명 시각 (unix 초) */
|
|
15
|
+
timestamp: number;
|
|
16
|
+
/** v1 서명(hex) 목록. 헤더에 실린 순서 그대로 */
|
|
17
|
+
signatures: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `Sayren-Signature` 헤더를 파싱한다. `t`가 정확히 1개, `v1`이 1개 이상이어야 하며
|
|
21
|
+
* 아니면 null. 알 수 없는 스킴(예: 향후 `v2`)은 무시한다.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseWebhookSignatureHeader(header: string | null | undefined): ParsedWebhookSignature | null;
|
|
24
|
+
type WebhookSignatureFailureReason =
|
|
25
|
+
/** 헤더가 없거나 비어 있음 */
|
|
26
|
+
"MISSING_HEADER" |
|
|
27
|
+
/** 헤더 형식 오류 (`t` 누락·중복·비정수, `v1` 없음) */
|
|
28
|
+
"MALFORMED_HEADER" |
|
|
29
|
+
/** 서명 시각이 허용 오차를 벗어남 (재전송 공격 방지) */
|
|
30
|
+
"TIMESTAMP_OUT_OF_TOLERANCE" |
|
|
31
|
+
/** 어떤 시크릿으로도 일치하는 v1이 없음 (본문 변조·시크릿 불일치) */
|
|
32
|
+
"SIGNATURE_MISMATCH";
|
|
33
|
+
type WebhookSignatureVerification = {
|
|
34
|
+
ok: true;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: WebhookSignatureFailureReason;
|
|
39
|
+
};
|
|
40
|
+
interface VerifyWebhookSignatureOptions {
|
|
41
|
+
/** 요청 본문 원문. JSON 파싱 전 바이트 그대로의 문자열이어야 한다 */
|
|
42
|
+
payload: string;
|
|
43
|
+
/** `Sayren-Signature` 헤더 값 */
|
|
44
|
+
header: string | null | undefined;
|
|
45
|
+
/** 서명 시크릿. 교체 유예 중이면 새·이전 시크릿을 모두 넘긴다. 하나라도 일치하면 통과 */
|
|
46
|
+
secret: string | readonly string[];
|
|
47
|
+
/** 타임스탬프 허용 오차(초). 기본 300 */
|
|
48
|
+
toleranceSec?: number;
|
|
49
|
+
/** 기준 시각. 생략하면 현재 시각 */
|
|
50
|
+
now?: Date;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `Sayren-Signature`를 검증한다. 비교는 `crypto.subtle.verify`(HMAC 상수 시간 비교)로 하며
|
|
54
|
+
* 헤더의 v1 중 하나가 시크릿 중 하나와 일치하면 통과한다.
|
|
55
|
+
* 실패해도 예외를 던지지 않고 `{ ok: false, reason }`을 돌려준다.
|
|
56
|
+
*/
|
|
57
|
+
export declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<WebhookSignatureVerification>;
|
|
58
|
+
//#endregion
|
|
59
|
+
export { type ParsedWebhookSignature, type SignWebhookPayloadOptions, type VerifyWebhookSignatureOptions, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, type WebhookSignatureFailureReason, type WebhookSignatureVerification };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-ap_exsb9.mjs";
|
|
2
|
+
//#region src/webhook-signature.ts
|
|
3
|
+
/**
|
|
4
|
+
* 웹훅 서명 (`Sayren-Signature`) 생성·파싱·검증.
|
|
5
|
+
*
|
|
6
|
+
* 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]`. v1 = HMAC-SHA256(secret, `${t}.${rawBody}`)의 소문자 hex.
|
|
7
|
+
* 시크릿 교체 유예 중에는 현재·이전 시크릿 서명을 모두 싣는다(현재 먼저).
|
|
8
|
+
*
|
|
9
|
+
* WebCrypto(`crypto.subtle`)만 써서 Node·Workers·Deno 어디서나 동작한다. 키 import는 호출 시점에
|
|
10
|
+
* 하므로 모듈 평가에 부작용·top-level await가 없다(CJS `require(esm)` 소비 조건).
|
|
11
|
+
* 시크릿을 다루므로 서버 엔트리포인트(`@sayren/store-sdk/server`)로만 노출한다.
|
|
12
|
+
*/
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
function subtle() {
|
|
15
|
+
const api = globalThis.crypto?.subtle;
|
|
16
|
+
if (!api) throw new Error("WebCrypto(crypto.subtle)를 사용할 수 없는 런타임입니다");
|
|
17
|
+
return api;
|
|
18
|
+
}
|
|
19
|
+
function importHmacKey(secret, usage) {
|
|
20
|
+
return subtle().importKey("raw", encoder.encode(secret), {
|
|
21
|
+
name: "HMAC",
|
|
22
|
+
hash: "SHA-256"
|
|
23
|
+
}, false, [usage]);
|
|
24
|
+
}
|
|
25
|
+
function toHex(buffer) {
|
|
26
|
+
let hex = "";
|
|
27
|
+
for (const byte of new Uint8Array(buffer)) hex += byte.toString(16).padStart(2, "0");
|
|
28
|
+
return hex;
|
|
29
|
+
}
|
|
30
|
+
/** 64자 hex → 32바이트. 형식이 아니면 null */
|
|
31
|
+
function fromHex(hex) {
|
|
32
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hex)) return null;
|
|
33
|
+
const bytes = /* @__PURE__ */ new Uint8Array(32);
|
|
34
|
+
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
function signedContent(timestamp, payload) {
|
|
38
|
+
return encoder.encode(`${timestamp}.${payload}`);
|
|
39
|
+
}
|
|
40
|
+
function toSecretList(secret) {
|
|
41
|
+
return (typeof secret === "string" ? [secret] : [...secret]).filter((s) => s.length > 0);
|
|
42
|
+
}
|
|
43
|
+
/** `Sayren-Signature` 헤더 값을 만든다 */
|
|
44
|
+
async function signWebhookPayload(options) {
|
|
45
|
+
const secrets = toSecretList(options.secret);
|
|
46
|
+
if (secrets.length === 0) throw new Error("서명 시크릿이 비어 있습니다");
|
|
47
|
+
const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
48
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new Error("timestamp는 0 이상의 정수(unix 초)여야 합니다");
|
|
49
|
+
const content = signedContent(timestamp, options.payload);
|
|
50
|
+
const parts = [`t=${timestamp}`];
|
|
51
|
+
for (const secret of secrets) {
|
|
52
|
+
const key = await importHmacKey(secret, "sign");
|
|
53
|
+
parts.push(`v1=${toHex(await subtle().sign("HMAC", key, content))}`);
|
|
54
|
+
}
|
|
55
|
+
return parts.join(",");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `Sayren-Signature` 헤더를 파싱한다. `t`가 정확히 1개, `v1`이 1개 이상이어야 하며
|
|
59
|
+
* 아니면 null. 알 수 없는 스킴(예: 향후 `v2`)은 무시한다.
|
|
60
|
+
*/
|
|
61
|
+
function parseWebhookSignatureHeader(header) {
|
|
62
|
+
if (!header) return null;
|
|
63
|
+
let timestamp = null;
|
|
64
|
+
const signatures = [];
|
|
65
|
+
for (const raw of header.split(",")) {
|
|
66
|
+
const part = raw.trim();
|
|
67
|
+
const eq = part.indexOf("=");
|
|
68
|
+
if (eq <= 0) return null;
|
|
69
|
+
const scheme = part.slice(0, eq);
|
|
70
|
+
const value = part.slice(eq + 1);
|
|
71
|
+
if (scheme === "t") {
|
|
72
|
+
if (timestamp !== null || !/^\d{1,15}$/.test(value)) return null;
|
|
73
|
+
timestamp = Number(value);
|
|
74
|
+
} else if (scheme === "v1") {
|
|
75
|
+
if (!value) return null;
|
|
76
|
+
signatures.push(value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (timestamp === null || signatures.length === 0) return null;
|
|
80
|
+
return {
|
|
81
|
+
timestamp,
|
|
82
|
+
signatures
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `Sayren-Signature`를 검증한다. 비교는 `crypto.subtle.verify`(HMAC 상수 시간 비교)로 하며
|
|
87
|
+
* 헤더의 v1 중 하나가 시크릿 중 하나와 일치하면 통과한다.
|
|
88
|
+
* 실패해도 예외를 던지지 않고 `{ ok: false, reason }`을 돌려준다.
|
|
89
|
+
*/
|
|
90
|
+
async function verifyWebhookSignature(options) {
|
|
91
|
+
const secrets = toSecretList(options.secret);
|
|
92
|
+
if (secrets.length === 0) throw new Error("서명 시크릿이 비어 있습니다");
|
|
93
|
+
if (!options.header) return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "MISSING_HEADER"
|
|
96
|
+
};
|
|
97
|
+
const parsed = parseWebhookSignatureHeader(options.header);
|
|
98
|
+
if (!parsed) return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: "MALFORMED_HEADER"
|
|
101
|
+
};
|
|
102
|
+
const tolerance = options.toleranceSec ?? 300;
|
|
103
|
+
const nowSec = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
104
|
+
if (Math.abs(nowSec - parsed.timestamp) > tolerance) return {
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: "TIMESTAMP_OUT_OF_TOLERANCE"
|
|
107
|
+
};
|
|
108
|
+
const candidates = parsed.signatures.map(fromHex).filter((bytes) => bytes !== null);
|
|
109
|
+
if (candidates.length > 0) {
|
|
110
|
+
const content = signedContent(parsed.timestamp, options.payload);
|
|
111
|
+
for (const secret of secrets) {
|
|
112
|
+
const key = await importHmacKey(secret, "verify");
|
|
113
|
+
for (const signature of candidates) if (await subtle().verify("HMAC", key, signature, content)) return {
|
|
114
|
+
ok: true,
|
|
115
|
+
timestamp: parsed.timestamp
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: "SIGNATURE_MISMATCH"
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
export { WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, parseWebhookSignatureHeader, signWebhookPayload, verifyWebhookSignature };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/webhook-headers.d.ts
|
|
2
|
+
/** 웹훅 발송 요청 헤더 상수 (공개 계약). zod 의존 없이 서버 엔트리포인트에서도 가볍게 쓴다 */
|
|
3
|
+
/** 서명 헤더. 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]` */
|
|
4
|
+
declare const WEBHOOK_SIGNATURE_HEADER = "Sayren-Signature";
|
|
5
|
+
/** 이벤트 ID 헤더. 본문 `id`와 같다. 중복 제거 키 */
|
|
6
|
+
declare const WEBHOOK_EVENT_ID_HEADER = "Sayren-Event-Id";
|
|
7
|
+
/** 이벤트 타입 헤더. 본문 `type`과 같다 */
|
|
8
|
+
declare const WEBHOOK_EVENT_TYPE_HEADER = "Sayren-Event-Type";
|
|
9
|
+
/** 전송 기록 ID 헤더. 전송 기록의 `deliveryId`와 같다 */
|
|
10
|
+
declare const WEBHOOK_DELIVERY_ID_HEADER = "Sayren-Delivery-Id";
|
|
11
|
+
/** 발송 요청의 `User-Agent` 값 */
|
|
12
|
+
declare const WEBHOOK_USER_AGENT = "Sayren-Webhooks/1.0";
|
|
13
|
+
/** 수신 측 권장 타임스탬프 허용 오차(초) */
|
|
14
|
+
declare const WEBHOOK_DEFAULT_TOLERANCE_SEC = 300;
|
|
15
|
+
declare const WEBHOOK_HEADERS: {
|
|
16
|
+
readonly signature: "Sayren-Signature";
|
|
17
|
+
readonly eventId: "Sayren-Event-Id";
|
|
18
|
+
readonly eventType: "Sayren-Event-Type";
|
|
19
|
+
readonly deliveryId: "Sayren-Delivery-Id";
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { WEBHOOK_HEADERS as a, WEBHOOK_EVENT_TYPE_HEADER as i, WEBHOOK_DELIVERY_ID_HEADER as n, WEBHOOK_SIGNATURE_HEADER as o, WEBHOOK_EVENT_ID_HEADER as r, WEBHOOK_USER_AGENT as s, WEBHOOK_DEFAULT_TOLERANCE_SEC as t };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/webhook-headers.ts
|
|
2
|
+
/** 웹훅 발송 요청 헤더 상수 (공개 계약). zod 의존 없이 서버 엔트리포인트에서도 가볍게 쓴다 */
|
|
3
|
+
/** 서명 헤더. 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]` */
|
|
4
|
+
const WEBHOOK_SIGNATURE_HEADER = "Sayren-Signature";
|
|
5
|
+
/** 이벤트 ID 헤더. 본문 `id`와 같다. 중복 제거 키 */
|
|
6
|
+
const WEBHOOK_EVENT_ID_HEADER = "Sayren-Event-Id";
|
|
7
|
+
/** 이벤트 타입 헤더. 본문 `type`과 같다 */
|
|
8
|
+
const WEBHOOK_EVENT_TYPE_HEADER = "Sayren-Event-Type";
|
|
9
|
+
/** 전송 기록 ID 헤더. 전송 기록의 `deliveryId`와 같다 */
|
|
10
|
+
const WEBHOOK_DELIVERY_ID_HEADER = "Sayren-Delivery-Id";
|
|
11
|
+
/** 발송 요청의 `User-Agent` 값 */
|
|
12
|
+
const WEBHOOK_USER_AGENT = "Sayren-Webhooks/1.0";
|
|
13
|
+
/** 수신 측 권장 타임스탬프 허용 오차(초) */
|
|
14
|
+
const WEBHOOK_DEFAULT_TOLERANCE_SEC = 300;
|
|
15
|
+
const WEBHOOK_HEADERS = {
|
|
16
|
+
signature: WEBHOOK_SIGNATURE_HEADER,
|
|
17
|
+
eventId: WEBHOOK_EVENT_ID_HEADER,
|
|
18
|
+
eventType: WEBHOOK_EVENT_TYPE_HEADER,
|
|
19
|
+
deliveryId: WEBHOOK_DELIVERY_ID_HEADER
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { WEBHOOK_HEADERS as a, WEBHOOK_EVENT_TYPE_HEADER as i, WEBHOOK_DELIVERY_ID_HEADER as n, WEBHOOK_SIGNATURE_HEADER as o, WEBHOOK_EVENT_ID_HEADER as r, WEBHOOK_USER_AGENT as s, WEBHOOK_DEFAULT_TOLERANCE_SEC as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sayren/store-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./dist/index.mjs",
|
|
5
|
+
"types": "./dist/index.d.mts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"development": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"default": "./dist/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./server": {
|
|
13
|
+
"development": "./src/server.ts",
|
|
14
|
+
"types": "./dist/server.d.mts",
|
|
15
|
+
"default": "./dist/server.mjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"zod": "^4.6.5"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@biomejs/biome": "^2.5.14",
|
|
23
|
+
"tsdown": "^0.23.0",
|
|
24
|
+
"typescript": "^6.0.3",
|
|
25
|
+
"vitest": "^5.0.1"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"license": "UNLICENSED",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/cochoio/shop.24.git"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
39
|
+
],
|
|
40
|
+
"description": "sayren 관리 API SDK — zod 스키마 + fetch 클라이언트, PKCE 헬퍼",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"dev": "tsdown --watch",
|
|
44
|
+
"lint": "biome check --write . && tsc --noEmit",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/base64.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64 인코딩 헬퍼 — 웹 표준(`btoa` + `TextEncoder`)만 사용한다.
|
|
3
|
+
*
|
|
4
|
+
* 이 패키지는 브라우저/서버 공용 entrypoint 하나로 배포되므로 Node 전용 `Buffer`에
|
|
5
|
+
* 의존하지 않는다. `btoa`는 Latin-1 코드유닛만 받기 때문에 문자열은 반드시
|
|
6
|
+
* UTF-8 바이트로 먼저 인코딩한 뒤 넘긴다 — 그래야 non-ASCII 입력에서도
|
|
7
|
+
* `Buffer.from(text, "utf8").toString("base64")`와 동일한 결과가 나온다.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 바이트 배열 → 표준 base64 (패딩 포함) */
|
|
11
|
+
export function base64FromBytes(bytes: Uint8Array): string {
|
|
12
|
+
let binary = "";
|
|
13
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
14
|
+
return btoa(binary);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 바이트 배열 → base64url (RFC 4648 §5, 패딩 없음) */
|
|
18
|
+
export function base64UrlFromBytes(bytes: Uint8Array): string {
|
|
19
|
+
return base64FromBytes(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 문자열 → UTF-8 base64. `btoa(text)`를 직접 쓰면 Latin-1로 해석돼
|
|
24
|
+
* non-ASCII에서 잘못된 바이트가 나오거나(예: "café") 예외가 난다(예: "🔑").
|
|
25
|
+
*/
|
|
26
|
+
export function base64Utf8(text: string): string {
|
|
27
|
+
return base64FromBytes(new TextEncoder().encode(text));
|
|
28
|
+
}
|