@acms-subir/core 0.3.29 → 0.3.31

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.
@@ -40,9 +40,19 @@ export class LiveStore {
40
40
  async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
41
41
  await (await this.driver()).batch([{sql:'DELETE FROM live_oauth_states WHERE expires_at<? OR session_hash=?',args:[Date.now(),sessionHash]}, {sql:'INSERT INTO live_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)',args:[hash,sessionHash,revision,encrypted,expiresAt]}]);
42
42
  }
43
- async consumeState(hash: string, sessionHash: string, now: number) {
44
- const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE hash=? AND session_hash=? AND expires_at>? RETURNING revision,encrypted', [hash,sessionHash,now]);
45
- return rows[0] ? {revision:Number(rows[0].revision),encrypted:String(rows[0].encrypted)} : null;
43
+ // Google's redirect finds its pending state by the state value alone (it often arrives without a
44
+ // usable sign-in); only the signed-in admin who began it can finish it (consumeSessionStates).
45
+ async pendingState(hash: string, now: number) {
46
+ const rows = await (await this.driver()).all('SELECT encrypted,expires_at FROM live_oauth_states WHERE hash=? AND expires_at>?', [hash,now]);
47
+ return rows[0] ? {encrypted:String(rows[0].encrypted),expiresAt:Number(rows[0].expires_at)} : null;
48
+ }
49
+ /** Swaps in the payload with Google's code. Compare-and-swap on the old ciphertext: only the first callback counts. */
50
+ async attachCode(hash: string, before: string, after: string, expiresAt: number, now: number) {
51
+ return (await (await this.driver()).all('UPDATE live_oauth_states SET encrypted=?,expires_at=? WHERE hash=? AND encrypted=? AND expires_at>? RETURNING hash', [after,expiresAt,hash,before,now])).length > 0;
52
+ }
53
+ async consumeSessionStates(sessionHash: string, now: number) {
54
+ const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE session_hash=? AND expires_at>? RETURNING revision,encrypted', [sessionHash,now]);
55
+ return rows.map(r => ({revision:Number(r.revision),encrypted:String(r.encrypted)}));
46
56
  }
47
57
  async heartbeat(now: number) { await (await this.driver()).run('UPDATE live_health SET last_at=? WHERE id=1',[now]); }
48
58
  async lastHeartbeat() { return Number((await (await this.driver()).all('SELECT last_at FROM live_health WHERE id=1'))[0]?.last_at ?? 0); }
@@ -8,7 +8,8 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }), apiVersion: z.literal(1),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }),
12
+ legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
12
13
  });
13
14
  export const Theme = z.object({
14
15
  id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
@@ -10,10 +10,13 @@ import { darkPalette, liveColor } from '../themeTokens';
10
10
  import { safeUrl } from './content';
11
11
  import { queryParams } from './http';
12
12
  import { guestSignInReady } from './guest';
13
+ import { LEGAL } from '../legal/config';
13
14
 
14
15
  export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
15
16
  z.object({}).strict().parse(queryParams(url));
16
17
  const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
18
+ // the privacy notice and terms: the standard pages, or published pages at those slugs
19
+ const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? `${siteOrigin(settings, url)}/${slug}` : null);
17
20
  const origin = siteOrigin(settings, url);
18
21
  const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
19
22
  const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
@@ -38,6 +41,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
38
41
  // Integrations → Live video; GET /api/v1/live answers 404 while it's off
39
42
  live: settings.live.mode !== 'off',
40
43
  },
44
+ // in another site language, add /<code> after the origin, like every other page
45
+ legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
41
46
  apiVersion: 1,
42
47
  };
43
48
  }
@@ -252,7 +252,8 @@ export async function sendStartingSoon(repo: ContentRepository, opts: PushRunOpt
252
252
  if (!chosen.length) return 0;
253
253
  const lead = rule.minutes * MINUTE;
254
254
  const occurrences = expandEvents(chosen, settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + lead + 1 })
255
- .filter((o) => !o.allDay && o.start > now && o.start - lead <= now);
255
+ // a single cancelled date of a repeating event gets no reminder
256
+ .filter((o) => !o.allDay && !o.cancelled && o.start > now && o.start - lead <= now);
256
257
  const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
257
258
  let sent = 0;
