memi-board 0.47.0 → 0.47.1
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/README.md +85 -17
- package/dist/index.js +130 -84
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +3 -1
- package/docs/firebase-setup.md +8 -9
- package/docs/firestore.rules.example +39 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -486,40 +486,108 @@ Storage 예제의 파일 읽기·목록 조회는 공개다. 숨김 보드·비
|
|
|
486
486
|
|
|
487
487
|
### AI 글쓰기 도우미 사용 제한
|
|
488
488
|
|
|
489
|
-
에디터의 AI 글쓰기 도우미는 로그인 사용자별 24시간 구간에 최대 **10회** 사용할 수 있다.
|
|
490
|
-
`memiBoardUsers/{uid}`의 `aiWritingCount` / `aiWritingWindowAt`을 Firestore 트랜잭션으로 갱신하므로 여러 탭에서 동시에 실행해도 제한을 넘지 않는다.
|
|
489
|
+
에디터의 AI 글쓰기 도우미는 로그인 사용자별 24시간 구간에 최대 **10회** 사용할 수 있다. 호출 전에는 `aiWritingPending` 예약만 잡고, 모델 호출이 성공하면 `aiWritingCount`로 확정한다. 호출이 실패하면 예약만 되돌린다. 확정된 횟수는 내려가지 않는다. 예약과 확정은 `memiBoardUsers/{uid}`를 Firestore 트랜잭션으로 갱신하므로 여러 탭이 동시에 실행해도 합이 10을 넘지 않는다.
|
|
491
490
|
|
|
492
|
-
호스트는 반드시 최신 `docs/firestore.rules.example`의 `memiBoardUsers/{uid}` 규칙을 실제 Firestore Rules에 병합·배포해야 한다. 규칙을 배포하지 않으면 클라이언트가 카운터를 임의로 수정해 제한을 우회할 수 있다.
|
|
491
|
+
호스트는 반드시 최신 `docs/firestore.rules.example`의 `memiBoardUsers/{uid}` 규칙을 실제 Firestore Rules에 병합·배포해야 한다. 0.47.1부터는 예약 필드가 추가됐다. 규칙을 배포하지 않으면 새 패키지의 글쓰기 AI 횟수 갱신이 거절되거나, 예전 규칙만 있는 동안 클라이언트가 카운터를 임의로 수정해 제한을 우회할 수 있다.
|
|
493
492
|
|
|
494
493
|
콘솔에서 **AI Logic(Gemini Developer API)** 활성화. 신규 프로젝트는 구형 `gemini-2.5-flash` 가 404 날 수 있음 → 3.x Flash / Flash-Lite 사용.
|
|
495
494
|
|
|
496
495
|
### App Check (AI Enforce 시 사실상 필수)
|
|
497
496
|
|
|
498
|
-
`firebaseml` / AI Logic 이 **Enforce** 되어 있으면 App Check 토큰 없이 `generateContent` 가 실패한다.
|
|
497
|
+
`firebaseml` / AI Logic 이 **Enforce** 되어 있으면 App Check 토큰 없이 `generateContent` 가 실패한다. memi-board는 App Check를 초기화하지 않는다. 기본값 `moderation.useLimitedUseAppCheckTokens: true` 는 **한 번만 쓰는 토큰**을 요구한다. reCAPTCHA v3의 `ReCaptchaV3Provider` 는 이 토큰을 만들지 않는다.
|
|
499
498
|
|
|
500
|
-
1.
|
|
501
|
-
2.
|
|
499
|
+
1. reCAPTCHA Enterprise에서 **점수형** 웹 키를 만든다. 체크박스 키와 v3 공급자는 쓰지 않는다. 도메인에는 실제 사이트 주소만 넣는다. 사이트 키는 공개 값이라 클라이언트 설정에 둔다. 시크릿 키는 클라이언트에 넣지 않는다. Firebase가 토큰을 교환한다.
|
|
500
|
+
2. Firebase 콘솔 → App Check → 웹 앱에 그 사이트 키를 등록한다. 등록하는 웹 앱의 `appId` 가 `vuefire.config.appId` 와 같아야 한다.
|
|
501
|
+
3. 브라우저 API 키의 API 제한에 **Firebase App Check API** 를 포함한다. 기존에 허용한 API는 빼지 않는다.
|
|
502
|
+
4. 호스트 플러그인에서 마운트 후에 Enterprise 토큰을 교환한다. SPA는 첫 페인트 전에 `document.body` 가 비어 있어 `ReCaptchaEnterpriseProvider` 의 숨은 위젯이 `appCheck/recaptcha-error` 로 실패한다.
|
|
502
503
|
|
|
503
504
|
```ts
|
|
504
505
|
// app/plugins/app-check.client.ts
|
|
505
|
-
import { initializeAppCheck,
|
|
506
|
+
import { initializeAppCheck, CustomProvider } from 'firebase/app-check'
|
|
507
|
+
import type { FirebaseApp } from 'firebase/app'
|
|
506
508
|
import { useFirebaseApp } from 'vuefire'
|
|
507
509
|
|
|
508
|
-
export default defineNuxtPlugin(() => {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
510
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
511
|
+
const siteKey = String(useRuntimeConfig().public.recaptchaSiteKey || '').trim()
|
|
512
|
+
if (!siteKey) return
|
|
513
|
+
const app = useFirebaseApp()
|
|
514
|
+
|
|
515
|
+
nuxtApp.hook('app:mounted', () => {
|
|
516
|
+
initializeAppCheck(app, {
|
|
517
|
+
provider: new CustomProvider({
|
|
518
|
+
getToken: () => exchangeEnterpriseToken(app, siteKey),
|
|
519
|
+
}),
|
|
520
|
+
// 한 번용 토큰은 세션으로 갱신하지 않는다. AI SDK가 호출마다 getToken 한다.
|
|
521
|
+
isTokenAutoRefreshEnabled: false,
|
|
522
|
+
})
|
|
514
523
|
})
|
|
515
524
|
})
|
|
525
|
+
|
|
526
|
+
declare global {
|
|
527
|
+
interface Window {
|
|
528
|
+
grecaptcha?: {
|
|
529
|
+
enterprise?: {
|
|
530
|
+
ready: (callback: () => void) => void
|
|
531
|
+
execute: (siteKey: string, options: { action: string }) => Promise<string>
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let enterpriseReady: Promise<void> | undefined
|
|
538
|
+
|
|
539
|
+
function loadEnterprise(siteKey: string) {
|
|
540
|
+
enterpriseReady ??= new Promise((resolve, reject) => {
|
|
541
|
+
const ready = () => {
|
|
542
|
+
const enterprise = window.grecaptcha?.enterprise
|
|
543
|
+
if (!enterprise) {
|
|
544
|
+
reject(new Error('reCAPTCHA Enterprise가 로드되지 않았습니다.'))
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
enterprise.ready(() => resolve())
|
|
548
|
+
}
|
|
549
|
+
if (window.grecaptcha?.enterprise) {
|
|
550
|
+
ready()
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
const script = document.createElement('script')
|
|
554
|
+
script.src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`
|
|
555
|
+
script.async = true
|
|
556
|
+
script.onload = ready
|
|
557
|
+
script.onerror = () => reject(new Error('reCAPTCHA Enterprise 스크립트를 불러오지 못했습니다.'))
|
|
558
|
+
document.head.appendChild(script)
|
|
559
|
+
})
|
|
560
|
+
return enterpriseReady
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function exchangeEnterpriseToken(app: FirebaseApp, siteKey: string) {
|
|
564
|
+
await loadEnterprise(siteKey)
|
|
565
|
+
const enterprise = window.grecaptcha?.enterprise
|
|
566
|
+
if (!enterprise) throw new Error('reCAPTCHA Enterprise가 로드되지 않았습니다.')
|
|
567
|
+
const recaptchaToken = await enterprise.execute(siteKey, { action: 'fire_app_check' })
|
|
568
|
+
const { projectId, appId, apiKey } = app.options
|
|
569
|
+
const response = await fetch(
|
|
570
|
+
`https://content-firebaseappcheck.googleapis.com/v1/projects/${projectId}/apps/${appId}:exchangeRecaptchaEnterpriseToken?key=${apiKey}`,
|
|
571
|
+
{
|
|
572
|
+
method: 'POST',
|
|
573
|
+
headers: { 'Content-Type': 'application/json' },
|
|
574
|
+
body: JSON.stringify({ recaptcha_enterprise_token: recaptchaToken, limited_use: true }),
|
|
575
|
+
},
|
|
576
|
+
)
|
|
577
|
+
if (!response.ok) throw new Error(`App Check token exchange failed (${response.status})`)
|
|
578
|
+
const data = await response.json() as { token: string, ttl?: string }
|
|
579
|
+
const seconds = Number(/^([\d.]+)s$/.exec(data.ttl ?? '')?.[1])
|
|
580
|
+
return {
|
|
581
|
+
token: data.token,
|
|
582
|
+
expireTimeMillis: Date.now() + (Number.isFinite(seconds) ? seconds * 1000 : 60 * 60 * 1000),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
516
585
|
```
|
|
517
586
|
|
|
518
|
-
|
|
519
|
-
4. `firebaseConfig.appId` 가 App Check에 등록한 **웹 앱과 동일**한지 확인
|
|
520
|
-
5. (선택) Firestore/Storage 에도 Enforce — 켜면 토큰 없는 요청 거부
|
|
587
|
+
일반 세션 토큰을 쓰려면 `useLimitedUseAppCheckTokens: false` 로 바꾸고, 교환 본문의 `limited_use` 도 빼야 한다.
|
|
521
588
|
|
|
522
|
-
|
|
589
|
+
5. App Check 적용은 **Firebase AI Logic만** 켠다. Cloud Storage와 Cloud Firestore는 모니터링으로 둔다. 한 번용 토큰은 AI Logic만 받는다. Storage나 Firestore에 적용을 켜면 이미지 업로드와 글 저장이 `storage/unauthorized` 또는 권한 오류로 거절된다.
|
|
590
|
+
6. 로컬은 `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true` 로 나온 UUID를 콘솔 디버그 토큰에 등록한다. 프로덕션 빌드에서는 디버그 토큰을 켜지 않는다.
|
|
523
591
|
|
|
524
592
|
## 다른 프로젝트에 붙일 때 체크리스트
|
|
525
593
|
|
package/dist/index.js
CHANGED
|
@@ -2786,6 +2786,11 @@ function Xr(t) {
|
|
|
2786
2786
|
//#endregion
|
|
2787
2787
|
//#region src/data/versionHistory.ts
|
|
2788
2788
|
var Zr = [
|
|
2789
|
+
{
|
|
2790
|
+
version: "0.47.1",
|
|
2791
|
+
date: "2026-10-29",
|
|
2792
|
+
highlights: ["AI 글쓰기 도우미가 실패하면 그 시도는 하루 횟수에서 빠져요", "App Check 연결 안내를 한 번용 토큰과 SPA에 맞게 고쳤어요"]
|
|
2793
|
+
},
|
|
2789
2794
|
{
|
|
2790
2795
|
version: "0.47.0",
|
|
2791
2796
|
date: "2026-10-23",
|
|
@@ -3150,105 +3155,146 @@ function ai(e) {
|
|
|
3150
3155
|
}
|
|
3151
3156
|
function oi(e = {}) {
|
|
3152
3157
|
let t = k(), n = N(() => O(I(e.locale)) ?? t.locale.value), r = (e, t) => E(n.value, e, t), i = Ee(), a = Z(), o = we(), s = _(), { translateEntries: c, translateHtml: l } = An();
|
|
3153
|
-
|
|
3154
|
-
let e =
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3158
|
+
function u(e, t) {
|
|
3159
|
+
let n = ai(e.aiWritingWindowAt), r = n != null && t - n < $r, i = Math.max(0, Math.floor(Number(e.aiWritingCount) || 0)), a = Math.max(0, Math.floor(Number(e.aiWritingPending) || 0));
|
|
3160
|
+
return {
|
|
3161
|
+
active: r,
|
|
3162
|
+
windowAt: n,
|
|
3163
|
+
count: r ? i : 0,
|
|
3164
|
+
pending: r ? a : 0
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
function d(e, t) {
|
|
3168
|
+
let n = e + $r, i = Math.max(1, Math.ceil((n - t) / 36e5));
|
|
3169
|
+
return Error(r("writingAssistant.dailyLimit", {
|
|
3170
|
+
limit: 10,
|
|
3171
|
+
hours: i
|
|
3172
|
+
}));
|
|
3173
|
+
}
|
|
3174
|
+
async function f(e) {
|
|
3175
|
+
let t = o.value?.uid;
|
|
3176
|
+
if (!t) throw Error(r("writingAssistant.loginRequired"));
|
|
3177
|
+
await xe(a, async (n) => {
|
|
3178
|
+
let i = A(a, M(), t), o = await n.get(i);
|
|
3179
|
+
if (!o.exists()) throw Error(r("writingAssistant.userMissing"));
|
|
3180
|
+
let s = o.data(), c = e(u(s, Date.now()), s);
|
|
3181
|
+
c && n.update(i, {
|
|
3182
|
+
...c,
|
|
3170
3183
|
updatedAt: K()
|
|
3171
3184
|
});
|
|
3172
3185
|
});
|
|
3173
3186
|
}
|
|
3174
|
-
async function
|
|
3187
|
+
async function p() {
|
|
3188
|
+
await f((e) => {
|
|
3189
|
+
if (e.active && e.windowAt != null && e.count + e.pending >= 10) throw d(e.windowAt, Date.now());
|
|
3190
|
+
return e.active ? { aiWritingPending: e.pending + 1 } : {
|
|
3191
|
+
aiWritingCount: 0,
|
|
3192
|
+
aiWritingPending: 1,
|
|
3193
|
+
aiWritingWindowAt: K()
|
|
3194
|
+
};
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
async function m() {
|
|
3198
|
+
await f((e) => !e.active || e.pending < 1 || e.count >= 10 ? null : {
|
|
3199
|
+
aiWritingCount: e.count + 1,
|
|
3200
|
+
aiWritingPending: e.pending - 1
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
async function h() {
|
|
3204
|
+
await f((e) => !e.active || e.pending < 1 ? null : { aiWritingPending: e.pending - 1 });
|
|
3205
|
+
}
|
|
3206
|
+
async function g(e) {
|
|
3207
|
+
await p();
|
|
3208
|
+
try {
|
|
3209
|
+
let t = await e();
|
|
3210
|
+
try {
|
|
3211
|
+
await m();
|
|
3212
|
+
} catch {}
|
|
3213
|
+
return t;
|
|
3214
|
+
} catch (e) {
|
|
3215
|
+
throw await h().catch(() => {}), e;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
async function v(e) {
|
|
3175
3219
|
(e.action === "translate-en" || e.action === "translate-ko") && S();
|
|
3176
3220
|
let t = n.value, a = t === "ko", o = ri(e, t, r);
|
|
3177
3221
|
if (!e.content.trim()) throw Error(r("writingAssistant.contentRequired"));
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3222
|
+
return g(async () => {
|
|
3223
|
+
let t = s.moderation ?? {}, n = Ve(i, {
|
|
3224
|
+
backend: new ze(),
|
|
3225
|
+
useLimitedUseAppCheckTokens: t.useLimitedUseAppCheckTokens !== !1
|
|
3226
|
+
}), c = He(n, {
|
|
3227
|
+
model: t.model ?? "gemini-3.5-flash-lite",
|
|
3228
|
+
systemInstruction: (a ? [
|
|
3229
|
+
"너는 게시판 글쓰기 편집 도우미다.",
|
|
3230
|
+
"사용자가 요청한 편집 결과만 출력하고 설명, 인사, 마크다운 코드 펜스를 붙이지 마라.",
|
|
3231
|
+
e.action === "title" || e.selection ? "출력은 일반 텍스트로 작성하라." : "입력의 HTML 구조와 링크, 이미지 태그를 보존하고 결과도 HTML 조각으로 출력하라."
|
|
3232
|
+
] : [
|
|
3233
|
+
"You are an editing assistant for writing message board posts.",
|
|
3234
|
+
"Output only the requested edit result. Do not add explanations, greetings, or markdown code fences.",
|
|
3235
|
+
e.action === "title" || e.selection ? "Write the output as plain text." : "Preserve the HTML structure, links, and image tags of the input, and output the result as an HTML fragment as well."
|
|
3236
|
+
]).join("\n"),
|
|
3237
|
+
generationConfig: {
|
|
3238
|
+
temperature: e.action === "continue" || e.action === "title" || e.action === "custom" ? .6 : .2,
|
|
3239
|
+
maxOutputTokens: 2048
|
|
3240
|
+
}
|
|
3241
|
+
}), l = e.content.trim(), u = (a ? [
|
|
3242
|
+
`작업: ${o}`,
|
|
3243
|
+
e.title ? `현재 제목: ${e.title}` : "",
|
|
3244
|
+
`본문:\n${l.slice(0, 12e3)}`
|
|
3188
3245
|
] : [
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
]).join("\n"),
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
}
|
|
3197
|
-
}), f = e.content.trim(), p = (a ? [
|
|
3198
|
-
`작업: ${o}`,
|
|
3199
|
-
e.title ? `현재 제목: ${e.title}` : "",
|
|
3200
|
-
`본문:\n${f.slice(0, 12e3)}`
|
|
3201
|
-
] : [
|
|
3202
|
-
`Task: ${o}`,
|
|
3203
|
-
e.title ? `Current title: ${e.title}` : "",
|
|
3204
|
-
`Body:\n${f.slice(0, 12e3)}`
|
|
3205
|
-
]).filter(Boolean).join("\n\n"), m = ii((await d.generateContent(p)).response.text());
|
|
3206
|
-
if (!m) throw Error(r("writingAssistant.emptyResult"));
|
|
3207
|
-
return m;
|
|
3246
|
+
`Task: ${o}`,
|
|
3247
|
+
e.title ? `Current title: ${e.title}` : "",
|
|
3248
|
+
`Body:\n${l.slice(0, 12e3)}`
|
|
3249
|
+
]).filter(Boolean).join("\n\n"), d = ii((await c.generateContent(u)).response.text());
|
|
3250
|
+
if (!d) throw Error(r("writingAssistant.emptyResult"));
|
|
3251
|
+
return d;
|
|
3252
|
+
});
|
|
3208
3253
|
}
|
|
3209
|
-
async function
|
|
3254
|
+
async function y(e) {
|
|
3210
3255
|
S();
|
|
3211
3256
|
let t = e.source ?? n.value, i = e.title?.trim() ?? "", a = e.summary?.trim() ?? "", o = e.content ?? "", s = {};
|
|
3212
3257
|
i && (s.title = i), a && (s.summary = a);
|
|
3213
|
-
let
|
|
3214
|
-
if (!Object.keys(s).length && !
|
|
3215
|
-
if (
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
};
|
|
3234
|
-
if (d) {
|
|
3235
|
-
let e = {};
|
|
3236
|
-
for (let [t, n] of Object.entries(p)) {
|
|
3237
|
-
let r = O(t);
|
|
3238
|
-
r && typeof n == "string" && n.trim() && (e[r] = { content: n.trim() });
|
|
3239
|
-
}
|
|
3240
|
-
let n = rt(t, { content: o }, e);
|
|
3241
|
-
if (!it(n, ["content"])) throw Error(r("writingAssistant.translateIncomplete"));
|
|
3242
|
-
h.contentI18n = n, h.contentI18nSource = {
|
|
3243
|
-
locale: t,
|
|
3244
|
-
hash: $e(o)
|
|
3258
|
+
let u = !!Yt(o);
|
|
3259
|
+
if (!Object.keys(s).length && !u) throw Error(r("writingAssistant.translateEmpty"));
|
|
3260
|
+
if (u && o.length > 12e3) throw Error(r("writingAssistant.translateTooLong", { limit: Tn }));
|
|
3261
|
+
return g(async () => {
|
|
3262
|
+
let [e, n] = await Promise.all([Object.keys(s).length ? c([{
|
|
3263
|
+
id: "post",
|
|
3264
|
+
fields: s,
|
|
3265
|
+
context: "message board post title and short summary"
|
|
3266
|
+
}], {
|
|
3267
|
+
source: t,
|
|
3268
|
+
purpose: "message board post title and short summary"
|
|
3269
|
+
}) : Promise.resolve({}), u ? l(o, { source: t }) : Promise.resolve({})]), d = rt(t, s, e.post);
|
|
3270
|
+
if (Object.keys(s).length && !it(d, Object.keys(s))) throw Error(r("writingAssistant.translateIncomplete"));
|
|
3271
|
+
let f = {
|
|
3272
|
+
i18n: d,
|
|
3273
|
+
i18nSource: {
|
|
3274
|
+
locale: t,
|
|
3275
|
+
title: i,
|
|
3276
|
+
summary: a
|
|
3277
|
+
}
|
|
3245
3278
|
};
|
|
3246
|
-
|
|
3247
|
-
|
|
3279
|
+
if (u) {
|
|
3280
|
+
let e = {};
|
|
3281
|
+
for (let [t, r] of Object.entries(n)) {
|
|
3282
|
+
let n = O(t);
|
|
3283
|
+
n && typeof r == "string" && r.trim() && (e[n] = { content: r.trim() });
|
|
3284
|
+
}
|
|
3285
|
+
let i = rt(t, { content: o }, e);
|
|
3286
|
+
if (!it(i, ["content"])) throw Error(r("writingAssistant.translateIncomplete"));
|
|
3287
|
+
f.contentI18n = i, f.contentI18nSource = {
|
|
3288
|
+
locale: t,
|
|
3289
|
+
hash: $e(o)
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
return f;
|
|
3293
|
+
});
|
|
3248
3294
|
}
|
|
3249
3295
|
return {
|
|
3250
|
-
assist:
|
|
3251
|
-
translatePost:
|
|
3296
|
+
assist: v,
|
|
3297
|
+
translatePost: y
|
|
3252
3298
|
};
|
|
3253
3299
|
}
|
|
3254
3300
|
//#endregion
|