@acms-subir/core 0.3.0 → 0.3.2

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/live-video.md +91 -0
  3. package/integration.mjs +39 -0
  4. package/package.json +2 -1
  5. package/src/actions/index.ts +3 -2
  6. package/src/components/LinksManager.tsx +5 -1
  7. package/src/components/LiveCard.astro +174 -0
  8. package/src/components/LiveManager.tsx +910 -0
  9. package/src/components/ThemeSections.astro +2 -1
  10. package/src/components/WidgetRenderer.astro +3 -0
  11. package/src/components/editor/extensions/cards.tsx +3 -38
  12. package/src/components/editor/extensions/mediaCards.tsx +4 -0
  13. package/src/layouts/Admin.astro +1 -1
  14. package/src/layouts/Site.astro +28 -0
  15. package/src/lib/api.ts +6 -2
  16. package/src/lib/backup/content.ts +1 -1
  17. package/src/lib/embeds.ts +50 -0
  18. package/src/lib/live/client.ts +88 -0
  19. package/src/lib/live/crypto.ts +28 -0
  20. package/src/lib/live/migration.ts +15 -0
  21. package/src/lib/live/providers.ts +152 -0
  22. package/src/lib/live/service.ts +170 -0
  23. package/src/lib/live/signals.ts +89 -0
  24. package/src/lib/live/store.ts +49 -0
  25. package/src/lib/live/types.ts +52 -0
  26. package/src/lib/live/view.ts +78 -0
  27. package/src/lib/repo/index.ts +12 -8
  28. package/src/lib/repo/migrations.ts +4 -0
  29. package/src/lib/repo/sql.ts +58 -12
  30. package/src/lib/repo/types.ts +3 -0
  31. package/src/lib/scheduler.ts +2 -0
  32. package/src/lib/schema.ts +7 -1
  33. package/src/lib/themeTokens.ts +9 -0
  34. package/src/lib/webhook-sql.ts +68 -0
  35. package/src/lib/webhooks.ts +15 -7
  36. package/src/lib/widgets.ts +1 -0
  37. package/src/middleware.ts +10 -1
  38. package/src/pages/[...slug].astro +6 -0
  39. package/src/pages/admin/calendar/integrations.astro +2 -219
  40. package/src/pages/admin/integrations.astro +237 -0
  41. package/src/pages/admin/live.astro +12 -0
  42. package/src/pages/api/calendar/docs.ts +4 -2
  43. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  44. package/src/pages/api/live/settings.ts +45 -0
  45. package/src/pages/api/live/status.ts +7 -0
  46. package/src/pages/api/live/thumb.ts +48 -0
  47. package/src/pages/api/webhooks/docs.ts +61 -0
  48. package/src/pages/index.astro +6 -0
  49. package/src/pages/links.astro +6 -0
  50. package/src/styles/live-admin.css +126 -0
  51. package/src/styles/live.css +199 -0
  52. package/src/theme-sdk/contract.mjs +1 -0
  53. package/src/theme-sdk/tokens.mjs +3 -0
  54. package/src/themes/editorial/theme.css +5 -0
  55. package/src/themes/editorial/theme.json +2 -0
  56. package/src/themes/field/theme.json +1 -0
  57. package/src/themes/night/theme.json +1 -0
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import LiveCard from './LiveCard.astro';
2
3
  import type { Page, Settings } from '../lib/schema';
3
4
  import { getTheme } from '../lib/theme';
4
5
  import { getRepo } from '../lib/repo';