258
259
  for (const o of occurrences) {
@@ -668,12 +668,20 @@ export const MIGRATIONS: Migration[] = [
668
668
  `ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
669
669
  `ALTER TABLE events ADD COLUMN request TEXT`,
670
670
  ] },
671
+ // Settings → Legal (Settings.legal): shown on /privacy and /terms, so it
672
+ // bumps the content version; the automatic "last updated" stamps don't
673
+ { version: 57, statements: [
674
+ `ALTER TABLE settings ADD COLUMN legal TEXT NOT NULL DEFAULT '{}'`,
675
+ `ALTER TABLE settings ADD COLUMN legal_stamps TEXT NOT NULL DEFAULT '{}'`,
676
+ `DROP TRIGGER content_version_settings_update`,
677
+ ...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal']),
678
+ ] },
671
679
  ];
672
680
 
673
681
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
674
682
  export const CURRENT_CONTENT_VERSION_COLUMNS = {
675
683
  ...CONTENT_VERSION_COLUMNS,
676
- settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale'],
684
+ settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
677
685
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
678
686
  };
679
687
 
@@ -939,6 +939,17 @@ export class SqlRepository implements ContentRepository {
939
939
  await this.ready;
940
940
  await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
941
941
  }
942
+ async cancelOccurrenceRsvps(id: string, start: number) {
943
+ await this.ready;
944
+ // the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
945
+ const mutation = crypto.randomUUID();
946
+ const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
947
+ await this.driver.batch([
948
+ {sql:"UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=? WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ('pending','confirmed')",args:[mutation,Date.now(),id,start]},
949
+ ...notifications,
950
+ ]);
951
+ return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
952
+ }
942
953
  async discardEventDraft(id: string) {
943
954
  await this.ready;
944
955
  await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
@@ -2032,6 +2043,8 @@ export class SqlRepository implements ContentRepository {
2032
2043
  push: JSON.parse(String(r?.push ?? '{}')),
2033
2044
  calendar: JSON.parse(String(r?.calendar ?? '{}')),
2034
2045
  mobileAccess: JSON.parse(String(r?.mobile_access ?? '{}')),
2046
+ legal: JSON.parse(String(r?.legal ?? '{}')),
2047
+ legalStamps: JSON.parse(String(r?.legal_stamps ?? '{}')),
2035
2048
  monitoring: JSON.parse(String(r?.monitoring ?? '{}')),
2036
2049
  agent: JSON.parse(String(r?.agent ?? '{}')),
2037
2050
  draftsApiKey: r?.drafts_api_key ?? '',
@@ -2063,9 +2076,9 @@ export class SqlRepository implements ContentRepository {
2063
2076
  linksAvatarShape:'links_avatar_shape', linksAvatarFit:'links_avatar_fit', linksAvatarScale:'links_avatar_scale',
2064
2077
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
2065
2078
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
2066
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring',
2079
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring', legal:'legal', legalStamps:'legal_stamps',
2067
2080
  };
2068
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring']);
2081
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring','legal','legalStamps']);
2069
2082
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
2070
2083
  if (!entries.length) return;
2071
2084
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -149,6 +149,8 @@ export interface ContentRepository {
149
149
  discardEventDraft(id: string): Promise<void>;
150
150
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
151
151
  setEventCancelledDates(id: string, dates: string[]): Promise<void>;
152
+ /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
153
+ cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
152
154
  deleteEvent(id: string): Promise<void>;
153
155
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
154
156
  // live row and/or the staged draft, leaving every other field — and the
package/src/lib/schema.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { safeEventImageUrl } from './calendar/images';
2
2
  import { LiveConfig } from './live/types';
3
3
  import { PushSettings } from './push/types';
4
+ import { LegalSettings, LegalStamps } from './legal/types';
4
5
  import { SectionPages } from '../theme-sdk/contract.mjs';
5
6
  import { AiConfig, AgentConfig } from './ai-schema';
6
7
  import { z } from 'zod';
@@ -762,6 +763,9 @@ export const Settings = z.object({
762
763
  monitoring: Monitoring.default({}),
763
764
  // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
764
765
  push: PushSettings.default({}),
766
+ // Settings → Legal (lib/legal): operator details and their own sections
767
+ legal: LegalSettings.default({}),
768
+ legalStamps: LegalStamps,
765
769
  siteTitle: z.string().default('My Site'),
766
770
  tagline: z.string().default(''),
767
771
  // site-wide footer, markdown allowed; empty falls back to "title — tagline"
@@ -2,6 +2,8 @@
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import AiSettings from '../../components/AiSettings';
4
4
  import MonitoringSettings from '../../components/MonitoringSettings';
5
+ import LegalSettings from '../../components/LegalSettings';
6
+ import { LEGAL } from '../../lib/legal/config';
5
7
  import { getRepo } from '../../lib/repo';
6
8
  import { accessFor } from '../../lib/permissions/access';
7
9
  import type { Permission } from '../../lib/permissions/catalog';
@@ -233,6 +235,7 @@ const groups = [
233
235
  { id: 'site', label: 'Title & description', terms: 'site title tagline url footer seo' },
234
236
  { id: 'localization', label: 'Timezone & language', terms: 'timezone locale language date' },
235
237
  { id: 'announcement', label: 'Announcement bar', terms: 'announcement banner bar' },
238
+ ...(can('site.settings') ? [{ id: 'legal', label: 'Legal', terms: 'legal privacy notice policy terms of service aviso de privacidad arco gdpr app store privacy data safety address' }] : []),
236
239
  ] },
237
240
  { label: 'Publishing', items: [
238
241
  ...(can('integrations.manage') ? [{ id: 'drafts-api', label: 'External drafts API', terms: 'api key drafts cors origins' }] : []),
@@ -381,6 +384,16 @@ const groups = [
381
384
  </form>
382
385
  </section>
383
386
 
387
+ {can('site.settings') && (
388
+ <section class="scard" id="legal">
389
+ <header class="scard-head">
390
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
391
+ <div class="grow"><h2>Legal</h2><p>{LEGAL.enabled ? 'Your privacy notice and terms (/privacy, /terms): who you are, your own sections, and the answers for the app stores.' : 'The standard privacy and terms pages are off for this site.'}</p></div>
392
+ </header>
393
+ <LegalSettings client:visible />
394
+ </section>
395
+ )}
396
+
384
397
  <div class="set-group-title">Publishing</div>
385
398
 
386
399
  {can('integrations.manage') && (
@@ -0,0 +1,23 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { ApiError, apiError, json, readJson } from '../../lib/api';
4
+ import { legalAdminState, saveLegal } from '../../lib/legal/admin';
5
+ import { requirePermission } from '../../lib/permissions/access';
6
+
7
+ // Settings → Legal (General settings permission, this site's admin only).
8
+ export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
9
+ try {
10
+ const repo = await getRepo(locals);
11
+ await requirePermission(request, cookies, repo, 'site.settings');
12
+ return json(await legalAdminState(repo, url, locals));
13
+ } catch (e) { return apiError(e); }
14
+ };
15
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
16
+ try {
17
+ const repo = await getRepo(locals);
18
+ await requirePermission(request, cookies, repo, 'site.settings');
19
+ if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
20
+ await saveLegal(repo, await readJson(request, 200_000));
21
+ return json(await legalAdminState(repo, url, locals));
22
+ } catch (e) { return apiError(e); }
23
+ };
@@ -1,18 +1,15 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { getRepo } from '../../../../../lib/repo';
3
- import { ApiError } from '../../../../../lib/api';
4
- import { requestSessionBinding } from '../../../../../lib/auth/request';
5
- import { accessFor } from '../../../../../lib/permissions/access';
6
- import { finishOAuth } from '../../../../../lib/live/service';
7
- export const GET:APIRoute=async({request,cookies,locals,url})=>{
3
+ import { receiveOAuthCallback } from '../../../../../lib/live/service';
4
+ // Google's redirect back is a cross-site GET that may carry no usable sign-in (Clerk development
5
+ // instances, a session token that expired on Google's screens), so it doesn't check one: it parks
6
+ // the code on its one-use pending state, and the signed-in admin page finishes with a same-origin
7
+ // POST (finishAuthorize) bound to the session that began it. The code never enters a URL again.
8
+ export const GET:APIRoute=async({locals,url})=>{
8
9
  const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
9
10
  try{
10
- const repo=await getRepo(locals),session=await requestSessionBinding(request,cookies,locals);
11
- // OAuth is intentionally a cross-site GET. Session-bound, one-use state provides CSRF protection.
12
- const access=await accessFor(request,cookies,repo);
13
- if(!session||!access?.can('services.manage'))throw new ApiError(403,'Sign in as someone who manages connected services and start authorization again.');
14
- await finishOAuth(repo,session,url,locals);
15
- return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
11
+ await receiveOAuthCallback(await getRepo(locals),url,locals);
12
+ return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=finish'}});
16
13
  }catch{
17
14
  // Never reflect callback codes, tokens or provider error bodies into an HTML page or URL.
18
15
  return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=error'}});
@@ -4,7 +4,7 @@ import { getRepo } from '../../../lib/repo';
4
4
  import { ApiError, apiError, json, readJson } from '../../../lib/api';
5
5
  import { requestSessionBinding } from '../../../lib/auth/request';
6
6
  import { Provider } from '../../../lib/live/types';
7
- import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
7
+ import { adminState, beginOAuth, disconnect, finishOAuth, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
8
8
  import { requirePermission } from '../../../lib/permissions/access';
9
9
  import { sendManualLiveNotice } from '../../../lib/push/service';
10
10
  const Input=z.discriminatedUnion('action',[
@@ -13,6 +13,8 @@ const Input=z.discriminatedUnion('action',[
13
13
  z.object({action:z.literal('selectChannel'),provider:z.literal('youtube'),channelId:z.string().min(1).max(200)}).strict(),
14
14
  z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
15
15
  z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
16
+ // after Google's redirect (?live=finish): exchange the parked code for this signed-in session
17
+ z.object({action:z.literal('finishAuthorize'),provider:z.literal('youtube')}).strict(),
16
18
  z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
17
19
  // notify: "Notify app users" on a manual link (sends once per link; needs push)
18
20
  z.object({action:z.literal('saveConfig'),config:z.unknown(),notify:z.boolean().default(false)}).strict(),
@@ -30,6 +32,7 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
30
32
  case 'saveConnection':await saveConnection(repo,input,locals);break;
31
33
  case 'test':await syncConnection(repo,input.provider,locals,true);break;
32
34
  case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
35
+ case 'finishAuthorize':await finishOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals);break;
33
36
  case 'disconnect':await disconnect(repo,input.provider,locals);break;
34
37
  case 'saveConfig':{
35
38
  await repo.updateSettings({live:validateConfig(input.config)});
@@ -0,0 +1,23 @@
1
+ /* Settings → Legal */
2
+ .lg { display: grid; gap: 20px; }
3
+ .lg-block { display: grid; gap: 10px; }
4
+ .lg-block h3 { margin: 0; font-size: 14px; }
5
+ .lg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px 16px; }
6
+ .lg-wide { grid-column: 1 / -1; }
7
+ .lg-warn { margin: 0; padding: 10px 12px 10px 28px; border: 1px solid var(--line); border-radius: 8px; background: var(--soft); font-size: 13px; display: grid; gap: 4px; }
8
+ .lg-links { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline; margin: 0; }
9
+ .lg-section { display: grid; gap: 8px; margin: 0; padding: 12px; border: 1px solid var(--line); border-radius: 8px; }
10
+ .lg-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
11
+ .lg-title { flex: 1 1 220px; }
12
+ .lg-section textarea, .lg-tr input, .lg-tr textarea { width: 100%; }
13
+ .lg-tr { display: grid; gap: 6px; }
14
+ .lg-tr summary { cursor: pointer; font-size: 13px; color: var(--muted); }
15
+ .lg-tr[open] { gap: 6px; }
16
+ .lg-tr[open] > * + * { margin-top: 6px; }
17
+ .lg-table { display: grid; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; font-size: 13px; }
18
+ .lg-table > div { display: grid; grid-template-columns: minmax(140px, 1fr) 2fr 2fr; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
19
+ .lg-table > div:first-child { border-top: 0; }
20
+ .lg-table small { display: block; margin-top: 2px; }
21
+ .lg-th { background: var(--soft); font-weight: 600; }
22
+ .lg-notes { margin: 0; padding-left: 18px; display: grid; gap: 4px; font-size: 13px; }
23
+ @media (max-width: 720px) { .lg-table > div { grid-template-columns: 1fr; gap: 4px; } }
package/src/virtual.d.ts CHANGED
@@ -18,7 +18,7 @@ declare module 'virtual:acms-config' {
18
18
  search?: { enabled: boolean };
19
19
  // resolved by integration.mjs; '' = pick from the environment (lib/maps/config.ts)
20
20
  maps?: { provider: '' | 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias' };
21
- legal?: { enabled: boolean; operatorName: string; contactEmail: string; updatedAt: string };
21
+ legal?: { enabled: boolean; operatorName: string; contactEmail: string; address: string; country: string; updatedAt: string };
22
22
  // resolved by integration.mjs (lib/roles.ts and lib/permissions/catalog.ts read them)
23
23
  roles?: { key: string; label: string; summary: string; admin: boolean; home: string }[];
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];