@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.
- package/CHANGELOG.md +33 -0
- package/cli/migrations.generated.mjs +9 -1
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +10 -5
- package/integration.mjs +6 -3
- package/package.json +1 -1
- package/src/components/LegalPage.astro +18 -10
- package/src/components/LegalSettings.tsx +152 -0
- package/src/components/LiveManager.tsx +15 -3
- package/src/lib/booking/service.ts +16 -1
- package/src/lib/calendar/schedule.ts +5 -1
- package/src/lib/legal/admin.ts +60 -0
- package/src/lib/legal/app-privacy.ts +49 -0
- package/src/lib/legal/config.ts +4 -0
- package/src/lib/legal/facts.ts +91 -0
- package/src/lib/legal/index.ts +34 -23
- package/src/lib/legal/text-en.ts +128 -0
- package/src/lib/legal/text-es.ts +129 -0
- package/src/lib/legal/text.ts +69 -0
- package/src/lib/legal/types.ts +28 -0
- package/src/lib/live/service.ts +22 -5
- package/src/lib/live/store.ts +13 -3
- package/src/lib/mobile/schemas.ts +2 -1
- package/src/lib/mobile/site.ts +5 -0
- package/src/lib/push/service.ts +2 -1
- package/src/lib/repo/migrations.ts +9 -1
- package/src/lib/repo/sql.ts +15 -2
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +4 -0
- package/src/pages/admin/settings.astro +13 -0
- package/src/pages/api/legal.ts +23 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +8 -11
- package/src/pages/api/live/settings.ts +4 -1
- package/src/styles/legal-settings.css +23 -0
- package/src/virtual.d.ts +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { Settings } from '../schema';
|
|
4
|
+
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
5
|
+
import { PUSH } from '../push/config';
|
|
6
|
+
import { AUTH } from '../auth/config';
|
|
7
|
+
import { EDITOR } from '../editor/config';
|
|
8
|
+
import { SITE_ROLES } from '../roles';
|
|
9
|
+
import { mapsStatus } from '../maps/config';
|
|
10
|
+
import { resolveConfig } from '../email/config';
|
|
11
|
+
import { guestSignInReady } from '../mobile/guest';
|
|
12
|
+
import { offsiteConfig } from '../backup/offsite';
|
|
13
|
+
import { LEGAL } from './config';
|
|
14
|
+
|
|
15
|
+
// What this site actually does with personal information, read from its
|
|
16
|
+
// configuration and settings, so the generated notice only describes enabled
|
|
17
|
+
// features (and names the providers in use).
|
|
18
|
+
|
|
19
|
+
export interface LegalOperator {
|
|
20
|
+
name: string; address: string; country: string; email: string; contact: string;
|
|
21
|
+
}
|
|
22
|
+
export interface LegalFacts {
|
|
23
|
+
operator: LegalOperator;
|
|
24
|
+
/** the operator supplied a name and a contact email (otherwise the page is marked incomplete and noindex) */
|
|
25
|
+
complete: boolean;
|
|
26
|
+
calendar: boolean;
|
|
27
|
+
/** resource bookings (RSVPs come with the calendar) */
|
|
28
|
+
bookings: boolean;
|
|
29
|
+
guestSignIn: boolean;
|
|
30
|
+
/** the app API takes form responses (Admin → Mobile app → App access) */
|
|
31
|
+
appForms: boolean;
|
|
32
|
+
push: { enabled: boolean; retentionDays: number };
|
|
33
|
+
auth: 'builtin' | 'clerk';
|
|
34
|
+
/** people other than staff can sign in (site roles or members pages) */
|
|
35
|
+
members: boolean;
|
|
36
|
+
editorApps: boolean;
|
|
37
|
+
/** email provider name, or '' when email isn't set up */
|
|
38
|
+
email: string;
|
|
39
|
+
/** maps provider name, or '' */
|
|
40
|
+
maps: string;
|
|
41
|
+
live: boolean;
|
|
42
|
+
analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
|
|
43
|
+
ai: boolean;
|
|
44
|
+
offsiteBackups: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMAIL_NAMES: Record<string, string> = { resend: 'Resend', mailgun: 'Mailgun', ses: 'Amazon SES', cloudflare: 'Cloudflare Email' };
|
|
48
|
+
|
|
49
|
+
/** Operator details: Settings → Legal first, then acms({ legal }) config, then the site title. */
|
|
50
|
+
export function legalOperator(settings: Settings): LegalOperator {
|
|
51
|
+
const l = settings.legal;
|
|
52
|
+
return {
|
|
53
|
+
name: l.operatorName || LEGAL.operatorName || settings.siteTitle,
|
|
54
|
+
address: l.address || LEGAL.address,
|
|
55
|
+
country: l.country || LEGAL.country,
|
|
56
|
+
email: l.contactEmail || LEGAL.contactEmail,
|
|
57
|
+
contact: l.privacyContact,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function legalFacts(repo: ContentRepository, settings: Settings, locals?: unknown): Promise<LegalFacts> {
|
|
62
|
+
const operator = legalOperator(settings);
|
|
63
|
+
const email = await repo.email.config().then((record) => resolveConfig(record, locals)).catch(() => null);
|
|
64
|
+
let offsite = false;
|
|
65
|
+
try { offsite = !!offsiteConfig(locals); } catch { offsite = true; /* configured, if misconfigured */ }
|
|
66
|
+
const a = settings.analytics;
|
|
67
|
+
const members = SITE_ROLES.length > 0 || (userConfig as { members?: { enabled?: boolean } }).members?.enabled === true;
|
|
68
|
+
return {
|
|
69
|
+
operator,
|
|
70
|
+
complete: Boolean((settings.legal.operatorName || LEGAL.operatorName) && operator.email),
|
|
71
|
+
calendar: CALENDAR.enabled,
|
|
72
|
+
bookings: bookingsEnabled(),
|
|
73
|
+
guestSignIn: await guestSignInReady(repo, locals).catch(() => false),
|
|
74
|
+
appForms: settings.mobileAccess.forms,
|
|
75
|
+
push: { enabled: PUSH.enabled, retentionDays: PUSH.retentionDays },
|
|
76
|
+
auth: AUTH.provider === 'clerk' ? 'clerk' : 'builtin',
|
|
77
|
+
members,
|
|
78
|
+
editorApps: EDITOR.enabled,
|
|
79
|
+
email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
|
|
80
|
+
maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
|
|
81
|
+
live: settings.live.mode !== 'off',
|
|
82
|
+
analytics: {
|
|
83
|
+
umami: a.umami.enabled && !!a.umami.websiteId,
|
|
84
|
+
replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
|
|
85
|
+
meta: a.metaPixel.enabled && !!a.metaPixel.pixelId,
|
|
86
|
+
tiktok: a.tiktokPixel.enabled && !!a.tiktokPixel.pixelId,
|
|
87
|
+
},
|
|
88
|
+
ai: settings.ai.enabled,
|
|
89
|
+
offsiteBackups: offsite,
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/lib/legal/index.ts
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import type { Settings } from '../schema';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
];
|
|
24
|
-
|
|
3
|
+
import { LEGAL } from './config';
|
|
4
|
+
import { legalFacts, type LegalFacts } from './facts';
|
|
5
|
+
import { buildPolicy, policyHash, siteDay, type BuiltPolicy, type Policy } from './text';
|
|
6
|
+
|
|
7
|
+
// The standard /privacy and /terms pages: built from the features this site
|
|
8
|
+
// has turned on (lib/legal/facts.ts), in English or Spanish, with the owner's
|
|
9
|
+
// own sections from Settings → Legal. A published CMS page with the same slug
|
|
10
|
+
// replaces either one.
|
|
11
|
+
export { LEGAL } from './config';
|
|
12
|
+
export { legalFacts, legalOperator, type LegalFacts, type LegalOperator } from './facts';
|
|
13
|
+
export { buildPolicy, policyLocale, legalLanguage, LEGAL_LANGUAGES, type BuiltPolicy, type Policy, type PolicySection } from './text';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* When the policy's wording last changed (YYYY-MM-DD): a fingerprint of the
|
|
17
|
+
* text is kept, and the date moves when it differs (a feature turned on, an
|
|
18
|
+
* owner edit, new framework text). acms({ legal: { updatedAt } }) is the
|
|
19
|
+
* earliest date shown.
|
|
20
|
+
*/
|
|
21
|
+
export async function policyUpdated(repo: ContentRepository, kind: Policy, facts: LegalFacts, settings: Settings, now = Date.now()): Promise<string> {
|
|
22
|
+
const hash = policyHash(kind, facts, settings);
|
|
23
|
+
let stamp = settings.legalStamps[kind];
|
|
24
|
+
if (stamp?.hash !== hash) {
|
|
25
|
+
stamp = { hash, date: siteDay(now, settings.timezone) };
|
|
26
|
+
await repo.updateSettings({ legalStamps: { ...settings.legalStamps, [kind]: stamp } }).catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
return LEGAL.updatedAt && LEGAL.updatedAt > stamp.date ? LEGAL.updatedAt : stamp.date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Everything a page needs: the text in the language and the "last updated" date. For a site's own /privacy page too. */
|
|
32
|
+
export async function legalPolicy(repo: ContentRepository, kind: Policy, opts: { locale: string; locals?: unknown; now?: number }): Promise<BuiltPolicy & { updated: string }> {
|
|
33
|
+
const settings = await repo.getSettings();
|
|
34
|
+
const facts = await legalFacts(repo, settings, opts.locals);
|
|
35
|
+
return { ...buildPolicy(kind, facts, settings, opts.locale), updated: await policyUpdated(repo, kind, facts, settings, opts.now) };
|
|
25
36
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { LegalFacts } from './facts';
|
|
2
|
+
import type { PolicySection, PolicyText } from './text';
|
|
3
|
+
|
|
4
|
+
// The standard privacy notice and terms in English. Every section describes
|
|
5
|
+
// what the framework does; text.test.ts keeps text-es.ts in step (same ids).
|
|
6
|
+
|
|
7
|
+
const list = (xs: string[]) => xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`;
|
|
8
|
+
const days = (n: number) => `${n} days`;
|
|
9
|
+
|
|
10
|
+
function processors(f: LegalFacts): string[] {
|
|
11
|
+
return [
|
|
12
|
+
'our website host and network provider',
|
|
13
|
+
...(f.email ? [`${f.email} (email)`] : []),
|
|
14
|
+
...(f.push.enabled ? ['Expo, Apple and Google (app notifications)'] : []),
|
|
15
|
+
...(f.auth === 'clerk' ? ['Clerk (sign-in)'] : []),
|
|
16
|
+
...(f.maps ? [`${f.maps} (maps)`] : []),
|
|
17
|
+
...(f.analytics.umami ? ['Umami (visit statistics)'] : []),
|
|
18
|
+
...(f.ai ? ['OpenAI or another AI service we choose (writing tools used by staff)'] : []),
|
|
19
|
+
...(f.offsiteBackups ? ['our backup storage provider'] : []),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
24
|
+
const o = f.operator;
|
|
25
|
+
const pixels = f.analytics.meta || f.analytics.tiktok;
|
|
26
|
+
const pixelNames = list([...(f.analytics.meta ? ['Meta Pixel (Facebook, Instagram)'] : []), ...(f.analytics.tiktok ? ['TikTok Pixel'] : [])]);
|
|
27
|
+
const app = f.push.enabled || f.guestSignIn;
|
|
28
|
+
const s: PolicySection[] = [
|
|
29
|
+
{ id: 'responsible', title: 'Who is responsible', text: [
|
|
30
|
+
`${o.name} (“we”) is responsible for the personal information collected through this website${app ? ' and our app' : ''}, and decides how it is used.${o.address ? ` Our address is ${o.address}.` : ''}${o.email ? ` Contact us at ${o.email}${o.contact ? ` (attention: ${o.contact})` : ''}.` : ' Contact us using the details published on this site.'}`,
|
|
31
|
+
'The ACMS software that runs this site doesn’t receive a copy of your information.',
|
|
32
|
+
] },
|
|
33
|
+
{ id: 'visiting', title: 'Visiting the site', text: [
|
|
34
|
+
'Our hosting and network providers process your IP address, the pages you request and technical logs to deliver the site, keep it secure and fix errors. To limit repeated requests, the site keeps a scrambled (hashed) form of your IP address in short-lived counters, normally for about a minute. Searches on the site aren’t stored.',
|
|
35
|
+
...(app ? ['Our app reads the same public information as the website and needs no account; its requests are limited the same way.'] : []),
|
|
36
|
+
] },
|
|
37
|
+
{ id: 'accounts', title: f.members ? 'Staff and member accounts' : 'Staff accounts', text: [
|
|
38
|
+
f.auth === 'clerk'
|
|
39
|
+
? `${f.members ? 'Staff and members' : 'Staff'} sign in through Clerk (clerk.com), which processes their email address, sign-in details and session cookies on our behalf. We keep their name, email address and role.`
|
|
40
|
+
: `${f.members ? 'Staff and members' : 'Staff'} who sign in have an account with their name, email address, role and a protected (salted and hashed) form of their password, never the password itself. A cookie keeps them signed in for up to seven days.`,
|
|
41
|
+
...(f.editorApps ? ['Staff who sign in to our editor app stay signed in on that device for up to 90 days unless they sign out or their access is removed.'] : []),
|
|
42
|
+
] },
|
|
43
|
+
];
|
|
44
|
+
if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Bookings and RSVPs' : 'Event RSVPs', text: [
|
|
45
|
+
`When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}.`,
|
|
46
|
+
'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
|
|
47
|
+
] });
|
|
48
|
+
s.push({ id: 'forms', title: 'Forms', text: [
|
|
49
|
+
'When you send a form, we store your answers as you entered them (for example your name, email address or phone number, if the form asks for them) and when you sent it. Staff with access to form responses can read and export them. We don’t store your IP address with a response.',
|
|
50
|
+
] });
|
|
51
|
+
if (f.guestSignIn) s.push({ id: 'guest', title: 'Signing in to see your bookings', text: [
|
|
52
|
+
'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
|
|
53
|
+
] });
|
|
54
|
+
if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
|
|
55
|
+
'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language and the topics you choose. We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
|
|
56
|
+
`You choose the topics in the app and can change them at any time; we never add a topic without asking you. To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
|
|
57
|
+
] });
|
|
58
|
+
if (f.email) s.push({ id: 'email', title: 'Email', text: [
|
|
59
|
+
`We send email through ${f.email}, which processes your email address and the message. We keep a delivery record (recipient and status) for 30 days, and keep addresses that bounce or report our email as spam on a list so we stop writing to them.`,
|
|
60
|
+
] });
|
|
61
|
+
if (f.maps) s.push({ id: 'maps', title: 'Maps', text: [
|
|
62
|
+
`When staff enter an event’s address, we look it up with ${f.maps}. Your browser doesn’t contact the map service: map images are stored on this site. Directions links open Google Maps only if you choose them.`,
|
|
63
|
+
] });
|
|
64
|
+
if (f.live) s.push({ id: 'live', title: 'Live video', text: [
|
|
65
|
+
'When we stream, the site shows our broadcast from YouTube or Twitch. Until you press play, your browser only contacts this site: preview images come through it. Pressing play, or opening the broadcast on YouTube or Twitch, loads that platform, which applies its own privacy policy.',
|
|
66
|
+
] });
|
|
67
|
+
s.push({ id: 'embeds', title: 'Embedded content and fonts', text: [
|
|
68
|
+
'Some pages include content from other services, such as videos (for example YouTube or Vimeo). When that content loads, the service receives your IP address and may set its own cookies. Pages may load fonts from Google Fonts, and your browser connects to Google’s font servers on each page.',
|
|
69
|
+
] });
|
|
70
|
+
if (f.analytics.umami || pixels) s.push({ id: 'analytics', title: 'Visit statistics and advertising', text: [
|
|
71
|
+
...(f.analytics.umami ? [`We count visits with Umami, which doesn’t use cookies.${f.analytics.replay ? ' It also records a sample of visits (pages, clicks and scrolling, with what you type hidden) to help us improve the site.' : ''}`] : []),
|
|
72
|
+
...(pixels ? [`This site uses ${pixelNames}. ${f.analytics.meta && f.analytics.tiktok ? 'They record' : 'It records'} the pages you visit and buttons you use and send them to ${f.analytics.meta && f.analytics.tiktok ? 'Meta and TikTok' : f.analytics.meta ? 'Meta' : 'TikTok'} to measure our advertising, under ${f.analytics.meta && f.analytics.tiktok ? 'their' : 'its'} own privacy policy. ${f.analytics.meta && f.analytics.tiktok ? 'They' : 'It'} may connect this to your account there and set cookies. You can block these with your browser’s privacy settings.`] : []),
|
|
73
|
+
] });
|
|
74
|
+
s.push({ id: 'integrations', title: 'Tools we connect', text: [
|
|
75
|
+
`Staff may connect other tools to this site, such as automations (webhooks), API keys or AI assistants. These can receive the information they need, such as booking details, and work on our behalf.${f.ai ? ' Staff may use AI writing tools to draft and translate our content; we don’t send your personal information to them.' : ''} We keep backups of the site, including bookings and form responses${f.offsiteBackups ? ', also stored off-site' : ''}, so we can restore it after a failure.`,
|
|
76
|
+
] });
|
|
77
|
+
s.push({ id: 'sensitive', title: 'Sensitive information', text: [
|
|
78
|
+
'The standard features of this site don’t ask for sensitive information, such as health details or religious beliefs. If a form or registration asks for any, it will say so and ask for your express consent first.',
|
|
79
|
+
] });
|
|
80
|
+
s.push({ id: 'cookies', title: 'Cookies and storage on your device', text: [
|
|
81
|
+
`${f.auth === 'clerk' ? 'Clerk’s sign-in cookies' : 'A sign-in cookie (up to seven days)'} for people who sign in; a preview cookie only for staff previews and design share links (up to 30 days); and, in your browser, your light or dark mode choice, the announcements you close and, for one visit, the live video notices you close.${pixels ? ` ${pixelNames} ${f.analytics.meta && f.analytics.tiktok ? 'set their' : 'sets its'} own advertising cookies.` : ' We don’t use cookies for advertising.'}`,
|
|
82
|
+
...(app ? ['Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
|
|
83
|
+
] });
|
|
84
|
+
s.push({ id: 'sharing', title: 'Who receives your information', text: [
|
|
85
|
+
`We don’t sell your personal information${pixels ? '' : ' or share it for advertising'}. These service providers process it on our behalf and only for the purposes above: ${list(processors(f))}. They must protect it at least as this notice describes. Some of them may store it outside your country, including in the United States.`,
|
|
86
|
+
`${pixels ? `Apart from the advertising tools above, we` : 'We'} don’t pass your information to anyone else unless the law requires it.`,
|
|
87
|
+
] });
|
|
88
|
+
s.push({ id: 'retention', title: 'How long we keep it', text: [
|
|
89
|
+
`Bookings and form responses: until we delete them, as part of our record keeping.${f.push.enabled ? ` App notification devices: ${days(f.push.retentionDays)} after the app was last opened.` : ''}${f.email ? ' Email delivery records: 30 days.' : ''}${f.guestSignIn ? ' Sign-in codes: 10 minutes; app sign-ins: 90 days unused.' : ''} Records that stop a booking from being submitted twice (a scrambled copy of the request) are kept to prevent repeats. Backups keep copies until they are replaced.`,
|
|
90
|
+
] });
|
|
91
|
+
s.push({ id: 'rights', title: 'Your choices and rights', text: [
|
|
92
|
+
`You can ask us to show, correct or delete your information, object to a use of it, or withdraw consent you gave${country === 'MX' ? ' (your ARCO rights)' : ''}. Write to ${o.email || 'us'}${o.contact ? `, attention ${o.contact},` : ''} with your name, a way to reply to you, the information concerned and what you’d like us to do, plus anything that helps us find it (such as the email address you used). We may ask you to confirm your identity. We’ll answer within 20 business days and carry out your request within 15 business days after that. Requests are free.`,
|
|
93
|
+
`You can also: ${list([...(f.push.enabled ? ['turn notifications off in the app or in your phone’s settings'] : []), ...(f.calendar ? ['cancel a booking with its private link'] : []), ...(f.guestSignIn ? ['sign out of the app'] : []), 'ask us to stop emailing you'])}.${country === 'MX' ? ' If you’re not satisfied with our answer, you can contact Mexico’s data protection authority, the Secretaría Anticorrupción y Buen Gobierno.' : ''}`,
|
|
94
|
+
] });
|
|
95
|
+
s.push({ id: 'children', title: 'Children', text: [
|
|
96
|
+
'This site is for a general audience and isn’t directed at children under 13. Bookings, registrations and forms for children should be completed by a parent or guardian.',
|
|
97
|
+
] });
|
|
98
|
+
s.push({ id: 'tracking', title: 'Do Not Track and other sites', text: [
|
|
99
|
+
pixels
|
|
100
|
+
? `We don’t respond to browsers’ “Do Not Track” signals. The advertising tools above (${pixelNames}) can collect information about your activity over time and across other sites. Embedded content can also be collected by its provider.`
|
|
101
|
+
: 'We don’t track you across other sites, so a browser’s “Do Not Track” signal has nothing to turn off. Services whose content is embedded on our pages can collect information when it loads.',
|
|
102
|
+
] });
|
|
103
|
+
return s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function termsEn(f: LegalFacts, country: string, countryName: string): PolicySection[] {
|
|
107
|
+
const o = f.operator;
|
|
108
|
+
return [
|
|
109
|
+
{ id: 'about', title: 'About this service', text: [`${o.name} provides this website${f.push.enabled || f.guestSignIn ? ', our app' : ''} and their content${f.calendar ? ', calendar and registrations' : ''}.${o.email ? ` Contact us at ${o.email}.` : ' Contact us using the details published on this site.'} These terms cover the standard features; other services may come with their own terms.`] },
|
|
110
|
+
{ id: 'use', title: 'Using the site', text: ['Use the service lawfully. Don’t try to get unauthorized access, disrupt it, get around its request limits, or send harmful or misleading material. Keep your sign-in details and private booking links secure.'] },
|
|
111
|
+
...(f.calendar ? [{ id: 'bookings', title: f.bookings ? 'Bookings and events' : 'Events and RSVPs', text: ['Give accurate contact and request details. Sending a request doesn’t guarantee a place: check whether it’s pending or confirmed. Availability can change until your request is saved. If we cancel a date, we’ll tell the people registered for it. Any fees, cancellation rules or eligibility for a particular event will be shown with that event.'] }] : []),
|
|
112
|
+
{ id: 'forms', title: 'Forms and submissions', text: ['Send only information you’re allowed to share, and don’t send someone else’s information without their permission. We use what you send as the privacy notice describes. Respect the rights of the people who own the content published here.'] },
|
|
113
|
+
...(f.push.enabled ? [{ id: 'app', title: 'Our app and notifications', text: ['Notifications are optional and the app works without them. Choose your topics in the app and turn them off at any time. Important notices, such as cancellations, may go to everyone who has notifications turned on. We don’t use notifications to advertise other companies’ products.'] }] : []),
|
|
114
|
+
...(f.guestSignIn ? [{ id: 'guest', title: 'Signing in with your email', text: ['Keep the codes we email you private. Sign out of the app on devices you no longer use.'] }] : []),
|
|
115
|
+
{ id: 'availability', title: 'Availability and other services', text: ['The service may be interrupted for maintenance or technical reasons. Published information can change or contain mistakes; contact us to confirm anything important. Linked and embedded services are run by their own providers under their own terms and privacy policies.'] },
|
|
116
|
+
{ id: 'law', title: 'Your rights and changes', text: [
|
|
117
|
+
`Nothing in these terms limits rights you have under the law that can’t be limited.${country ? ` These terms are governed by the laws of ${countryName}, except where the law where you live gives you more protection.` : ''} We’ll post changes here and announce important ones on the site before they take effect.`,
|
|
118
|
+
] },
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const EN: PolicyText = {
|
|
123
|
+
privacyTitle: 'Privacy notice', termsTitle: 'Terms of service', updated: 'Last updated',
|
|
124
|
+
incomplete: 'Standard notice: the site operator hasn’t finished adding its name and contact details yet. This page describes the site’s standard features.',
|
|
125
|
+
contact: 'Contact us about privacy', changesTitle: 'Changes to this notice',
|
|
126
|
+
changes: (app) => `We’ll post any changes here and update the date at the top. If a change is significant, we’ll also announce it on the site${app ? ' or in the app' : ''} before it takes effect.`,
|
|
127
|
+
privacy: privacyEn, terms: termsEn,
|
|
128
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { LegalFacts } from './facts';
|
|
2
|
+
import type { PolicySection, PolicyText } from './text';
|
|
3
|
+
|
|
4
|
+
// El aviso de privacidad y los términos estándar en español. Mismas secciones
|
|
5
|
+
// (mismos ids) que text-en.ts; text.test.ts lo comprueba.
|
|
6
|
+
|
|
7
|
+
const list = (xs: string[]) => xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} y ${xs[xs.length - 1]}`;
|
|
8
|
+
const days = (n: number) => `${n} días`;
|
|
9
|
+
|
|
10
|
+
function processors(f: LegalFacts): string[] {
|
|
11
|
+
return [
|
|
12
|
+
'nuestro proveedor de alojamiento web y de red',
|
|
13
|
+
...(f.email ? [`${f.email} (correo electrónico)`] : []),
|
|
14
|
+
...(f.push.enabled ? ['Expo, Apple y Google (notificaciones de la app)'] : []),
|
|
15
|
+
...(f.auth === 'clerk' ? ['Clerk (inicio de sesión)'] : []),
|
|
16
|
+
...(f.maps ? [`${f.maps} (mapas)`] : []),
|
|
17
|
+
...(f.analytics.umami ? ['Umami (estadísticas de visitas)'] : []),
|
|
18
|
+
...(f.ai ? ['OpenAI u otro servicio de inteligencia artificial que elijamos (herramientas de redacción que usa el personal)'] : []),
|
|
19
|
+
...(f.offsiteBackups ? ['nuestro proveedor de almacenamiento de respaldos'] : []),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
24
|
+
const o = f.operator;
|
|
25
|
+
const pixels = f.analytics.meta || f.analytics.tiktok;
|
|
26
|
+
const both = f.analytics.meta && f.analytics.tiktok;
|
|
27
|
+
const pixelNames = list([...(f.analytics.meta ? ['Meta Pixel (Facebook, Instagram)'] : []), ...(f.analytics.tiktok ? ['TikTok Pixel'] : [])]);
|
|
28
|
+
const app = f.push.enabled || f.guestSignIn;
|
|
29
|
+
const s: PolicySection[] = [
|
|
30
|
+
{ id: 'responsible', title: 'Responsable', text: [
|
|
31
|
+
`${o.name} (“nosotros”) es responsable de los datos personales que se recaban a través de este sitio web${app ? ' y de nuestra app' : ''}, y decide cómo se usan.${o.address ? ` Nuestro domicilio es ${o.address}.` : ''}${o.email ? ` Puede escribirnos a ${o.email}${o.contact ? ` (atención: ${o.contact})` : ''}.` : ' Puede contactarnos con los datos publicados en este sitio.'}`,
|
|
32
|
+
'El software ACMS con el que funciona este sitio no recibe una copia de sus datos.',
|
|
33
|
+
] },
|
|
34
|
+
{ id: 'visiting', title: 'Al visitar el sitio', text: [
|
|
35
|
+
'Nuestros proveedores de alojamiento y de red tratan su dirección IP, las páginas que solicita y registros técnicos para mostrar el sitio, mantenerlo seguro y corregir errores. Para limitar las solicitudes repetidas, el sitio guarda su dirección IP cifrada de forma irreversible (hash) en contadores de corta duración, normalmente cerca de un minuto. Las búsquedas en el sitio no se guardan.',
|
|
36
|
+
...(app ? ['Nuestra app lee la misma información pública que el sitio web y no necesita una cuenta; sus solicitudes se limitan de la misma forma.'] : []),
|
|
37
|
+
] },
|
|
38
|
+
{ id: 'accounts', title: f.members ? 'Cuentas del personal y de los miembros' : 'Cuentas del personal', text: [
|
|
39
|
+
f.auth === 'clerk'
|
|
40
|
+
? `${f.members ? 'El personal y los miembros inician' : 'El personal inicia'} sesión a través de Clerk (clerk.com), que trata por cuenta nuestra su correo electrónico, sus datos de inicio de sesión y sus cookies de sesión. Nosotros guardamos su nombre, su correo electrónico y su función.`
|
|
41
|
+
: `${f.members ? 'El personal y los miembros que inician' : 'El personal que inicia'} sesión tienen una cuenta con su nombre, su correo electrónico, su función y una versión protegida (con sal y hash) de su contraseña, nunca la contraseña en sí. Una cookie mantiene la sesión abierta hasta siete días.`,
|
|
42
|
+
...(f.editorApps ? ['El personal que inicia sesión en nuestra app de edición mantiene la sesión en ese dispositivo hasta 90 días, salvo que la cierre o se le retire el acceso.'] : []),
|
|
43
|
+
] },
|
|
44
|
+
];
|
|
45
|
+
if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
|
|
46
|
+
`Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}.`,
|
|
47
|
+
'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
|
|
48
|
+
] });
|
|
49
|
+
s.push({ id: 'forms', title: 'Formularios', text: [
|
|
50
|
+
'Cuando envía un formulario, guardamos sus respuestas tal como las escribió (por ejemplo su nombre, correo o teléfono, si el formulario los pide) y la fecha en que lo envió. El personal con acceso a las respuestas puede leerlas y exportarlas. No guardamos su dirección IP con la respuesta.',
|
|
51
|
+
] });
|
|
52
|
+
if (f.guestSignIn) s.push({ id: 'guest', title: 'Inicio de sesión para ver sus reservaciones', text: [
|
|
53
|
+
'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
|
|
54
|
+
] });
|
|
55
|
+
if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
|
|
56
|
+
'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma y los temas que elija. Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
|
|
57
|
+
`Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
|
|
58
|
+
] });
|
|
59
|
+
if (f.email) s.push({ id: 'email', title: 'Correo electrónico', text: [
|
|
60
|
+
`Enviamos correos a través de ${f.email}, que trata su dirección de correo y el mensaje. Guardamos un registro de entrega (destinatario y estado) durante 30 días, y mantenemos en una lista las direcciones que rebotan o marcan nuestros correos como spam para dejar de escribirles.`,
|
|
61
|
+
] });
|
|
62
|
+
if (f.maps) s.push({ id: 'maps', title: 'Mapas', text: [
|
|
63
|
+
`Cuando el personal escribe la dirección de un evento, la buscamos con ${f.maps}. Su navegador no se conecta con el servicio de mapas: las imágenes de los mapas se guardan en este sitio. Los enlaces para llegar abren Google Maps solo si usted los elige.`,
|
|
64
|
+
] });
|
|
65
|
+
if (f.live) s.push({ id: 'live', title: 'Video en vivo', text: [
|
|
66
|
+
'Cuando transmitimos, el sitio muestra nuestra transmisión de YouTube o Twitch. Hasta que usted presiona reproducir, su navegador solo se conecta con este sitio: las imágenes de vista previa pasan por él. Al presionar reproducir, o al abrir la transmisión en YouTube o Twitch, se carga esa plataforma, que aplica su propia política de privacidad.',
|
|
67
|
+
] });
|
|
68
|
+
s.push({ id: 'embeds', title: 'Contenido insertado y tipografías', text: [
|
|
69
|
+
'Algunas páginas incluyen contenido de otros servicios, como videos (por ejemplo de YouTube o Vimeo). Cuando ese contenido se carga, el servicio recibe su dirección IP y puede guardar sus propias cookies. Las páginas pueden cargar tipografías de Google Fonts, y su navegador se conecta con los servidores de tipografías de Google en cada página.',
|
|
70
|
+
] });
|
|
71
|
+
if (f.analytics.umami || pixels) s.push({ id: 'analytics', title: 'Estadísticas de visitas y publicidad', text: [
|
|
72
|
+
...(f.analytics.umami ? [`Contamos las visitas con Umami, que no usa cookies.${f.analytics.replay ? ' También graba una muestra de visitas (páginas, clics y desplazamiento, ocultando lo que usted escribe) para ayudarnos a mejorar el sitio.' : ''}`] : []),
|
|
73
|
+
...(pixels ? [`Este sitio usa ${pixelNames}. ${both ? 'Registran' : 'Registra'} las páginas que visita y los botones que usa, y los ${both ? 'envían' : 'envía'} a ${both ? 'Meta y TikTok' : f.analytics.meta ? 'Meta' : 'TikTok'} para medir nuestra publicidad, conforme a ${both ? 'sus propias políticas' : 'su propia política'} de privacidad. ${both ? 'Pueden' : 'Puede'} relacionarlo con su cuenta en ${both ? 'esos servicios' : 'ese servicio'} y guardar cookies. Puede bloquearlos en la configuración de privacidad de su navegador.`] : []),
|
|
74
|
+
] });
|
|
75
|
+
s.push({ id: 'integrations', title: 'Herramientas que conectamos', text: [
|
|
76
|
+
`El personal puede conectar otras herramientas a este sitio, como automatizaciones (webhooks), claves de API o asistentes de inteligencia artificial. Estas pueden recibir la información que necesitan, como los datos de una reservación, y trabajan por cuenta nuestra.${f.ai ? ' El personal puede usar herramientas de redacción con inteligencia artificial para escribir y traducir nuestro contenido; no les enviamos sus datos personales.' : ''} Guardamos respaldos del sitio, incluidas las reservaciones y las respuestas a formularios${f.offsiteBackups ? ', también fuera del servidor' : ''}, para poder restaurarlo después de una falla.`,
|
|
77
|
+
] });
|
|
78
|
+
s.push({ id: 'sensitive', title: 'Datos sensibles', text: [
|
|
79
|
+
'Las funciones estándar de este sitio no piden datos sensibles, como datos de salud o creencias religiosas. Si un formulario o un registro pide alguno, lo indicará y le pedirá antes su consentimiento expreso.',
|
|
80
|
+
] });
|
|
81
|
+
s.push({ id: 'cookies', title: 'Cookies y almacenamiento en su dispositivo', text: [
|
|
82
|
+
`${f.auth === 'clerk' ? 'Las cookies de inicio de sesión de Clerk' : 'Una cookie de inicio de sesión (hasta siete días)'} para quienes inician sesión; una cookie de vista previa solo para las vistas previas del personal y los enlaces para compartir diseños (hasta 30 días); y, en su navegador, su preferencia de modo claro u oscuro, los anuncios que cierra y, durante una visita, los avisos de video en vivo que cierra.${pixels ? ` ${pixelNames} ${both ? 'guardan sus propias' : 'guarda sus propias'} cookies de publicidad.` : ' No usamos cookies con fines publicitarios.'}`,
|
|
83
|
+
...(app ? ['Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
|
|
84
|
+
] });
|
|
85
|
+
s.push({ id: 'sharing', title: 'Quién recibe sus datos', text: [
|
|
86
|
+
`No vendemos sus datos personales${pixels ? '' : ' ni los compartimos con fines publicitarios'}. Estos proveedores los tratan por cuenta nuestra y solo para los fines descritos: ${list(processors(f))}. Deben protegerlos al menos como lo describe este aviso. Algunos pueden guardarlos fuera de su país, incluido Estados Unidos.`,
|
|
87
|
+
`${pixels ? 'Aparte de las herramientas de publicidad mencionadas, no' : 'No'} transferimos sus datos a nadie más, salvo que la ley lo exija.`,
|
|
88
|
+
] });
|
|
89
|
+
s.push({ id: 'retention', title: 'Cuánto tiempo los guardamos', text: [
|
|
90
|
+
`Reservaciones y respuestas a formularios: hasta que las borramos, como parte de nuestros registros.${f.push.enabled ? ` Dispositivos con notificaciones: ${days(f.push.retentionDays)} después de la última vez que se abrió la app.` : ''}${f.email ? ' Registros de entrega de correo: 30 días.' : ''}${f.guestSignIn ? ' Códigos de inicio de sesión: 10 minutos; sesiones en la app: 90 días sin uso.' : ''} Los registros que evitan que una reservación se envíe dos veces (una copia cifrada de la solicitud) se guardan para impedir duplicados. Los respaldos conservan copias hasta que se reemplazan.`,
|
|
91
|
+
] });
|
|
92
|
+
s.push({ id: 'rights', title: 'Sus opciones y derechos', text: [
|
|
93
|
+
`Puede pedirnos acceder a sus datos, rectificarlos o cancelarlos, oponerse a algún uso o revocar el consentimiento que nos dio${country === 'MX' ? ' (sus derechos ARCO)' : ''}. Escriba a ${o.email || 'nosotros'}${o.contact ? `, a la atención de ${o.contact},` : ''} con su nombre, un medio para responderle, los datos de que se trata y lo que desea, además de cualquier dato que nos ayude a encontrarlos (como el correo que usó). Podemos pedirle que confirme su identidad. Le responderemos en un máximo de 20 días hábiles y atenderemos su solicitud dentro de los 15 días hábiles siguientes. Las solicitudes son gratuitas.`,
|
|
94
|
+
`También puede: ${list([...(f.push.enabled ? ['desactivar las notificaciones en la app o en los ajustes de su teléfono'] : []), ...(f.calendar ? ['cancelar una reservación con su enlace privado'] : []), ...(f.guestSignIn ? ['cerrar sesión en la app'] : []), 'pedirnos que dejemos de escribirle'])}.${country === 'MX' ? ' Si no está conforme con nuestra respuesta, puede acudir a la autoridad de protección de datos, la Secretaría Anticorrupción y Buen Gobierno.' : ''}`,
|
|
95
|
+
] });
|
|
96
|
+
s.push({ id: 'children', title: 'Menores de edad', text: [
|
|
97
|
+
'Este sitio está dirigido al público en general y no a menores de 13 años. Las reservaciones, registros y formularios para menores deben llenarlos su madre, padre o tutor.',
|
|
98
|
+
] });
|
|
99
|
+
s.push({ id: 'tracking', title: '“No rastrear” y otros sitios', text: [
|
|
100
|
+
pixels
|
|
101
|
+
? `No respondemos a las señales de “No rastrear” (Do Not Track) de los navegadores. Las herramientas de publicidad mencionadas (${pixelNames}) pueden recabar información sobre su actividad a lo largo del tiempo y en otros sitios. El contenido insertado también puede recabar información a través de su proveedor.`
|
|
102
|
+
: 'No le damos seguimiento en otros sitios, así que la señal de “No rastrear” (Do Not Track) de su navegador no tiene nada que desactivar. Los servicios cuyo contenido aparece en nuestras páginas pueden recabar información cuando se carga.',
|
|
103
|
+
] });
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function termsEs(f: LegalFacts, country: string, countryName: string): PolicySection[] {
|
|
108
|
+
const o = f.operator;
|
|
109
|
+
return [
|
|
110
|
+
{ id: 'about', title: 'Sobre este servicio', text: [`${o.name} ofrece este sitio web${f.push.enabled || f.guestSignIn ? ', nuestra app' : ''} y su contenido${f.calendar ? ', calendario y registros' : ''}.${o.email ? ` Puede escribirnos a ${o.email}.` : ' Puede contactarnos con los datos publicados en este sitio.'} Estos términos cubren las funciones estándar; otros servicios pueden tener sus propios términos.`] },
|
|
111
|
+
{ id: 'use', title: 'Uso del sitio', text: ['Use el servicio conforme a la ley. No intente acceder sin autorización, interrumpirlo, evadir sus límites de solicitudes ni enviar material dañino o engañoso. Mantenga seguros sus datos de inicio de sesión y los enlaces privados de sus reservaciones.'] },
|
|
112
|
+
...(f.calendar ? [{ id: 'bookings', title: f.bookings ? 'Reservaciones y eventos' : 'Eventos y registros', text: ['Dé datos de contacto y de solicitud correctos. Enviar una solicitud no garantiza un lugar: revise si está pendiente o confirmada. La disponibilidad puede cambiar hasta que se guarde su solicitud. Si cancelamos una fecha, avisaremos a las personas registradas. Cualquier costo, regla de cancelación o requisito de un evento se mostrará con ese evento.'] }] : []),
|
|
113
|
+
{ id: 'forms', title: 'Formularios y envíos', text: ['Envíe solo información que tenga permiso de compartir, y no envíe datos de otra persona sin su permiso. Usamos lo que envía como lo describe el aviso de privacidad. Respete los derechos de quienes son dueños del contenido publicado aquí.'] },
|
|
114
|
+
...(f.push.enabled ? [{ id: 'app', title: 'Nuestra app y las notificaciones', text: ['Las notificaciones son opcionales y la app funciona sin ellas. Elija sus temas en la app y desactívelas cuando quiera. Los avisos importantes, como una cancelación, pueden enviarse a todas las personas que tienen las notificaciones activadas. No usamos las notificaciones para anunciar productos de otras empresas.'] }] : []),
|
|
115
|
+
...(f.guestSignIn ? [{ id: 'guest', title: 'Inicio de sesión con su correo', text: ['Mantenga en privado los códigos que le enviamos por correo. Cierre sesión en la app en los dispositivos que ya no use.'] }] : []),
|
|
116
|
+
{ id: 'availability', title: 'Disponibilidad y otros servicios', text: ['El servicio puede interrumpirse por mantenimiento o por razones técnicas. La información publicada puede cambiar o tener errores; contáctenos para confirmar lo importante. Los servicios enlazados e insertados los operan sus propios proveedores con sus propios términos y políticas de privacidad.'] },
|
|
117
|
+
{ id: 'law', title: 'Sus derechos y cambios', text: [
|
|
118
|
+
`Nada de lo dispuesto en estos términos limita los derechos que la ley le otorga y que no pueden limitarse.${country ? ` Estos términos se rigen por las leyes de ${countryName}, salvo cuando la ley del lugar donde usted vive le dé más protección.` : ''} Publicaremos aquí los cambios y anunciaremos en el sitio los importantes antes de que entren en vigor.`,
|
|
119
|
+
] },
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const ES: PolicyText = {
|
|
124
|
+
privacyTitle: 'Aviso de privacidad', termsTitle: 'Términos del servicio', updated: 'Última actualización',
|
|
125
|
+
incomplete: 'Aviso estándar: el responsable del sitio todavía no termina de agregar su nombre y sus datos de contacto. Esta página describe las funciones estándar del sitio.',
|
|
126
|
+
contact: 'Contáctenos sobre privacidad', changesTitle: 'Cambios a este aviso',
|
|
127
|
+
changes: (app) => `Publicaremos aquí cualquier cambio y actualizaremos la fecha que aparece arriba. Si un cambio es importante, también lo anunciaremos en el sitio${app ? ' o en la app' : ''} antes de que entre en vigor.`,
|
|
128
|
+
privacy: privacyEs, terms: termsEs,
|
|
129
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { Settings } from '../schema';
|
|
3
|
+
import { I18N } from '../i18n';
|
|
4
|
+
import type { LegalFacts } from './facts';
|
|
5
|
+
import type { LegalSection } from './types';
|
|
6
|
+
import { EN } from './text-en';
|
|
7
|
+
import { ES } from './text-es';
|
|
8
|
+
|
|
9
|
+
export type Policy = 'privacy' | 'terms';
|
|
10
|
+
export interface PolicySection { id: string; title: string; text: string[] }
|
|
11
|
+
export interface PolicyText {
|
|
12
|
+
privacyTitle: string; termsTitle: string; updated: string; incomplete: string; contact: string; changesTitle: string;
|
|
13
|
+
changes: (app: boolean) => string;
|
|
14
|
+
privacy: (f: LegalFacts, country: string) => PolicySection[];
|
|
15
|
+
terms: (f: LegalFacts, country: string, countryName: string) => PolicySection[];
|
|
16
|
+
}
|
|
17
|
+
export interface BuiltPolicy {
|
|
18
|
+
title: string; sections: PolicySection[];
|
|
19
|
+
/** the language the standard text is in (other site languages show English) */
|
|
20
|
+
lang: string;
|
|
21
|
+
complete: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** languages with built-in text; other site languages fall back to English */
|
|
25
|
+
export const LEGAL_LANGUAGES: Record<string, PolicyText> = { en: EN, es: ES };
|
|
26
|
+
const base = (locale: string) => locale.toLowerCase().split('-')[0];
|
|
27
|
+
export const legalLanguage = (locale: string) => (LEGAL_LANGUAGES[base(locale)] ? base(locale) : 'en');
|
|
28
|
+
|
|
29
|
+
/** the page's language: the request's, else the site language setting, else English */
|
|
30
|
+
export function policyLocale(requestLocale: string, settings: Pick<Settings, 'locale'>): string {
|
|
31
|
+
return requestLocale || settings.locale || 'en';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function countryName(code: string, lang: string) {
|
|
35
|
+
if (!code) return '';
|
|
36
|
+
try { return new Intl.DisplayNames([lang], { type: 'region' }).of(code) ?? code; } catch { return code; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** an owner-written section in a language: its translation where filled in, else the main text */
|
|
40
|
+
export function ownSection(s: LegalSection, locale: string): PolicySection {
|
|
41
|
+
const isDefault = !locale || locale === (I18N.defaultLocale || locale);
|
|
42
|
+
const t = isDefault ? undefined : s.translations[locale] ?? s.translations[base(locale)];
|
|
43
|
+
const body = t?.body || s.body;
|
|
44
|
+
return { id: `own-${s.id}`, title: t?.title || s.title, text: body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The privacy notice or terms for a language: the standard sections for this site's features, then the owner's own. */
|
|
48
|
+
export function buildPolicy(kind: Policy, facts: LegalFacts, settings: Settings, locale: string): BuiltPolicy {
|
|
49
|
+
const lang = legalLanguage(locale);
|
|
50
|
+
const t = LEGAL_LANGUAGES[lang];
|
|
51
|
+
const country = facts.operator.country;
|
|
52
|
+
const sections = kind === 'privacy' ? t.privacy(facts, country) : t.terms(facts, country, countryName(country, lang));
|
|
53
|
+
const own = settings.legal.sections.filter((s) => s.policy === kind).map((s) => ownSection(s, locale));
|
|
54
|
+
const changes = kind === 'privacy' ? [{ id: 'changes', title: t.changesTitle, text: [t.changes(facts.push.enabled || facts.guestSignIn)] }] : [];
|
|
55
|
+
return { title: kind === 'privacy' ? t.privacyTitle : t.termsTitle, sections: [...sections, ...own, ...changes], lang, complete: facts.complete };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A fingerprint of a policy's text in every built-in language, plus the owner's sections with their translations. */
|
|
59
|
+
export function policyHash(kind: Policy, facts: LegalFacts, settings: Settings): string {
|
|
60
|
+
const texts = Object.keys(LEGAL_LANGUAGES).map((lang) => buildPolicy(kind, facts, { ...settings, legal: { ...settings.legal, sections: [] } }, lang).sections);
|
|
61
|
+
const own = settings.legal.sections.filter((s) => s.policy === kind);
|
|
62
|
+
return createHash('sha256').update(JSON.stringify({ texts, own })).digest('hex').slice(0, 32);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** today in the site's timezone, as YYYY-MM-DD */
|
|
66
|
+
export function siteDay(now: number, timezone: string): string {
|
|
67
|
+
try { return new Intl.DateTimeFormat('en-CA', { timeZone: timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now); }
|
|
68
|
+
catch { return new Date(now).toISOString().slice(0, 10); }
|
|
69
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Settings → Legal: the operator's details and their own sections of the
|
|
4
|
+
// privacy notice and terms. Empty fields fall back to acms({ legal }) config.
|
|
5
|
+
const locale = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/);
|
|
6
|
+
export const LegalSection = z.object({
|
|
7
|
+
id: z.string().regex(/^[\w-]{1,40}$/),
|
|
8
|
+
policy: z.enum(['privacy', 'terms']),
|
|
9
|
+
title: z.string().trim().min(1, 'Give each section a title.').max(120),
|
|
10
|
+
body: z.string().trim().max(6000).default(''),
|
|
11
|
+
/** the same section in another site language; empty fields show the main text */
|
|
12
|
+
translations: z.record(locale, z.object({ title: z.string().trim().max(120).default(''), body: z.string().trim().max(6000).default('') })).default({}),
|
|
13
|
+
}).strict();
|
|
14
|
+
export type LegalSection = z.infer<typeof LegalSection>;
|
|
15
|
+
export const LegalSettings = z.object({
|
|
16
|
+
operatorName: z.string().trim().max(200).default(''),
|
|
17
|
+
address: z.string().trim().max(500).default(''),
|
|
18
|
+
/** ISO 3166-1 alpha-2 ("MX", "US"): the law the notice refers to */
|
|
19
|
+
country: z.string().trim().toUpperCase().regex(/^([A-Z]{2})?$/, 'Use a two-letter country code such as MX or US.').default(''),
|
|
20
|
+
contactEmail: z.string().trim().max(200).refine((v) => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), 'Use an email address.').default(''),
|
|
21
|
+
/** the person or department that answers privacy requests */
|
|
22
|
+
privacyContact: z.string().trim().max(200).default(''),
|
|
23
|
+
sections: z.array(LegalSection).max(20).default([]),
|
|
24
|
+
}).strict();
|
|
25
|
+
export type LegalSettings = z.infer<typeof LegalSettings>;
|
|
26
|
+
/** a fingerprint of each policy's text and the day it last changed (automatic "last updated") */
|
|
27
|
+
export const LegalStamps = z.record(z.enum(['privacy', 'terms']), z.object({ hash: z.string(), date: z.string() })).default({});
|
|
28
|
+
export type LegalStamps = z.infer<typeof LegalStamps>;
|
package/src/lib/live/service.ts
CHANGED
|
@@ -141,15 +141,32 @@ export async function beginOAuth(repo:ContentRepository,session:string,url:URL,l
|
|
|
141
141
|
const params={client_id:conn.clientId,redirect_uri:callbackUrl,response_type:'code',scope:'https://www.googleapis.com/auth/youtube.readonly',access_type:'offline',prompt:'consent select_account',state,code_challenge:Buffer.from(hash(verifier),'hex').toString('base64url'),code_challenge_method:'S256'};
|
|
142
142
|
Object.entries(params).forEach(([k,v])=>auth.searchParams.set(k,v));return auth.href;
|
|
143
143
|
}
|
|
144
|
-
|
|
144
|
+
type PendingOAuth={verifier:string;callbackUrl:string;code?:string};
|
|
145
|
+
// Google sends the browser back with a cross-site GET, which often carries no usable sign-in: a
|
|
146
|
+
// Clerk development instance withholds its SameSite=Strict cookie, and a session token can expire
|
|
147
|
+
// while the owner is on Google's screens. So the callback only parks the code on its pending state
|
|
148
|
+
// (found by the state value, which only Google's redirect carries); the signed-in admin page then
|
|
149
|
+
// finishes it with a same-origin POST bound to the session that began it.
|
|
150
|
+
export async function receiveOAuthCallback(repo:ContentRepository,url:URL,locals?:unknown) {
|
|
145
151
|
const state=url.searchParams.get('state')??'';
|
|
146
152
|
if(!/^[\w-]{43}$/.test(state))throw new ApiError(400,'Invalid OAuth state. Start authorization again.');
|
|
147
|
-
const stored=await repo.live.consumeState(hash(state),hash(session),Date.now());
|
|
148
|
-
const conn=await repo.live.get('youtube');
|
|
149
|
-
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
150
153
|
if(url.searchParams.has('error'))throw new ApiError(400,'YouTube authorization was not completed. Try again and approve read-only access.');
|
|
151
154
|
const code=url.searchParams.get('code');if(!code||code.length>8192)throw new ApiError(400,'Missing authorization code.');
|
|
152
|
-
const
|
|
155
|
+
const now=Date.now(),stored=await repo.live.pendingState(hash(state),now);
|
|
156
|
+
if(!stored)throw new ApiError(400,'Authorization expired. Start again.');
|
|
157
|
+
const pending=decrypt<PendingOAuth>(stored.encrypted,'oauth',locals);
|
|
158
|
+
if(pending.code)throw new ApiError(409,'This authorization already came back. Start again.');
|
|
159
|
+
if(new URL(pending.callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
160
|
+
// the admin page finishes within seconds; a parked code doesn't wait around
|
|
161
|
+
if(!await repo.live.attachCode(hash(state),stored.encrypted,encrypt({...pending,code},'oauth',locals),Math.min(stored.expiresAt,now+120_000),now))throw new ApiError(409,'This authorization already came back. Start again.');
|
|
162
|
+
}
|
|
163
|
+
/** Exchanges the parked code for tokens. `url` is the signed-in admin request (same origin as the callback). */
|
|
164
|
+
export async function finishOAuth(repo:ContentRepository,session:string,url:URL,locals?:unknown) {
|
|
165
|
+
const rows=session?await repo.live.consumeSessionStates(hash(session),Date.now()):[];
|
|
166
|
+
const stored=rows.map(r=>({...r,...decrypt<PendingOAuth>(r.encrypted,'oauth',locals)})).find(r=>r.code);
|
|
167
|
+
const conn=await repo.live.get('youtube');
|
|
168
|
+
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
169
|
+
const {verifier,callbackUrl,code}=stored as Required<typeof stored>;
|
|
153
170
|
if(new URL(callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
154
171
|
const old=decrypt<Credentials>(conn.encrypted,'youtube',locals);
|
|
155
172
|
try{
|