@jeffusion/bungee-core 5.5.0 → 5.6.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.
@@ -78,6 +78,15 @@
78
78
  "execution": "control",
79
79
  "capability": "config.write"
80
80
  },
81
+ {
82
+ "path": "/accounts/auto-reset",
83
+ "methods": [
84
+ "POST"
85
+ ],
86
+ "handler": "setAutoResetCredits",
87
+ "execution": "control",
88
+ "capability": "config.write"
89
+ },
81
90
  {
82
91
  "path": "/accounts/draft",
83
92
  "methods": [
@@ -344,6 +353,13 @@
344
353
  "ui.credit.redeemed": "Redeemed",
345
354
  "ui.credit.unknown": "Unknown",
346
355
  "ui.useCredit": "Use",
356
+ "ui.autoResetEnabled": "Automatic reset enabled (30 minutes before expiry)",
357
+ "ui.autoResetDisabled": "Automatic reset disabled",
358
+ "ui.autoResetLabel": "Auto reset",
359
+ "ui.enableAutoReset": "Enable automatic reset",
360
+ "ui.disableAutoReset": "Disable automatic reset",
361
+ "ui.autoResetHelp": "The server refreshes the credit list every 5 minutes and schedules available credits for 30 minutes before expiry. Credits already in their final 30 minutes are processed immediately. Expired credits and credits without an expiry are skipped. Unknown results pause automation and require explicit resolution. Enable for this account?",
362
+ "ui.disableAutoResetHelp": "Stop scheduling automatic resets for this account. Requests already sent may still finish.",
347
363
  "ui.resetConfirmTitle": "Use reset credit",
348
364
  "ui.resetRetryTitle": "Retry reset credit",
349
365
  "ui.resetConfirmHelp": "This uses one reset credit for the account. Confirm only once.",
@@ -537,6 +553,13 @@
537
553
  "ui.credit.redeemed": "已兑换",
538
554
  "ui.credit.unknown": "未知",
539
555
  "ui.useCredit": "使用",
556
+ "ui.autoResetEnabled": "自动重置已开启(到期前 30 分钟)",
557
+ "ui.autoResetDisabled": "自动重置已关闭",
558
+ "ui.autoResetLabel": "自动重置",
559
+ "ui.enableAutoReset": "开启自动重置",
560
+ "ui.disableAutoReset": "关闭自动重置",
561
+ "ui.autoResetHelp": "服务端每 5 分钟刷新卡列表,并定时在重置卡到期前 30 分钟自动使用;已进入最后 30 分钟的卡立即处理。跳过已过期或到期时间未知的卡;结果未知时暂停自动兑换,需手动处理。是否为此账号开启?",
562
+ "ui.disableAutoResetHelp": "停止为此账号安排自动重置。已发送的兑换请求仍可能完成。",
540
563
  "ui.resetConfirmTitle": "使用重置额度",
541
564
  "ui.resetRetryTitle": "重试重置额度",
542
565
  "ui.resetConfirmHelp": "这会为该账号使用一份重置额度,请只确认一次。",
@@ -40,7 +40,7 @@
40
40
  type AccountUsage = ReturnType<typeof accountUsage>;
41
41
  type Credit = NonNullable<AccountUsage['resetCredits']['value']>['credits'][number];
42
42
  type UsageSnapshot = AccountUsage | { state: 'loading' | 'unavailable'; error?: boolean };
43
- type PendingReset = { credit: Credit; creditId: string; redeemRequestId: string; unknown: true };
43
+ type PendingReset = { credit: Credit; creditId: string; redeemRequestId: string; unknown: true; automatic?: boolean };
44
44
  type Session = { sessionId: string; expiresAt: number; userCode?: string; verificationUri?: string; authorizationUrl?: string };
45
45
  const pluginName = 'chatgpt-oauth';
46
46
  const id = $props.id();
@@ -220,6 +220,12 @@
220
220
  if (!Array.isArray(response.accounts)) throw new Error('invalid_response');
221
221
  if (generation !== accountRefreshGeneration) return;
222
222
  accounts = response.accounts.map(accountSummary);
223
+ for (const account of accounts) {
224
+ const pending = account.pendingAutoReset;
225
+ if (pending) pendingResetByAccount[account.id] = { ...pending, unknown: true, automatic: true,
226
+ credit: { creditId: pending.creditId, status: 'unknown', expiresAt: pending.expiresAt } };
227
+ else if (pendingResetByAccount[account.id]?.automatic) delete pendingResetByAccount[account.id];
228
+ }
223
229
  await refreshUsages(accounts, generation);
224
230
  } catch (error) { if (!lifetime.signal.aborted && generation === accountRefreshGeneration) notice = errorText(error); }
225
231
  finally { if (generation === accountRefreshGeneration) refreshing = false; }
@@ -303,7 +309,7 @@
303
309
  if (actionBusy) return;
304
310
  const generation = ++referenceGeneration;
305
311
  action = value; actionAccount = account; label = account.label; actionNotice = ''; references = null; referenceNotice = ''; actionOpen = true;
306
- if (value === 'rename') return;
312
+ if (value === 'rename' || value === 'auto-reset') return;
307
313
  referenceNotice = 'ui.referencesLoading';
308
314
  try {
309
315
  const snapshot = await getConfigSnapshot();
@@ -355,7 +361,8 @@
355
361
  actionBusy = true;
356
362
  const generation = referenceGeneration;
357
363
  try {
358
- await control('POST', `/accounts/${action}`, { accountRef: actionAccount.id, ...(action === 'rename' ? { label: label.trim() } : {}) });
364
+ await control('POST', `/accounts/${action}`, { accountRef: actionAccount.id, ...(action === 'rename' ? { label: label.trim() } : {}),
365
+ ...(action === 'auto-reset' ? { enabled: !actionAccount.autoResetCredits } : {}) });
359
366
  if (generation === referenceGeneration) actionOpen = false;
360
367
  await refreshAccounts();
361
368
  } catch (error) { actionNotice = errorText(error); }
@@ -450,6 +457,7 @@
450
457
  {#if !account.email}<p class="text-sm text-zinc-400">{t('ui.noEmail')}</p>{/if}
451
458
  {#if account.plan}<p class="text-sm text-zinc-300" data-testid="account-type">{t('ui.accountType', { plan: account.plan })}</p>{/if}
452
459
  {#if typeof account.expiresAt === 'number'}<p class="tabular-nums text-xs text-zinc-400">{t('ui.credentialExpiry', { date: dateText(account.expiresAt) })}</p>{/if}
460
+ <p class="text-xs text-zinc-400" data-testid="auto-reset-status">{t(account.autoResetCredits ? 'ui.autoResetEnabled' : 'ui.autoResetDisabled')}</p>
453
461
  </div>
454
462
  <div class="flex flex-wrap items-center gap-2"><span class="nx-field-label">{t('ui.usageLabel')}</span><Button variant="ghost" size="sm" class="aspect-square px-0 [&>span]:mr-0" disabled={!eligibleForUsage(account) || usageRefreshing[account.id]} aria-busy={!!usageRefreshing[account.id]} aria-label={t('ui.refreshUsage')} title={t('ui.refreshUsage')} onclick={() => { if (!usageRefreshing[account.id]) void refreshUsage(account); }}>{@render actionIcon(RefreshCw, usageRefreshing[account.id])}</Button>{#if status !== 'fresh'}<span role="status" class={`text-xs ${status === 'stale' || status === 'partial' ? 'text-amber-300' : 'text-zinc-400'}`}>{t(`ui.usage.${status}`)}</span>{/if}</div>
455
463
  {#if 'usage' in snapshot}
@@ -457,9 +465,25 @@
457
465
  {#if snapshot.usage.value?.primary?.usedPercent !== undefined}<MetricBar label={windowLabel(snapshot.usage.value.primary.windowSeconds)} value={snapshot.usage.value.primary.usedPercent} valueLabel={`${snapshot.usage.value.primary.usedPercent}% ${t('ui.used')}`} /><p class="tabular-nums text-xs text-zinc-400">{#if snapshot.usage.value.primary.windowSeconds !== undefined}{t('ui.window', { duration: formatWindow(snapshot.usage.value.primary.windowSeconds) })}{/if}{#if snapshot.usage.value.primary.resetAt !== undefined} · {t('ui.resetAt', { value: resetTime(snapshot.usage.value.primary.resetAt) })}{/if}</p>{/if}
458
466
  {#if snapshot.usage.value?.secondary?.usedPercent !== undefined}<MetricBar label={windowLabel(snapshot.usage.value.secondary.windowSeconds)} value={snapshot.usage.value.secondary.usedPercent} valueLabel={`${snapshot.usage.value.secondary.usedPercent}% ${t('ui.used')}`} /><p class="tabular-nums text-xs text-zinc-400">{#if snapshot.usage.value.secondary.windowSeconds !== undefined}{t('ui.window', { duration: formatWindow(snapshot.usage.value.secondary.windowSeconds) })}{/if}{#if snapshot.usage.value.secondary.resetAt !== undefined} · {t('ui.resetAt', { value: resetTime(snapshot.usage.value.secondary.resetAt) })}{/if}</p>{/if}
459
467
  {#if !snapshot.usage.value?.primary && !snapshot.usage.value?.secondary}<p class="text-sm text-zinc-400">{t('ui.noUsageWindows')}</p>{/if}
468
+ {:else if snapshot.state === 'loading'}<LoadingIndicator label={t('ui.usageLoading')} size="sm" height="none" />
469
+ {:else}<p class="text-sm text-zinc-400">{t(snapshot.error ? 'ui.usageFailed' : 'ui.usageSkipped')}</p>{/if}
470
+ <div class="mt-auto border-t border-carbon-600 pt-3 space-y-2">
471
+ <div class="flex items-center justify-between gap-2" data-testid="reset-heading">
472
+ <div class="flex min-w-0 flex-wrap items-center gap-2"><span class="nx-field-label">{t('ui.resetCredits')}</span><span aria-hidden="true" class="text-zinc-500">·</span><span class="text-sm text-zinc-400"><span class="nx-display tabular-nums text-zinc-100">{count ?? '—'}</span> {t('ui.creditsAvailable')}</span></div>
473
+ <button type="button" role="switch" aria-checked={account.autoResetCredits}
474
+ aria-label={`${t('ui.autoResetLabel')}: ${account.label}`}
475
+ title={t(account.autoResetCredits ? 'ui.autoResetEnabled' : 'ui.autoResetHelp')}
476
+ disabled={actionBusy || account.status === 'revoked'} data-testid="auto-reset-toggle"
477
+ class="inline-flex h-[24px] shrink-0 items-center gap-1.5 whitespace-nowrap text-xs text-zinc-400 transition-colors hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-nexus-500 focus-visible:ring-offset-2 focus-visible:ring-offset-carbon-950 disabled:cursor-not-allowed disabled:opacity-50"
478
+ onclick={() => openAction('auto-reset', account)}>
479
+ <span>{t('ui.autoResetLabel')}</span>
480
+ <span aria-hidden="true" data-testid="auto-reset-track"
481
+ class={`inline-flex h-[16px] w-[30px] shrink-0 items-center border bg-carbon-900 transition-colors ${account.autoResetCredits ? 'border-nexus-500' : 'border-carbon-600'}`}>
482
+ <span class={`block h-[12px] w-[12px] transition-transform ${account.autoResetCredits ? 'translate-x-[15px] bg-nexus-500' : 'translate-x-px bg-carbon-500'}`}></span>
483
+ </span>
484
+ </button>
485
+ </div>
460
486
  {#if count !== undefined || ('usage' in snapshot && snapshot.resetCredits.state !== 'unavailable') || pending}
461
- <div class="mt-auto border-t border-carbon-600 pt-3 space-y-2">
462
- <div class="flex flex-wrap items-center gap-2" data-testid="reset-heading"><span class="nx-field-label">{t('ui.resetCredits')}</span><span aria-hidden="true" class="text-zinc-500">·</span><span class="text-sm text-zinc-400"><span class="nx-display tabular-nums text-zinc-100">{count ?? '—'}</span> {t('ui.creditsAvailable')}</span></div>
463
487
  <!-- Native keyboard scrolling must also work when no credit action is available. -->
464
488
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
465
489
  <div class="credit-viewport h-32 overscroll-contain space-y-1 pr-1" data-testid="credit-viewport" role="region" aria-label={t('ui.resetCredits')} tabindex="0">
@@ -474,10 +498,8 @@
474
498
  </div>
475
499
  {/each}
476
500
  </div>
477
- </div>
478
- {/if}
479
- {:else if snapshot.state === 'loading'}<LoadingIndicator label={t('ui.usageLoading')} size="sm" height="none" />
480
- {:else}<p class="text-sm text-zinc-400">{t(snapshot.error ? 'ui.usageFailed' : 'ui.usageSkipped')}</p>{/if}
501
+ {:else}<p class="text-sm text-zinc-400">{t('ui.creditDetailsUnavailable')}</p>{/if}
502
+ </div>
481
503
  <div class="flex flex-wrap items-center justify-end gap-2" data-testid="account-actions">
482
504
  <Button variant="secondary" size="sm" disabled={!account.available} onclick={() => openUse(account)}>{@render actionIcon(Server)}{t('ui.useService')}</Button>
483
505
  <Button variant="secondary" size="sm" disabled={!account.available} onclick={() => openRoute(account)}>{@render actionIcon(RouteIcon)}{t('ui.useRoute')}</Button>
@@ -588,10 +610,11 @@
588
610
  {/snippet}
589
611
 
590
612
  <IndustrialDialog bind:open={actionOpen} busy={actionBusy} scrollBody closeLabel={t('ui.close')} footer={action === 'references' ? undefined : accountActionFooter}
591
- title={t(actionTitles[action] ?? 'ui.more')} description={actionAccount?.label ?? ''}>
613
+ title={t(action === 'auto-reset' ? (actionAccount?.autoResetCredits ? 'ui.disableAutoReset' : 'ui.enableAutoReset') : actionTitles[action] ?? 'ui.more')} description={actionAccount?.label ?? ''}>
592
614
  {#snippet body()}
593
615
  <form id={`${id}-account-action`} onsubmit={confirmAction} class="space-y-4">
594
616
  {#if action === 'rename'}<label class="block space-y-1.5"><span class="nx-field-label">{t('ui.accountName')}</span><Input bind:value={label} maxlength={128} required /></label>{/if}
617
+ {#if action === 'auto-reset'}<p class="text-sm text-zinc-300">{t(actionAccount?.autoResetCredits ? 'ui.disableAutoResetHelp' : 'ui.autoResetHelp')}</p>{/if}
595
618
  {#if ['delete', 'disable'].includes(action)}<p role="alert" class="text-sm text-amber-300">{t('ui.dangerHelp')}</p>{/if}
596
619
  {#if referenceNotice}<p role="status" class="text-sm text-zinc-400">{t(referenceNotice)}</p>{/if}
597
620
  {#if references}
@@ -51,7 +51,19 @@ export function accountSummary(input) {
51
51
  if (typeof value.id !== 'string' || typeof value.label !== 'string' || typeof value.status !== 'string' || !Object.hasOwn(accountStates, value.status)
52
52
  || typeof value.available !== 'boolean') throw new Error('invalid_response');
53
53
  const identity = value.identity === undefined ? {} : record(value.identity);
54
+ if (value.autoResetCredits !== undefined && typeof value.autoResetCredits !== 'boolean') throw new Error('invalid_response');
55
+ let pendingAutoReset;
56
+ if (value.pendingAutoReset !== undefined) {
57
+ const pending = record(value.pendingAutoReset);
58
+ const creditId = safeText(pending.creditId);
59
+ const redeemRequestId = safeText(pending.redeemRequestId, 128);
60
+ const expiresAt = safeEpoch(pending.expiresAt);
61
+ if (!creditId || !redeemRequestId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(redeemRequestId)
62
+ || pending.completed !== false) throw new Error('invalid_response');
63
+ pendingAutoReset = { creditId, redeemRequestId, expiresAt };
64
+ }
54
65
  return { id: value.id, label: value.label, status: value.status, available: value.available,
66
+ autoResetCredits: value.autoResetCredits === true, pendingAutoReset,
55
67
  expiresAt: Number.isFinite(value.expiresAt) ? value.expiresAt : undefined,
56
68
  email: typeof identity.email === 'string' ? identity.email : undefined,
57
69
  plan: typeof identity.planType === 'string' ? identity.planType : undefined };
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { test } from 'bun:test';
3
- import { accountUsage, errorCode, resetOutcome } from './account-model.js';
3
+ import { accountSummary, accountUsage, errorCode, resetOutcome } from './account-model.js';
4
4
 
5
5
  const usage = { usage: { state: 'fresh', value: { availableCount: 1 } }, resetCredits: { state: 'fresh', value: {
6
6
  availableCount: 1,
@@ -8,6 +8,19 @@ const usage = { usage: { state: 'fresh', value: { availableCount: 1 } }, resetCr
8
8
  title: 'Daily reset', description: 'A bounded description', expiresAt: 1700003600000 }],
9
9
  } } };
10
10
 
11
+ test('old accounts default automation off; pending automatic reset is bounded and redacted', () => {
12
+ const base = { id: 'simulation-account', label: 'Simulation', status: 'active', available: true };
13
+ assert.equal(accountSummary(base).autoResetCredits, false);
14
+ const pending = { creditId: 'simulation-credit', redeemRequestId: '123e4567-e89b-42d3-a456-426614174000', expiresAt: 1900000000000, completed: false };
15
+ const parsed = accountSummary({ ...base, autoResetCredits: true, pendingAutoReset: { ...pending, accessToken: 'never-expose' } });
16
+ assert.equal(parsed.autoResetCredits, true);
17
+ assert.deepEqual(parsed.pendingAutoReset, { creditId: pending.creditId, redeemRequestId: pending.redeemRequestId, expiresAt: pending.expiresAt });
18
+ for (const autoResetCredits of ['true', 1, null]) assert.throws(() => accountSummary({ ...base, autoResetCredits }), /invalid_response/);
19
+ for (const override of [{ creditId: '' }, { redeemRequestId: 'bad' }, { expiresAt: NaN }, { completed: true }]) {
20
+ assert.throws(() => accountSummary({ ...base, pendingAutoReset: { ...pending, ...override } }), /invalid_response/);
21
+ }
22
+ });
23
+
11
24
  test('projects bounded credit metadata without exposing unsafe fields', () => {
12
25
  const result = accountUsage(usage);
13
26
  assert.deepEqual(result.resetCredits.value?.credits[0], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffusion/bungee-core",
3
- "version": "5.5.0",
3
+ "version": "5.6.1",
4
4
  "description": "Bungee reverse proxy core engine",
5
5
  "main": "./dist/main.js",
6
6
  "types": "./dist/main.d.ts",
@@ -12,7 +12,7 @@
12
12
  "test": "bun test"
13
13
  },
14
14
  "dependencies": {
15
- "@jeffusion/bungee-types": "^5.5.0",
15
+ "@jeffusion/bungee-types": "^5.6.1",
16
16
  "@types/lodash-es": "^4.17.12",
17
17
  "acorn": "^8.15.0",
18
18
  "canonicalize": "3.0.0",