@@ -32,7 +33,7 @@ const sections = await Promise.all(layout.sections.map(async (section) => {
32
33
  <div class="acms-sections">
33
34
  {sections.filter(s => !s.hidden && !s.invalid).map(({ id, type, variant, data, Template, posts }) => (
34
35
  <section id={`section-${id}`} class:list={['acms-section', `section-${type}`, `variant-${variant}`]}>
35
- {type === 'page-content' ? (page.bodyHtml ? <article class="page-body" set:html={bodyHtml} /> : <WidgetRenderer pageId={page.id} blocks={page.blocks} />) : Template ? <Template data={data} variant={variant} settings={settings} page={page} /> : (
36
+ {type === 'live' && !Template ? <LiveCard display={variant} /> : type === 'page-content' ? (page.bodyHtml ? <article class="page-body" set:html={bodyHtml} /> : <WidgetRenderer pageId={page.id} blocks={page.blocks} />) : Template ? <Template data={data} variant={variant} settings={settings} page={page} /> : (
36
37
  <>
37
38
  <div class="section-copy">
38
39
  {data.heading && <h2>{String(data.heading)}</h2>}
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import LiveCard from './LiveCard.astro';
2
3
  import { parseConfig, type WidgetInstance, type WidgetType } from '../lib/schema';
3
4
  import { renderMarkdown } from '../lib/markdown';
4
5
 
@@ -21,6 +22,8 @@ const items = blocks.flatMap((b) => {
21
22
 
22
23
  {items.map(({ block, config }) => {
23
24
  switch (block.type) {
25
+ case 'live':
26
+ return <LiveCard display={config.display} />;
24
27
  case 'hero':
25
28
  return (
26
29
  <section class={`w-hero ${config.align}`}>
@@ -3,6 +3,9 @@ import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
4
  import { useState } from 'react';
5
5
  import { cleanEventName } from '../../../lib/analytics';
6
+ import { detectEmbed } from '../../../lib/embeds';
7
+
8
+ export { detectEmbed, type EmbedInfo } from '../../../lib/embeds';
6
9
 
7
10
  // Card nodes shared by the post editor. renderHTML emits the public markup
8
11
  // (`.pc-*` classes, styled in src/styles/cards.css); parseHTML round-trips it
@@ -129,44 +132,6 @@ function ImageCardView({ node, updateAttributes, deleteNode, selected }: NodeVie
129
132
 
130
133
  // ---- embed ----------------------------------------------------------------
131
134
 
132
- export interface EmbedInfo {
133
- provider: string;
134
- embedSrc: string;
135
- aspect: boolean; // true → 16:9, false → fixed height
136
- }
137
-
138
- export function detectEmbed(raw: string): EmbedInfo | null {
139
- let url: URL;
140
- try {
141
- url = new URL(raw.trim());
142
- } catch {
143
- return null;
144
- }
145
- const host = url.hostname.replace(/^www\./, '');
146
- if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be' || host === 'youtube-nocookie.com') {
147
- const id =
148
- host === 'youtu.be'
149
- ? url.pathname.slice(1).split('/')[0]
150
- : url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')
151
- ? url.pathname.split('/')[2]
152
- : url.searchParams.get('v');
153
- if (id) return { provider: 'youtube', embedSrc: `https://www.youtube-nocookie.com/embed/${id}`, aspect: true };
154
- }
155
- if (host === 'vimeo.com' || host === 'player.vimeo.com') {
156
- const id = url.pathname.split('/').filter(Boolean).pop();
157
- if (id && /^\d+$/.test(id)) return { provider: 'vimeo', embedSrc: `https://player.vimeo.com/video/${id}`, aspect: true };
158
- }
159
- if (host === 'loom.com' && url.pathname.startsWith('/share/')) {
160
- return { provider: 'loom', embedSrc: `https://www.loom.com/embed/${url.pathname.split('/')[2]}`, aspect: true };
161
- }
162
- if (host === 'codepen.io' && url.pathname.includes('/pen/')) {
163
- return { provider: 'codepen', embedSrc: raw.replace('/pen/', '/embed/'), aspect: false };
164
- }
165
- if (url.protocol === 'https:') {
166
- return { provider: 'iframe', embedSrc: raw, aspect: false };
167
- }
168
- return null;
169
- }
170
135
 
171
136
  export const EmbedCard = Node.create({
172
137
  name: 'embedCard',
@@ -40,6 +40,8 @@ export const FileCard = Node.create({
40
40
  return [
41
41
  {
42
42
  tag: 'a.pc-filecard',
43
+ // outrank the Link mark's a[href] rule, or the card reloads as a plain link
44
+ priority: 60,
43
45
  getAttrs: (el) => {
44
46
  const e = el as HTMLElement;
45
47
  return {
@@ -288,6 +290,8 @@ export const BookmarkCard = Node.create({
288
290
  return [
289
291
  {
290
292
  tag: 'a.pc-bookmark',
293
+ // outrank the Link mark's a[href] rule, or the card reloads as a plain link
294
+ priority: 60,
291
295
  getAttrs: (el) => {
292
296
  const e = el as HTMLElement;
293
297
  return {
@@ -115,7 +115,6 @@ const groups: { id: string; label: string; items: Item[] }[] = [
115
115
  ]
116
116
  : []),
117
117
  { key: 'calendar:calendars', label: 'Calendars', href: '/admin/calendar/calendars', icon: icons.calendars, count: null },
118
- { key: 'calendar:integrations', label: 'API & webhooks', href: '/admin/calendar/integrations', icon: icons.plug, count: null },
119
118
  ],
120
119
  }]
121
120
  : []),
@@ -134,6 +133,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
134
133
  items: [
135
134
  { key: 'users', label: 'Users', href: '/admin/users', icon: icons.users, count: null },
136
135
  { key: 'ai', label: 'Document import', href: '/admin/ai', icon: icons.posts, count: null },
136
+ { key: 'integrations', label: 'API & webhooks', href: '/admin/integrations', icon: icons.plug, count: null },
137
137
  { key: 'settings', label: 'Settings', href: '/admin/settings', icon: icons.settings, count: null },
138
138
  ],
139
139
  },
@@ -16,6 +16,7 @@ import { analyticsHead, analyticsBody } from '../lib/analytics';
16
16
  import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
17
17
  import '../styles/site.css';
18
18
  import '../styles/cards.css';
19
+ import '../styles/live.css';
19
20
 
20
21
  interface Props {
21
22
  title?: string;
@@ -117,6 +118,9 @@ const htmlLang = reqLocale || siteLang(settings);
117
118
  // search modal chrome: strings follow the document language, the form's
118
119
  // no-JS fallback goes to the (locale-prefixed) /search page
119
120
  const showSearch = !bare && SEARCH.enabled;
121
+ // site-wide live signals: rendered hidden, shown by the shared status poller
122
+ // only while a broadcast is live, so pages keep their normal caching
123
+ const liveSignals = !bare && settings.live.mode !== 'off' ? settings.live.display : null;
120
124
  const searchT = searchStrings(htmlLang.slice(0, 2));
121
125
  const searchAction = reqLocale && reqLocale !== I18N.defaultLocale ? `/${reqLocale}/search` : '/search';
122
126
  // columns footer: brand + tagline + socials, the footer nav, and a "Read"
@@ -204,6 +208,14 @@ const homeJsonLd =
204
208
  {settings.headCode && <Fragment set:html={settings.headCode} />}
205
209
  </head>
206
210
  <body>
211
+ {liveSignals?.bar && (
212
+ <div class="pc-live-bar" data-live-bar hidden role="status">
213
+ <span class="pc-live-mark">Live now</span>
214
+ <span class="pc-live-bar-title" data-live-bar-title></span>
215
+ <a data-live-bar-watch href="#">Watch →</a>
216
+ <button type="button" class="pc-live-x" aria-label="Dismiss live notice">✕</button>
217
+ </div>
218
+ )}
207
219
  {showAnnouncement && (
208
220
  <div class="announcement-bar" data-ann-key={ann.dismissible ? annKey : undefined} hidden={ann.dismissible || undefined}>
209
221
  {annLink ? <a href={annLink}>{ann.text}</a> : <span>{ann.text}</span>}
@@ -246,6 +258,7 @@ const homeJsonLd =
246
258
  ))}
247
259
  </nav>
248
260
  <div class="site-tools">
261
+ {liveSignals?.header && <a class="site-live" data-live-header href="#" hidden>Live</a>}
249
262
  {showSearch && (
250
263
  <a class="pc-search-trigger" href={searchAction} data-search-open aria-label={searchT.label}>
251
264
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
@@ -598,6 +611,21 @@ const homeJsonLd =
598
611
  </div>
599
612
  </footer>
600
613
  )}
614
+ {liveSignals?.dock && (
615
+ <div class="pc-live-dock" data-live-dock hidden>
616
+ <a href="#">
617
+ <span class="pc-live-dock-thumb"><img alt="" decoding="async" hidden /></span>
618
+ <span><span class="pc-live-mark">Live</span><b data-live-dock-title></b></span>
619
+ </a>
620
+ <button type="button" class="pc-live-x" aria-label="Dismiss live player">✕</button>
621
+ </div>
622
+ )}
623
+ <script>
624
+ // no-op unless the owner enabled a site-wide live signal above
625
+ import { mountLiveSignals } from '../lib/live/signals';
626
+ mountLiveSignals();
627
+ document.addEventListener('astro:page-load', mountLiveSignals);
628
+ </script>
601
629
  {analyticsBodyHtml && <Fragment set:html={analyticsBodyHtml} />}
602
630
  {settings.footerCode && <Fragment set:html={settings.footerCode} />}
603
631
  </body>
package/src/lib/api.ts CHANGED
@@ -18,6 +18,11 @@ export async function readJson(request: Request, maxBytes = 300_000): Promise<un
18
18
  return readResponseJson(request, maxBytes);
19
19
  }
20
20
  export async function readResponseJson(source: Request | Response, maxBytes: number): Promise<unknown> {
21
+ const bytes = await readResponseBytes(source, maxBytes);
22
+ try { return JSON.parse(new TextDecoder().decode(bytes)); }
23
+ catch { throw new ApiError(400, 'Body must be valid JSON.'); }
24
+ }
25
+ export async function readResponseBytes(source: Request | Response, maxBytes: number): Promise<Uint8Array> {
21
26
  if (Number(source.headers.get('content-length')) > maxBytes) throw new ApiError(413, 'Payload too large.');
22
27
  const reader = source.body?.getReader();
23
28
  if (!reader) throw new ApiError(400, 'JSON body is required.');
@@ -35,8 +40,7 @@ export async function readResponseJson(source: Request | Response, maxBytes: num
35
40
  const bytes = new Uint8Array(length);
36
41
  let offset = 0;
37
42
  for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
38
- try { return JSON.parse(new TextDecoder().decode(bytes)); }
39
- catch { throw new ApiError(400, 'Body must be valid JSON.'); }
43
+ return bytes;
40
44
  }
41
45
  export async function requireSession(
42
46
  request: Request, cookies: { get(name: string): { value: string } | undefined }, repo: ContentRepository, ownerOnly = false,
@@ -28,7 +28,7 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
28
28
  ]);
29
29
  return {
30
30
  format: 'acms-backup' as const, version: 5, exportedAt: new Date(now).toISOString(),
31
- settings: { ...settings, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
31
+ settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
32
32
  pages, posts, media, links, tags, navItems, users, redirects,
33
33
  calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
34
34
  resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
@@ -0,0 +1,50 @@
1
+ // Embeddable-URL detection shared by the editor's embed card (paste →
2
+ // card) and the importers (a Ghost/WordPress embed → embedCard markup).
3
+ export interface EmbedInfo {
4
+ provider: string;
5
+ embedSrc: string;
6
+ aspect: boolean; // true → 16:9, false → fixed height
7
+ }
8
+
9
+ export function detectEmbed(raw: string): EmbedInfo | null {
10
+ let url: URL;
11
+ try {
12
+ url = new URL(raw.trim());
13
+ } catch {
14
+ return null;
15
+ }
16
+ const host = url.hostname.replace(/^www\./, '');
17
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be' || host === 'youtube-nocookie.com') {
18
+ const id =
19
+ host === 'youtu.be'
20
+ ? url.pathname.slice(1).split('/')[0]
21
+ : url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/') || url.pathname.startsWith('/live/')
22
+ ? url.pathname.split('/')[2]
23
+ : url.searchParams.get('v');
24
+ // keep a start offset (?start=90, ?t=1h2m3s) — sermon and talk links
25
+ // usually point into the middle of a stream
26
+ const start = youtubeStart(url.searchParams.get('start') ?? url.searchParams.get('t'));
27
+ if (id) return { provider: 'youtube', embedSrc: `https://www.youtube-nocookie.com/embed/${id}${start ? `?start=${start}` : ''}`, aspect: true };
28
+ }
29
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
30
+ const id = url.pathname.split('/').filter(Boolean).pop();
31
+ if (id && /^\d+$/.test(id)) return { provider: 'vimeo', embedSrc: `https://player.vimeo.com/video/${id}`, aspect: true };
32
+ }
33
+ if (host === 'loom.com' && url.pathname.startsWith('/share/')) {
34
+ return { provider: 'loom', embedSrc: `https://www.loom.com/embed/${url.pathname.split('/')[2]}`, aspect: true };
35
+ }
36
+ if (host === 'codepen.io' && url.pathname.includes('/pen/')) {
37
+ return { provider: 'codepen', embedSrc: raw.replace('/pen/', '/embed/'), aspect: false };
38
+ }
39
+ if (url.protocol === 'https:') {
40
+ return { provider: 'iframe', embedSrc: raw, aspect: false };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function youtubeStart(raw: string | null): number {
46
+ if (!raw) return 0;
47
+ if (/^\d+s?$/.test(raw)) return parseInt(raw, 10);
48
+ const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
49
+ return m ? Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) : 0;
50
+ }
@@ -0,0 +1,88 @@
1
+ import type { PublicLiveSnapshot } from './types';
2
+
3
+ // One status poller per page, shared by every live surface (cards, the live
4
+ // bar, header marker, dock). Subscribers are tied to an element and dropped
5
+ // once it leaves the DOM (view transitions swap the body).
6
+ //
7
+ // It also mirrors the state onto <html data-live="live|upcoming"> so themes
8
+ // can style anything (an avatar ring, a nav item) without extra markup.
9
+
10
+ type Listener = (s: PublicLiveSnapshot | null, serverNow: number) => void;
11
+
12
+ let snapshot: PublicLiveSnapshot | null = null;
13
+ let offset = 0;
14
+ let loaded = false;
15
+ let pending = false;
16
+ let timers: ReturnType<typeof setInterval>[] = [];
17
+ const listeners = new Map<Listener, Element>();
18
+
19
+ export const serverNow = () => Date.now() + offset;
20
+
21
+ // a successful observation stops counting as live once it expires, even if
22
+ // every later refresh fails; expiresAt 0 = no expiry (offline fallback)
23
+ function current(): PublicLiveSnapshot | null {
24
+ if (snapshot && snapshot.expiresAt > 0 && snapshot.expiresAt <= serverNow()) return null;
25
+ return snapshot;
26
+ }
27
+
28
+ let shown: PublicLiveSnapshot | null | undefined;
29
+ function emit(force = false) {
30
+ const s = current();
31
+ if (!force && s === shown) return;
32
+ shown = s;
33
+ const status = s?.status === 'live' || s?.status === 'upcoming' ? s.status : '';
34
+ if (status) document.documentElement.dataset.live = status;
35
+ else delete document.documentElement.dataset.live;
36
+ for (const [fn, el] of listeners) {
37
+ if (!el.isConnected) { listeners.delete(fn); continue; }
38
+ fn(s, serverNow());
39
+ }
40
+ }
41
+
42
+ async function refresh() {
43
+ if (pending || document.hidden || !listeners.size) return;
44
+ pending = true;
45
+ try {
46
+ const r = await fetch('/api/live/status', { cache: 'no-store', signal: AbortSignal.timeout(10_000) });
47
+ if (!r.ok) return;
48
+ const data = await r.json();
49
+ if (typeof data.serverNow !== 'number' || !('snapshot' in data)) return;
50
+ offset = data.serverNow - Date.now();
51
+ snapshot = data.snapshot;
52
+ loaded = true;
53
+ emit(true);
54
+ } catch {
55
+ /* keep the last snapshot only until it expires */
56
+ } finally {
57
+ pending = false;
58
+ }
59
+ }
60
+
61
+ function start() {
62
+ if (timers.length) return;
63
+ timers = [
64
+ setInterval(() => {
65
+ if (![...listeners.values()].some((el) => el.isConnected)) return;
66
+ emit();
67
+ }, 1000),
68
+ setInterval(refresh, 30_000),
69
+ ];
70
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void refresh(); });
71
+ window.addEventListener('focus', () => void refresh());
72
+ }
73
+
74
+ // `initial` is the server-rendered state (live cards embed it); without it the
75
+ // first subscriber triggers a fetch.
76
+ export function subscribeLive(el: Element, fn: Listener, initial?: { snapshot: PublicLiveSnapshot | null; serverNow: number }) {
77
+ if (initial && !loaded) {
78
+ offset = initial.serverNow - Date.now();
79
+ snapshot = initial.snapshot;
80
+ loaded = true;
81
+ }
82
+ listeners.set(fn, el);
83
+ start();
84
+ if (loaded) emit(true);
85
+ else void refresh();
86
+ }
87
+
88
+ export const currentLive = current;
@@ -0,0 +1,28 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { env } from '../env';
3
+ import { ApiError } from '../api';
4
+ function key(locals?: unknown) {
5
+ const runtime = (locals as { runtime?: { env?: Record<string, unknown> } } | undefined)?.runtime?.env;
6
+ const secret = String(runtime?.LIVE_ENCRYPTION_KEY || env('LIVE_ENCRYPTION_KEY') || runtime?.SESSION_SECRET || env('SESSION_SECRET'));
7
+ if (secret.length < 32) throw new ApiError(503, 'Set LIVE_ENCRYPTION_KEY or SESSION_SECRET to at least 32 random characters on your host before storing live credentials.');
8
+ return createHash('sha256').update(`acms:live:v1:${secret}`).digest();
9
+ }
10
+ export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
11
+ export const randomToken = () => randomBytes(32).toString('base64url');
12
+ export function encryptionReady(locals?: unknown) { try { key(locals); return true; } catch { return false; } }
13
+ export function encrypt(value: unknown, context: string, locals?: unknown) {
14
+ const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key(locals), iv);
15
+ cipher.setAAD(Buffer.from(`acms:live:${context}`));
16
+ const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
17
+ return [iv, cipher.getAuthTag(), data].map(v => v.toString('base64url')).join('.');
18
+ }
19
+ export function decrypt<T>(encrypted: string, context: string, locals?: unknown): T {
20
+ try {
21
+ const parts = encrypted.split('.');
22
+ if (parts.length !== 3) throw new Error();
23
+ const [iv, tag, data] = parts.map(v => Buffer.from(v, 'base64url'));
24
+ const cipher = createDecipheriv('aes-256-gcm', key(locals), iv);
25
+ cipher.setAAD(Buffer.from(`acms:live:${context}`)); cipher.setAuthTag(tag);
26
+ return JSON.parse(Buffer.concat([cipher.update(data), cipher.final()]).toString('utf8'));
27
+ } catch { throw new ApiError(503, 'Saved live credentials cannot be decrypted. Re-enter the credentials and reconnect.'); }
28
+ }
@@ -0,0 +1,15 @@
1
+ export const LIVE_MIGRATION = { version: 34, statements: [
2
+ `ALTER TABLE settings ADD COLUMN live TEXT NOT NULL DEFAULT '{}'`,
3
+ `CREATE TABLE live_connections (
4
+ provider TEXT PRIMARY KEY CHECK(provider IN ('youtube','twitch')),
5
+ client_id TEXT NOT NULL, channel_input TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '', channel_name TEXT NOT NULL DEFAULT '',
6
+ encrypted TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 1,
7
+ channels TEXT NOT NULL DEFAULT '[]', google_status TEXT NOT NULL DEFAULT 'testing', tested_at INTEGER NOT NULL DEFAULT 0,
8
+ checked_at INTEGER NOT NULL DEFAULT 0, last_attempt_at INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0,
9
+ failures INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', lease TEXT NOT NULL DEFAULT '', lease_until INTEGER NOT NULL DEFAULT 0,
10
+ snapshot TEXT, upcoming TEXT, upcoming_checked_at INTEGER NOT NULL DEFAULT 0
11
+ )`,
12
+ `CREATE TABLE live_oauth_states (hash TEXT PRIMARY KEY, session_hash TEXT NOT NULL, revision INTEGER NOT NULL, encrypted TEXT NOT NULL, expires_at INTEGER NOT NULL)`,
13
+ `CREATE TABLE live_health (id INTEGER PRIMARY KEY CHECK(id=1), last_at INTEGER NOT NULL DEFAULT 0)`,
14
+ `INSERT INTO live_health (id) VALUES (1)`,
15
+ ] };
@@ -0,0 +1,152 @@
1
+ import { z } from 'zod';
2
+ import { readResponseJson } from '../api';
3
+ import { emptySnapshot, type Channel, type Connection, type Credentials, type LiveSnapshot, type Provider } from './types';
4
+
5
+ export class ProviderError extends Error {
6
+ constructor(public code: 'auth' | 'quota' | 'unavailable' | 'channel' | 'invalid', message: string, public retryAfter = 0) { super(message); }
7
+ }
8
+ const tokenSchema = z.object({ access_token: z.string().min(1).max(8192), expires_in: z.number().positive(), refresh_token: z.string().max(8192).optional(), scope: z.union([z.string(),z.array(z.string()).transform(v=>v.join(' '))]).optional() });
9
+ const idText = z.string().max(200);
10
+ const ytItem = z.object({ id: idText, snippet: z.object({ channelId: idText.optional(), title: z.string().max(1000), actualStartTime: z.string().optional(), scheduledStartTime: z.string().optional() }), status: z.object({ lifeCycleStatus: z.string().optional(), privacyStatus: z.string().optional() }).optional() });
11
+ const ytPage = z.object({ items: z.array(ytItem).max(50), nextPageToken: z.string().max(1000).optional() });
12
+ const twitchPage = z.object({ data: z.array(z.object({ id: idText, user_id: idText, user_login: idText, user_name: idText.optional(), title: z.string().max(1000), type: z.string(), started_at: z.string(), viewer_count: z.number().int().nonnegative().optional() })).max(100) });
13
+
14
+ async function request(url: string | URL, init: RequestInit, signal: AbortSignal): Promise<unknown> {
15
+ try {
16
+ const r = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) });
17
+ const body = await readResponseJson(r, 512_000).catch(() => null);
18
+ if (!r.ok) {
19
+ const detail = z.object({ error: z.union([z.string(), z.object({ errors: z.array(z.object({ reason: z.string() })).optional() })]).optional() }).safeParse(body);
20
+ const error = detail.success ? detail.data.error : undefined;
21
+ const quota = r.status === 429 || (typeof error === 'object' && error.errors?.some(e => ['quotaExceeded','dailyLimitExceeded','rateLimitExceeded','userRateLimitExceeded'].includes(e.reason)));
22
+ if (quota) {
23
+ const header = r.headers.get('retry-after');
24
+ const seconds = header && /^\d+$/.test(header) ? Number(header) : header ? (Date.parse(header)-Date.now())/1000 : 0;
25
+ const reset = Number(r.headers.get('ratelimit-reset') || 0)*1000-Date.now();
26
+ throw new ProviderError('quota', 'Provider quota or rate limit reached. Check your developer console; automatic checks will retry later.', Math.min(86400_000, Math.max(60_000,Number.isFinite(seconds)?seconds*1000:0,Number.isFinite(reset)?reset:0)));
27
+ }
28
+ if (r.status === 401 || r.status === 403 || error === 'invalid_grant' || error === 'invalid_client') throw new ProviderError('auth', 'Access was rejected. Check the saved credentials and authorize again if required.');
29
+ throw new ProviderError('unavailable', `The provider could not complete the check (HTTP ${r.status}).`);
30
+ }
31
+ if (!body) throw new ProviderError('unavailable', 'The provider returned an invalid response.');
32
+ return body;
33
+ } catch (e) {
34
+ if (e instanceof ProviderError) throw e;
35
+ throw new ProviderError('unavailable', 'The provider did not respond in time. Automatic checks will retry.');
36
+ }
37
+ }
38
+ const google = (path: string, params: Record<string,string>) => {
39
+ const url = new URL(`https://www.googleapis.com/youtube/v3/${path}`);
40
+ Object.entries(params).forEach(([k,v]) => url.searchParams.set(k,v)); return url;
41
+ };
42
+ const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
43
+ async function validateTwitchToken(clientId: string, creds: Credentials, signal: AbortSignal) {
44
+ const token = z.object({ client_id: z.string(), expires_in: z.number().nonnegative() }).parse(
45
+ await request('https://id.twitch.tv/oauth2/validate', { headers: bearer(creds.accessToken!) }, signal),
46
+ );
47
+ if (token.client_id !== clientId || token.expires_in <= 60) throw new ProviderError('auth', 'The Twitch token needs renewal.');
48
+ creds.tokenValidatedAt = Date.now();
49
+ creds.tokenExpiresAt = Date.now() + token.expires_in * 1000;
50
+ }
51
+ async function getToken(provider: Provider, clientId: string, creds: Credentials, signal: AbortSignal) {
52
+ if (creds.accessToken && (creds.tokenExpiresAt ?? 0) > Date.now()+60_000) {
53
+ if (provider !== 'twitch' || (creds.tokenValidatedAt ?? 0) > Date.now()-3600_000) return;
54
+ try { await validateTwitchToken(clientId, creds, signal); return; }
55
+ catch (error) {
56
+ if (!(error instanceof ProviderError) || error.code !== 'auth') throw error;
57
+ creds.accessToken = ''; creds.tokenExpiresAt = 0;
58
+ }
59
+ }
60
+ const body = new URLSearchParams({ client_id:clientId,client_secret:creds.clientSecret });
61
+ if (provider === 'twitch') body.set('grant_type','client_credentials');
62
+ else {
63
+ if (!creds.refreshToken) throw new ProviderError('auth', 'Authorize YouTube before testing this connection.');
64
+ body.set('grant_type','refresh_token'); body.set('refresh_token',creds.refreshToken);
65
+ }
66
+ // Twitch returns scope as an array on some flows; the client-credentials flow needs neither scope nor refresh token.
67
+ const data = await request(provider === 'twitch' ? 'https://id.twitch.tv/oauth2/token' : 'https://oauth2.googleapis.com/token', { method:'POST',body },signal);
68
+ const token = tokenSchema.parse(data);
69
+ creds.accessToken = token.access_token; creds.tokenExpiresAt = Date.now()+token.expires_in*1000;
70
+ if (token.refresh_token) creds.refreshToken = token.refresh_token;
71
+ if (provider === 'twitch') await validateTwitchToken(clientId, creds, signal);
72
+ }
73
+ async function authenticated<T>(conn: Connection, creds: Credentials, signal: AbortSignal, run: () => Promise<T>) {
74
+ await getToken(conn.provider,conn.clientId,creds,signal);
75
+ try { return await run(); }
76
+ catch (e) {
77
+ if (!(e instanceof ProviderError) || e.code !== 'auth') throw e;
78
+ creds.accessToken=''; creds.tokenExpiresAt=0;
79
+ await getToken(conn.provider,conn.clientId,creds,signal);
80
+ return run(); // One retry, never an unbounded token loop.
81
+ }
82
+ }
83
+ export function twitchLogin(value: string) {
84
+ let login = value.trim().toLowerCase();
85
+ if (/^https?:/i.test(login)) {
86
+ try { const u=new URL(login); if (u.protocol !== 'https:' || !['twitch.tv','www.twitch.tv'].includes(u.hostname) || u.username || u.password || u.search || u.hash) throw new Error(); login=u.pathname.replace(/^\/|\/$/g,''); }
87
+ catch { throw new ProviderError('channel','Enter a Twitch channel username or its HTTPS channel URL.'); }
88
+ }
89
+ if (!/^[a-z0-9_]{1,25}$/.test(login)) throw new ProviderError('channel','Enter a Twitch channel username or its HTTPS channel URL.');
90
+ return login;
91
+ }
92
+ export async function channels(conn: Connection, creds: Credentials, signal: AbortSignal): Promise<Channel[]> {
93
+ return authenticated(conn,creds,signal,async () => {
94
+ if (conn.provider === 'twitch') {
95
+ const u=new URL('https://api.twitch.tv/helix/users'); u.searchParams.set('login',twitchLogin(conn.channelInput));
96
+ const data=z.object({data:z.array(z.object({id:idText,display_name:idText})).max(100)}).parse(await request(u,{headers:{...bearer(creds.accessToken!), 'Client-Id':conn.clientId}},signal));
97
+ if (!data.data.length) throw new ProviderError('channel','That Twitch channel was not found. Check the username.');
98
+ return data.data.map(c => ({id:c.id,name:c.display_name}));
99
+ }
100
+ const result:Channel[]=[]; let page='';
101
+ for(let i=0;i<5;i++) {
102
+ const data=z.object({items:z.array(z.object({id:idText,snippet:z.object({title:idText})})).max(50).default([]),nextPageToken:z.string().optional()}).parse(await request(google('channels',{part:'snippet',mine:'true',maxResults:'50',...(page?{pageToken:page}:{})}),{headers:bearer(creds.accessToken!)},signal));
103
+ result.push(...data.items.map(c=>({id:c.id,name:c.snippet.title}))); page=data.nextPageToken??''; if (!page) return result;
104
+ }
105
+ throw new ProviderError('unavailable','Too many channel results. Connect a Google account with the intended channel identity.');
106
+ });
107
+ }
108
+ export async function exchangeGoogleCode(clientId:string,clientSecret:string,code:string,verifier:string,callbackUrl:string,signal:AbortSignal):Promise<Credentials> {
109
+ const token=tokenSchema.parse(await request('https://oauth2.googleapis.com/token',{method:'POST',body:new URLSearchParams({client_id:clientId,client_secret:clientSecret,code,code_verifier:verifier,redirect_uri:callbackUrl,grant_type:'authorization_code'})},signal));
110
+ if (!token.refresh_token) throw new ProviderError('auth','Google did not provide offline access. Reconnect and approve the read-only permission.');
111
+ if (!token.scope?.split(' ').includes('https://www.googleapis.com/auth/youtube.readonly')) throw new ProviderError('auth','YouTube read-only permission is required. Reconnect and approve that permission.');
112
+ return {clientSecret,accessToken:token.access_token,refreshToken:token.refresh_token,tokenExpiresAt:Date.now()+token.expires_in*1000,callbackUrl};
113
+ }
114
+ export async function revoke(provider:Provider,clientId:string,creds:Credentials) {
115
+ const token=provider==='youtube' ? creds.refreshToken||creds.accessToken : creds.accessToken;
116
+ if (!token) return;
117
+ const url=provider==='youtube'?'https://oauth2.googleapis.com/revoke':'https://id.twitch.tv/oauth2/revoke';
118
+ const body=new URLSearchParams({token,...(provider==='twitch'?{client_id:clientId}:{})});
119
+ // Revocation is best effort; disconnect always removes local credentials.
120
+ try { await fetch(url,{method:'POST',body,redirect:'error',signal:AbortSignal.timeout(5_000)}); } catch { /* no provider details in logs */ }
121
+ }
122
+ export async function detect(conn:Connection,creds:Credentials,signal:AbortSignal,upcoming=false):Promise<LiveSnapshot> {
123
+ return authenticated(conn,creds,signal,async()=>{
124
+ const now=Date.now();
125
+ if (!conn.channelId) throw new ProviderError('channel','Choose and test a channel before enabling automatic checks.');
126
+ if(conn.provider==='twitch') {
127
+ const u=new URL('https://api.twitch.tv/helix/streams');u.searchParams.set('user_id',conn.channelId);
128
+ const data=twitchPage.parse(await request(u,{headers:{...bearer(creds.accessToken!), 'Client-Id':conn.clientId}},signal));
129
+ const stream=data.data.find(s=>s.user_id===conn.channelId&&s.type==='live');
130
+ if(!stream) return emptySnapshot('twitch',now);
131
+ const login=twitchLogin(stream.user_login);
132
+ return {...emptySnapshot('twitch',now),status:'live',id:stream.id,title:stream.title,watchUrl:`https://www.twitch.tv/${login}`,embedId:login,startedAt:Date.parse(stream.started_at)||0,channel:stream.user_name||conn.channelName,viewers:stream.viewer_count};
133
+ }
134
+ const found:z.infer<typeof ytItem>[]=[]; let page='';
135
+ for(let i=0;i<5;i++) {
136
+ const data=ytPage.parse(await request(google('liveBroadcasts',{part:'snippet,status,contentDetails',broadcastStatus:upcoming?'upcoming':'active',broadcastType:'all',maxResults:'50',...(page?{pageToken:page}:{})}),{headers:bearer(creds.accessToken!)},signal));
137
+ found.push(...data.items.filter(v=>v.snippet.channelId===conn.channelId && v.status?.privacyStatus==='public' && (upcoming?['created','ready'].includes(v.status?.lifeCycleStatus??''):v.status?.lifeCycleStatus==='live') && /^[\w-]{11}$/.test(v.id)));
138
+ page=data.nextPageToken??'';if(!page)break;
139
+ if(i===4)throw new ProviderError('unavailable','Broadcast results exceeded the safe check limit. Narrow the connected channel.');
140
+ }
141
+ const start=(v:z.infer<typeof ytItem>)=>Date.parse((upcoming?v.snippet.scheduledStartTime:v.snippet.actualStartTime)??'')||0;
142
+ found.sort((a,b)=>(upcoming?start(a)-start(b):start(b)-start(a))||a.id.localeCompare(b.id));
143
+ const item=found[0];if(!item)return emptySnapshot('youtube',now);
144
+ // Recheck public video visibility and embedding permission before publishing OAuth-derived data.
145
+ // liveStreamingDetails rides along in the same 1-unit call for the viewer count.
146
+ const video=z.object({items:z.array(z.object({id:idText,status:z.object({privacyStatus:z.string(),embeddable:z.boolean().optional()}),liveStreamingDetails:z.object({concurrentViewers:z.string().max(20).optional()}).optional()})).default([])}).parse(await request(google('videos',{part:'status,liveStreamingDetails',id:item.id}),{headers:bearer(creds.accessToken!)},signal));
147
+ const visible=video.items.find(v=>v.id===item.id&&v.status.privacyStatus==='public');
148
+ if(!visible)return emptySnapshot('youtube',now);
149
+ const viewers=upcoming?'':visible.liveStreamingDetails?.concurrentViewers?.match(/^\d{1,12}$/)?.[0];
150
+ return {...emptySnapshot('youtube',now),status:upcoming?'upcoming':'live',id:item.id,title:item.snippet.title,watchUrl:`https://www.youtube.com/watch?v=${item.id}`,embedId:visible.status.embeddable?item.id:'',startedAt:start(item),channel:conn.channelName,...(viewers?{viewers:Number(viewers)}:{})};
151
+ });
152
+ }