@acms-subir/core 0.2.0 → 0.3.0
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 +78 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +324 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { env } from './env';
|
|
3
|
+
|
|
4
|
+
import type { AiConfig } from './ai-schema';
|
|
5
|
+
|
|
6
|
+
// Only owners configure this destination. Private addresses are intentional
|
|
7
|
+
// for Node deployments on a tailnet. Never accept per-generation URLs.
|
|
8
|
+
export function normalizeAiUrl(provider: AiConfig['provider'], raw: string): string {
|
|
9
|
+
if (provider === 'openai') return 'https://api.openai.com/v1';
|
|
10
|
+
let url: URL;
|
|
11
|
+
try { url = new URL(raw.trim()); } catch { throw new Error('Enter a full API URL starting with https:// or http://.'); }
|
|
12
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
13
|
+
throw new Error('API URL must use HTTP(S), without credentials, query parameters, or a fragment.');
|
|
14
|
+
}
|
|
15
|
+
let path = url.pathname.replace(/\/+$/, '');
|
|
16
|
+
if (/\/(management\.html|v0\/management)(\/|$)/i.test(path)) throw new Error('Use the proxy API base URL, not the CPAMC management URL.');
|
|
17
|
+
if (/\/(chat\/completions|responses|models)$/.test(path)) throw new Error('Enter the API base URL, not an individual endpoint.');
|
|
18
|
+
// Custom providers may serve at the root, /v1, /api/v4, or another prefix.
|
|
19
|
+
// Use the supplied base exactly; never guess a provider's API version.
|
|
20
|
+
url.pathname = path;
|
|
21
|
+
return url.href.replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
function encryptionKey(): Buffer {
|
|
24
|
+
const secret = env('SESSION_SECRET');
|
|
25
|
+
if (!secret) throw new Error('SESSION_SECRET is required to store AI credentials.');
|
|
26
|
+
return createHash('sha256').update(`acms:ai:v1:${secret}`).digest();
|
|
27
|
+
}
|
|
28
|
+
export function encryptApiKey(value: string): string {
|
|
29
|
+
if (!value) return '';
|
|
30
|
+
const iv = randomBytes(12);
|
|
31
|
+
const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv);
|
|
32
|
+
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
|
33
|
+
return [iv, cipher.getAuthTag(), ciphertext].map((v) => v.toString('base64url')).join('.');
|
|
34
|
+
}
|
|
35
|
+
export function decryptApiKey(value: string): string {
|
|
36
|
+
if (!value) return '';
|
|
37
|
+
try {
|
|
38
|
+
const parts = value.split('.');
|
|
39
|
+
if (parts.length !== 3) throw new Error();
|
|
40
|
+
const [iv, tag, ciphertext] = parts.map((v) => Buffer.from(v, 'base64url'));
|
|
41
|
+
const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), iv);
|
|
42
|
+
decipher.setAuthTag(tag);
|
|
43
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
44
|
+
} catch { throw new Error('The saved AI key cannot be decrypted. Re-enter it in Settings.'); }
|
|
45
|
+
}
|
|
46
|
+
export const hashAgentKey = (key: string) => createHash('sha256').update(key).digest('hex');
|
|
47
|
+
export function validAgentKey(key: string, hash: string): boolean {
|
|
48
|
+
if (!key || !/^[a-f0-9]{64}$/.test(hash)) return false;
|
|
49
|
+
return timingSafeEqual(Buffer.from(hashAgentKey(key), 'hex'), Buffer.from(hash, 'hex'));
|
|
50
|
+
}
|
|
51
|
+
export function generateAgentKey() {
|
|
52
|
+
const token = `acms_agent_${randomBytes(32).toString('hex')}`;
|
|
53
|
+
return { token, keyHash: hashAgentKey(token), keyPrefix: token.slice(0, 18) };
|
|
54
|
+
}
|
|
55
|
+
export function publicAiConfig(ai: AiConfig) {
|
|
56
|
+
const { encryptedKey, ...config } = ai;
|
|
57
|
+
return { ...config, hasApiKey: !!encryptedKey };
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Read legacy provider names as Custom without changing saved URLs or keys.
|
|
4
|
+
export const AiProvider = z.enum(['openai', 'custom', 'cliproxy', 'tailscale'])
|
|
5
|
+
.transform((provider): 'openai' | 'custom' => provider === 'openai' ? 'openai' : 'custom');
|
|
6
|
+
|
|
7
|
+
export const AiConfig = z.object({
|
|
8
|
+
enabled: z.boolean().default(false),
|
|
9
|
+
provider: AiProvider.default('openai'),
|
|
10
|
+
baseUrl: z.string().default('https://api.openai.com/v1'),
|
|
11
|
+
model: z.string().default(''),
|
|
12
|
+
encryptedKey: z.string().default(''),
|
|
13
|
+
}).default({});
|
|
14
|
+
export type AiConfig = z.infer<typeof AiConfig>;
|
|
15
|
+
export const AgentConfig = z.object({
|
|
16
|
+
enabled: z.boolean().default(false),
|
|
17
|
+
keyHash: z.string().default(''),
|
|
18
|
+
keyPrefix: z.string().default(''),
|
|
19
|
+
allowPublish: z.boolean().default(false),
|
|
20
|
+
}).default({});
|
|
21
|
+
export type AgentConfig = z.infer<typeof AgentConfig>;
|
|
22
|
+
|
package/src/lib/ai.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { AiConfig } from './ai-schema';
|
|
3
|
+
import { decryptApiKey, normalizeAiUrl } from './ai-config';
|
|
4
|
+
import { ApiError, readResponseJson } from './api';
|
|
5
|
+
import { CleanupInput, cleanupInstructions } from './documents/cleanup';
|
|
6
|
+
import { francAll } from 'franc-min';
|
|
7
|
+
|
|
8
|
+
async function providerRequest(config: AiConfig, path: string, payload?: unknown): Promise<unknown> {
|
|
9
|
+
const baseUrl = normalizeAiUrl(config.provider, config.baseUrl);
|
|
10
|
+
let key: string;
|
|
11
|
+
try { key = decryptApiKey(config.encryptedKey); }
|
|
12
|
+
catch { throw new ApiError(409, 'The saved AI key cannot be decrypted. Re-enter it in Settings.'); }
|
|
13
|
+
if (config.provider === 'openai' && !key) throw new ApiError(400, 'Add your OpenAI API key in Settings.');
|
|
14
|
+
let response: Response;
|
|
15
|
+
try {
|
|
16
|
+
response = await fetch(`${baseUrl}/${path}`, {
|
|
17
|
+
method: payload ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(payload ? 90_000 : 15_000),
|
|
18
|
+
headers: { Accept: 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}), ...(payload ? { 'Content-Type': 'application/json' } : {}) },
|
|
19
|
+
...(payload ? { body: JSON.stringify(payload) } : {}),
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
throw new ApiError(502, new URL(baseUrl).hostname.endsWith('.ts.net')
|
|
23
|
+
? 'Cannot reach the Tailscale proxy. The Node server must be on your tailnet; Cloudflare needs a publicly reachable endpoint. Check the URL, Tailscale access, and proxy service.'
|
|
24
|
+
: 'Cannot reach the AI provider. Check the API URL and network connection.');
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
await response.body?.cancel();
|
|
28
|
+
const detail = response.status === 401 || response.status === 403 ? 'Check the client API key and provider access.'
|
|
29
|
+
: response.status === 429 ? 'Provider quota or rate limit reached. Try again later.'
|
|
30
|
+
: response.status === 404 ? 'Check the API base URL and selected model.' : 'Check the provider service and model.';
|
|
31
|
+
throw new ApiError(502, `AI provider returned HTTP ${response.status}. ${detail}`);
|
|
32
|
+
}
|
|
33
|
+
try { return await readResponseJson(response, 2_000_000); }
|
|
34
|
+
catch { throw new ApiError(502, 'Provider returned an invalid or oversized JSON response. Check that the URL serves the AI API.'); }
|
|
35
|
+
}
|
|
36
|
+
export async function listAiModels(config: AiConfig) {
|
|
37
|
+
const result = z.object({ data: z.array(z.object({ id: z.string() })) }).safeParse(await providerRequest(config, 'models'));
|
|
38
|
+
if (!result.success) throw new ApiError(502, 'Provider did not return an OpenAI-compatible model list.');
|
|
39
|
+
return [...new Set(result.data.data.map((m) => m.id))].sort();
|
|
40
|
+
}
|
|
41
|
+
async function requestCleanupText(config: AiConfig, instruction: string, input: string) {
|
|
42
|
+
if (!config.enabled) throw new ApiError(409, 'AI is disabled. Enable it in Settings → AI & agents.');
|
|
43
|
+
if (!config.model) throw new ApiError(400, 'Choose an AI model in Settings.');
|
|
44
|
+
if (config.provider === 'openai') {
|
|
45
|
+
const raw = await providerRequest(config, 'responses', { model: config.model, instructions: instruction, input, store: false, max_output_tokens: 16384 });
|
|
46
|
+
const result = z.object({ status: z.string().optional(), output: z.array(z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })).optional() })).optional() }).safeParse(raw);
|
|
47
|
+
if (!result.success || result.data.status === 'incomplete' || result.data.status === 'failed') throw new ApiError(502, 'The provider could not complete the cleanup. Try a smaller document or a model with a larger output limit.');
|
|
48
|
+
const text = result.data.output?.flatMap((o) => o.content ?? []).filter((c) => c.type === 'output_text').map((c) => c.text ?? '').join('\n').trim();
|
|
49
|
+
if (!text) throw new ApiError(502, 'The provider returned no cleaned text. Try another model.');
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
const raw = await providerRequest(config, 'chat/completions', { model: config.model, messages: [{ role: 'system', content: instruction }, { role: 'user', content: input }], stream: false, max_tokens: 16384 });
|
|
53
|
+
const result = z.object({ choices: z.array(z.object({ message: z.object({ content: z.string().nullable() }), finish_reason: z.string().nullish() })) }).safeParse(raw);
|
|
54
|
+
const choice = result.success ? result.data.choices[0] : null;
|
|
55
|
+
if (!choice?.message.content?.trim() || choice.finish_reason === 'length') throw new ApiError(502, 'The provider returned empty or incomplete text. Try another model or a smaller document.');
|
|
56
|
+
return choice.message.content.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
// This is a conservative guard, not a language guarantee: short/ambiguous or
|
|
61
|
+
// mixed-language documents still need review. Reject clear full-document
|
|
62
|
+
// language switches rather than silently accepting a translated result.
|
|
63
|
+
function languageChanged(source: string, cleaned: string): boolean {
|
|
64
|
+
if (source.length < 160 || cleaned.length < 160) return false;
|
|
65
|
+
const before = francAll(source, { minLength: 160 });
|
|
66
|
+
const after = francAll(cleaned, { minLength: 160 });
|
|
67
|
+
if (before[0]?.[0] === 'und' || after[0]?.[0] === 'und' || before[0]?.[0] === after[0]?.[0]) return false;
|
|
68
|
+
const beforeInAfter = after.find(([lang]) => lang === before[0]?.[0])?.[1] ?? 0;
|
|
69
|
+
const afterInBefore = before.find(([lang]) => lang === after[0]?.[0])?.[1] ?? 0;
|
|
70
|
+
return beforeInAfter < 0.7 && afterInBefore < 0.7;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function cleanDocument(config: AiConfig, raw: unknown) {
|
|
74
|
+
const input = CleanupInput.parse(raw);
|
|
75
|
+
const response = await requestCleanupText(config, cleanupInstructions(input), JSON.stringify({
|
|
76
|
+
cleanupNotes: input.notes, sourceDocument: input.source,
|
|
77
|
+
}));
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try { parsed = JSON.parse(response.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim()); }
|
|
80
|
+
catch { throw new ApiError(502, 'The provider returned an unreadable cleanup result. Your original document is unchanged. Try again.'); }
|
|
81
|
+
const result = z.object({ text: z.string().trim().min(1).max(100_000), warnings: z.array(z.string().max(1000)).max(30).default([]) }).safeParse(parsed);
|
|
82
|
+
if (!result.success) throw new ApiError(502, 'The provider returned an invalid cleanup result. Your original document is unchanged.');
|
|
83
|
+
if (!input.translate && languageChanged(input.source, result.data.text)) {
|
|
84
|
+
throw new ApiError(502, 'The cleanup appears to change the document language without permission. The result was rejected; retry or use another model.');
|
|
85
|
+
}
|
|
86
|
+
if (!input.translate && result.data.text.replace(/\s+/g, ' ').length < input.source.replace(/\s+/g, ' ').length * 0.55) {
|
|
87
|
+
throw new ApiError(502, 'The cleanup appears to omit too much source content. The result was rejected. Try a smaller document or another model.');
|
|
88
|
+
}
|
|
89
|
+
return { ...result.data, model: config.model };
|
|
90
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Scoped API keys for the integration APIs (/api/calendar/*, and bookings
|
|
2
|
+
// later). A key is shown once at creation; only its SHA-256 hash is stored.
|
|
3
|
+
// The agent key from Settings → AI & agents also authenticates here and
|
|
4
|
+
// counts as every scope, so existing agent setups keep working.
|
|
5
|
+
import type { ContentRepository } from './repo/types';
|
|
6
|
+
import type { ApiScope } from './schema';
|
|
7
|
+
import { ApiError } from './api';
|
|
8
|
+
import { validAgentKey } from './ai-config';
|
|
9
|
+
|
|
10
|
+
export const API_KEY_PREFIX = 'acms_key_';
|
|
11
|
+
|
|
12
|
+
const hex = (bytes: ArrayBuffer | Uint8Array) =>
|
|
13
|
+
[...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
14
|
+
|
|
15
|
+
export async function sha256Hex(value: string): Promise<string> {
|
|
16
|
+
return hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generateApiKey(): string {
|
|
20
|
+
return API_KEY_PREFIX + hex(crypto.getRandomValues(new Uint8Array(24)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// creates the record and returns the plaintext key — the only time it exists
|
|
24
|
+
export async function issueApiKey(repo: ContentRepository, name: string, scopes: ApiScope[]) {
|
|
25
|
+
const key = generateApiKey();
|
|
26
|
+
const record = await repo.createApiKey({
|
|
27
|
+
name: name.trim() || 'API key',
|
|
28
|
+
prefix: key.slice(0, API_KEY_PREFIX.length + 6),
|
|
29
|
+
keyHash: await sha256Hex(key),
|
|
30
|
+
scopes: [...new Set(scopes)],
|
|
31
|
+
});
|
|
32
|
+
return { key, record };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApiPrincipal {
|
|
36
|
+
via: 'key' | 'agent';
|
|
37
|
+
name: string;
|
|
38
|
+
scopes: Set<ApiScope> | 'all';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const hasScope = (p: ApiPrincipal, scope: ApiScope) => p.scopes === 'all' || p.scopes.has(scope);
|
|
42
|
+
|
|
43
|
+
// null = no (or an unknown) bearer token; callers decide whether anonymous
|
|
44
|
+
// access is fine (public calendars) or a 401
|
|
45
|
+
export async function authenticate(request: Request, repo: ContentRepository): Promise<ApiPrincipal | null> {
|
|
46
|
+
const auth = request.headers.get('authorization') ?? '';
|
|
47
|
+
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
|
48
|
+
if (!token) return null;
|
|
49
|
+
if (token.startsWith(API_KEY_PREFIX)) {
|
|
50
|
+
const key = await repo.findApiKeyByHash(await sha256Hex(token));
|
|
51
|
+
return key ? { via: 'key', name: key.name, scopes: new Set(key.scopes) } : null;
|
|
52
|
+
}
|
|
53
|
+
const { agent } = await repo.getSettings();
|
|
54
|
+
if (agent.enabled && validAgentKey(token, agent.keyHash)) return { via: 'agent', name: 'Agent key', scopes: 'all' };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function requireScope(request: Request, repo: ContentRepository, scope: ApiScope): Promise<ApiPrincipal> {
|
|
59
|
+
const principal = await authenticate(request, repo);
|
|
60
|
+
if (!principal) {
|
|
61
|
+
throw new ApiError(401, 'Send an API key: Authorization: Bearer <key>.');
|
|
62
|
+
}
|
|
63
|
+
if (!hasScope(principal, scope)) throw new ApiError(403, `This key is missing the ${scope} scope.`);
|
|
64
|
+
return principal;
|
|
65
|
+
}
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { validateSession, OWNER_ENV, SESSION_COOKIE } from './auth';
|
|
3
|
+
import type { ContentRepository } from './repo/types';
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
constructor(public status: number, message: string) { super(message); }
|
|
7
|
+
}
|
|
8
|
+
export const json = (body: unknown, status = 200) => Response.json(body, {
|
|
9
|
+
status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
|
|
10
|
+
});
|
|
11
|
+
export function apiError(error: unknown): Response {
|
|
12
|
+
if (error instanceof ApiError) return json({ error: error.message }, error.status);
|
|
13
|
+
if (error instanceof z.ZodError) return json({ error: 'Invalid request', issues: error.issues.map(({ path, message }) => ({ path, message })) }, 400);
|
|
14
|
+
return json({ error: 'Request failed. Please try again.' }, 500);
|
|
15
|
+
}
|
|
16
|
+
export async function readJson(request: Request, maxBytes = 300_000): Promise<unknown> {
|
|
17
|
+
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new ApiError(415, 'Use Content-Type: application/json.');
|
|
18
|
+
return readResponseJson(request, maxBytes);
|
|
19
|
+
}
|
|
20
|
+
export async function readResponseJson(source: Request | Response, maxBytes: number): Promise<unknown> {
|
|
21
|
+
if (Number(source.headers.get('content-length')) > maxBytes) throw new ApiError(413, 'Payload too large.');
|
|
22
|
+
const reader = source.body?.getReader();
|
|
23
|
+
if (!reader) throw new ApiError(400, 'JSON body is required.');
|
|
24
|
+
const chunks: Uint8Array[] = [];
|
|
25
|
+
let length = 0;
|
|
26
|
+
try {
|
|
27
|
+
while (true) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
length += value.byteLength;
|
|
31
|
+
if (length > maxBytes) { await reader.cancel(); throw new ApiError(413, 'Payload too large.'); }
|
|
32
|
+
chunks.push(value);
|
|
33
|
+
}
|
|
34
|
+
} finally { reader.releaseLock(); }
|
|
35
|
+
const bytes = new Uint8Array(length);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
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.'); }
|
|
40
|
+
}
|
|
41
|
+
export async function requireSession(
|
|
42
|
+
request: Request, cookies: { get(name: string): { value: string } | undefined }, repo: ContentRepository, ownerOnly = false,
|
|
43
|
+
) {
|
|
44
|
+
const id = (await validateSession(cookies.get(SESSION_COOKIE)?.value, repo));
|
|
45
|
+
if (!id) throw new ApiError(401, 'Sign in to continue.');
|
|
46
|
+
const user = id === OWNER_ENV ? null : await repo.getUser(id);
|
|
47
|
+
if (id !== OWNER_ENV && (!user || (ownerOnly && user.role !== 'owner'))) throw new ApiError(403, 'Owner access is required.');
|
|
48
|
+
const origin = request.headers.get('origin');
|
|
49
|
+
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') {
|
|
50
|
+
throw new ApiError(403, 'Cross-origin requests are not allowed.');
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -15,7 +15,8 @@ function hmac(payload: string): string {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
function safeEqual(a: string, b: string): boolean {
|
|
18
|
-
|
|
18
|
+
const left = Buffer.from(a), right = Buffer.from(b);
|
|
19
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// ---- sessions -------------------------------------------------------------
|
|
@@ -27,26 +28,33 @@ function safeEqual(a: string, b: string): boolean {
|
|
|
27
28
|
|
|
28
29
|
export const OWNER_ENV = '__owner_env__';
|
|
29
30
|
|
|
30
|
-
export function createSessionToken(userId: string = OWNER_ENV): string {
|
|
31
|
+
export function createSessionToken(userId: string = OWNER_ENV, version = 0): string {
|
|
31
32
|
const exp = Date.now() + WEEK_MS;
|
|
32
33
|
if (userId === OWNER_ENV) return `${exp}.${hmac(String(exp))}`;
|
|
33
|
-
return `
|
|
34
|
+
return `v.${userId}.${version}.${exp}.${hmac(`${userId}.${version}.${exp}`)}`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// Returns the session's user id (OWNER_ENV for env-password sessions) or
|
|
37
38
|
// null. Truthy/falsy compatible with the old boolean signature.
|
|
38
39
|
export function verifySessionToken(token: string | undefined): string | null {
|
|
39
40
|
if (!token) return null;
|
|
41
|
+
if (token.startsWith('v.')) {
|
|
42
|
+
const parts = token.split('.');
|
|
43
|
+
if (parts.length !== 5) return null;
|
|
44
|
+
const [, id, version, exp, sig] = parts;
|
|
45
|
+
if (!id || !/^\d+$/.test(version) || !Number.isFinite(Number(exp)) || Number(exp) < Date.now()) return null;
|
|
46
|
+
return safeEqual(sig, hmac(`${id}.${version}.${exp}`)) ? id : null;
|
|
47
|
+
}
|
|
40
48
|
if (token.startsWith('u.')) {
|
|
41
49
|
const parts = token.split('.');
|
|
42
50
|
if (parts.length !== 4) return null;
|
|
43
51
|
const [, userId, expStr, sig] = parts;
|
|
44
|
-
if (!userId || Number(expStr) < Date.now()) return null;
|
|
52
|
+
if (!userId || !Number.isFinite(Number(expStr)) || Number(expStr) < Date.now()) return null;
|
|
45
53
|
return safeEqual(sig, hmac(`${userId}.${expStr}`)) ? userId : null;
|
|
46
54
|
}
|
|
47
55
|
const [expStr, sig] = token.split('.');
|
|
48
56
|
if (!expStr || !sig) return null;
|
|
49
|
-
if (Number(expStr) < Date.now()) return null;
|
|
57
|
+
if (!Number.isFinite(Number(expStr)) || Number(expStr) < Date.now()) return null;
|
|
50
58
|
return safeEqual(sig, hmac(expStr)) ? OWNER_ENV : null;
|
|
51
59
|
}
|
|
52
60
|
|
|
@@ -92,3 +100,15 @@ export async function verifyPassword(password: string, stored: string): Promise<
|
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
export const SESSION_COOKIE = COOKIE;
|
|
103
|
+
|
|
104
|
+
// All authorization paths must consult the database, not just the signature.
|
|
105
|
+
export async function validateSession(token: string | undefined, localsOrRepo?: unknown): Promise<string | null> {
|
|
106
|
+
const id = verifySessionToken(token);
|
|
107
|
+
if (!id || id === OWNER_ENV) return id;
|
|
108
|
+
const { getRepo } = await import('./repo');
|
|
109
|
+
const repo = localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo
|
|
110
|
+
? localsOrRepo as import('./repo/types').ContentRepository : await getRepo(localsOrRepo);
|
|
111
|
+
const version = await repo.email.sessionVersion(id);
|
|
112
|
+
const expected = token!.startsWith('v.') ? Number(token!.split('.')[2]) : 0;
|
|
113
|
+
return version !== null && version === expected ? id : null;
|
|
114
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
|
|
3
|
+
import type { ContentRepository } from '../repo/types';
|
|
4
|
+
|
|
5
|
+
export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
|
|
6
|
+
export type BookingRequest = z.infer<typeof BookingRequest>;
|
|
7
|
+
export const BackupContent = z.object({
|
|
8
|
+
format: z.literal('acms-backup'), version: z.number().int().min(1).max(5), exportedAt: z.string().optional(),
|
|
9
|
+
settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
|
|
10
|
+
tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
|
|
11
|
+
calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
|
|
12
|
+
bookingLinks: z.array(BookingLink).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
|
|
13
|
+
}).superRefine((data, ctx) => {
|
|
14
|
+
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
15
|
+
const keys = new Set<string>();
|
|
16
|
+
for (const r of data.bookingRequests ?? []) {
|
|
17
|
+
if (keys.has(r.keyHash)) ctx.addIssue({ code: 'custom', message: 'Duplicate booking retry key.' });
|
|
18
|
+
keys.add(r.keyHash);
|
|
19
|
+
}
|
|
20
|
+
for (const m of data.media) for (const key of [m.key, ...m.variants.map(v => v.key)]) {
|
|
21
|
+
if (!key || key.startsWith('/') || key.includes('..') || key.includes('\\') || /[\x00-\x1f]/.test(key)) ctx.addIssue({ code: 'custom', message: 'Unsafe media key.' });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
25
|
+
const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState] = await Promise.all([
|
|
26
|
+
repo.getSettings(), repo.listPages(), repo.listPosts(), repo.listMedia(), repo.listLinks(), repo.listTags(), repo.listNavItems(),
|
|
27
|
+
repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
|
|
28
|
+
]);
|
|
29
|
+
return {
|
|
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 } },
|
|
32
|
+
pages, posts, media, links, tags, navItems, users, redirects,
|
|
33
|
+
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
34
|
+
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/lib/backup/unzip.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { crc32 } from './zip';
|
|
1
2
|
// Minimal ZIP reader for acms backups. Backups are written by zip.ts with
|
|
2
3
|
// the store method (no compression), so this only needs to walk the central
|
|
3
4
|
// directory and slice entries out — deflated archives from other tools are
|
|
@@ -13,11 +14,14 @@ export function unzip(buf: ArrayBuffer): Map<string, Uint8Array> {
|
|
|
13
14
|
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
|
14
15
|
}
|
|
15
16
|
if (eocd < 0) throw new Error('Not a ZIP file.');
|
|
17
|
+
if (view.getUint16(eocd + 4, true) || view.getUint16(eocd + 6, true)) throw new Error('Split ZIP archives are not supported.');
|
|
16
18
|
const count = view.getUint16(eocd + 10, true);
|
|
17
19
|
let off = view.getUint32(eocd + 16, true);
|
|
20
|
+
if (off + view.getUint32(eocd + 12, true) !== eocd) throw new Error('Corrupt ZIP directory size.');
|
|
18
21
|
|
|
19
22
|
const entries = new Map<string, Uint8Array>();
|
|
20
23
|
for (let n = 0; n < count; n++) {
|
|
24
|
+
if (off + 46 > eocd) throw new Error('Truncated ZIP directory.');
|
|
21
25
|
if (view.getUint32(off, true) !== 0x02014b50) throw new Error('Corrupt ZIP central directory.');
|
|
22
26
|
const method = view.getUint16(off + 10, true);
|
|
23
27
|
const compSize = view.getUint32(off + 20, true);
|
|
@@ -25,13 +29,20 @@ export function unzip(buf: ArrayBuffer): Map<string, Uint8Array> {
|
|
|
25
29
|
const extraLen = view.getUint16(off + 30, true);
|
|
26
30
|
const commentLen = view.getUint16(off + 32, true);
|
|
27
31
|
const headerOff = view.getUint32(off + 42, true);
|
|
32
|
+
if (off + 46 + nameLen + extraLen + commentLen > eocd || headerOff + 30 > off) throw new Error('Truncated ZIP entry.');
|
|
33
|
+
if (view.getUint16(off + 8, true) & 1) throw new Error('Encrypted ZIP entries are not supported.');
|
|
28
34
|
const name = new TextDecoder().decode(bytes.subarray(off + 46, off + 46 + nameLen));
|
|
29
35
|
if (method !== 0) throw new Error('Compressed ZIP entries are not supported — use an unmodified acms backup.');
|
|
36
|
+
if (!name || name.startsWith('/') || name.split('/').includes('..') || name.includes('\\') || entries.has(name)) throw new Error('Unsafe or duplicate ZIP entry.');
|
|
37
|
+
if (compSize !== view.getUint32(off + 24, true) || view.getUint32(headerOff, true) !== 0x04034b50) throw new Error('Corrupt ZIP entry.');
|
|
30
38
|
// local header repeats name/extra lengths; data starts after them
|
|
31
39
|
const localNameLen = view.getUint16(headerOff + 26, true);
|
|
32
40
|
const localExtraLen = view.getUint16(headerOff + 28, true);
|
|
33
41
|
const dataStart = headerOff + 30 + localNameLen + localExtraLen;
|
|
34
|
-
|
|
42
|
+
if (dataStart + compSize > view.getUint32(eocd + 16, true)) throw new Error('Truncated ZIP data.');
|
|
43
|
+
const data = bytes.subarray(dataStart, dataStart + compSize);
|
|
44
|
+
if (crc32(data) !== view.getUint32(off + 16, true)) throw new Error('ZIP checksum mismatch. Download a fresh backup.');
|
|
45
|
+
entries.set(name, data);
|
|
35
46
|
off += 46 + nameLen + extraLen + commentLen;
|
|
36
47
|
}
|
|
37
48
|
return entries;
|
package/src/lib/backup/zip.ts
CHANGED
|
@@ -19,7 +19,7 @@ const CRC_TABLE = (() => {
|
|
|
19
19
|
return t;
|
|
20
20
|
})();
|
|
21
21
|
|
|
22
|
-
function crc32(data: Uint8Array): number {
|
|
22
|
+
export function crc32(data: Uint8Array): number {
|
|
23
23
|
let c = 0xffffffff;
|
|
24
24
|
for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
|
25
25
|
return (c ^ 0xffffffff) >>> 0;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Agent / MCP tools for bookings. Reads are always available to the agent
|
|
2
|
+
// key; anything that emails a guest or changes a booking needs the owner's
|
|
3
|
+
// "agent publishing" permission, like publishing content.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { ContentRepository } from '../repo/types';
|
|
6
|
+
import type { Booking } from '../schema';
|
|
7
|
+
import { ApiError } from '../api';
|
|
8
|
+
import { bookingsEnabled } from '../calendar/config';
|
|
9
|
+
import { availabilityContext, bookingJson, BookingError, createResourceBooking, rescheduleBooking, setBookingStatus } from './service';
|
|
10
|
+
import { addDaysYmd, describeLink, slotsForDay, ymd } from './availability';
|
|
11
|
+
|
|
12
|
+
export const BookingToolInputs = [
|
|
13
|
+
z.object({ tool: z.literal('list_resources'), arguments: z.object({}).strict() }).strict(),
|
|
14
|
+
z.object({ tool: z.literal('check_availability'), arguments: z.object({
|
|
15
|
+
resource: z.string().min(1), link: z.string().min(1),
|
|
16
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), days: z.number().int().min(1).max(31).default(7),
|
|
17
|
+
minutes: z.number().int().optional(),
|
|
18
|
+
}).strict() }).strict(),
|
|
19
|
+
z.object({ tool: z.literal('list_bookings'), arguments: z.object({
|
|
20
|
+
status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']).optional(), resource: z.string().optional(),
|
|
21
|
+
from: z.string().optional(), limit: z.number().int().min(1).max(100).default(25),
|
|
22
|
+
}).strict() }).strict(),
|
|
23
|
+
z.object({ tool: z.literal('create_booking'), arguments: z.object({
|
|
24
|
+
resource: z.string().min(1), link: z.string().min(1), start: z.string().min(1), minutes: z.number().int().optional(),
|
|
25
|
+
name: z.string().min(1).max(120), email: z.string().email(), phone: z.string().max(40).optional(),
|
|
26
|
+
answers: z.record(z.string()).optional(),
|
|
27
|
+
}).strict() }).strict(),
|
|
28
|
+
z.object({ tool: z.literal('update_booking'), arguments: z.object({
|
|
29
|
+
id: z.string().min(1), status: z.enum(['confirmed', 'declined', 'cancelled']).optional(),
|
|
30
|
+
start: z.string().optional(), notes: z.string().max(4000).optional(), message: z.string().max(1000).optional(),
|
|
31
|
+
}).strict() }).strict(),
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const BOOKING_TOOL_NAMES = ['list_resources', 'check_availability', 'list_bookings', 'create_booking', 'update_booking'] as const;
|
|
35
|
+
export const BOOKING_WRITE_TOOLS = ['create_booking', 'update_booking'];
|
|
36
|
+
export const BOOKING_TOOL_DESCRIPTIONS: Record<(typeof BOOKING_TOOL_NAMES)[number], string> = {
|
|
37
|
+
list_resources: 'List bookable resources (rooms, people, equipment) and their booking links with rules (length, auto-confirm, visibility).',
|
|
38
|
+
check_availability: 'Open start times for a resource + link (slugs) over the next days. Returns ISO start/end per slot.',
|
|
39
|
+
list_bookings: 'List bookings (guest name, email, answers, status). Filter by status (pending = waiting for approval) or resource slug.',
|
|
40
|
+
create_booking: 'Book a slot for a guest (checks availability; confirms or holds for approval per the link; emails the guest). Needs publishing permission.',
|
|
41
|
+
update_booking: 'Confirm, decline or cancel a booking, move it (start), or set private notes. Emails the guest. Needs publishing permission.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Input = z.infer<(typeof BookingToolInputs)[number]>;
|
|
45
|
+
const ms = (s: string) => {
|
|
46
|
+
const n = Date.parse(s);
|
|
47
|
+
if (!Number.isFinite(n)) throw new ApiError(400, `"${s}" isn't an ISO date-time.`);
|
|
48
|
+
return n;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export async function runBookingTool(repo: ContentRepository, input: Input, allowWrite: boolean, ctx: { origin: string; waitUntil?: (p: Promise<unknown>) => void }) {
|
|
52
|
+
if (!bookingsEnabled()) throw new ApiError(404, 'Bookings are disabled on this site.');
|
|
53
|
+
if (BOOKING_WRITE_TOOLS.includes(input.tool) && !allowWrite) throw new ApiError(403, 'Agent publishing is disabled. An owner can enable it in Settings.');
|
|
54
|
+
const settings = await repo.getSettings();
|
|
55
|
+
const resources = await repo.listResources();
|
|
56
|
+
const links = await repo.listBookingLinks();
|
|
57
|
+
const resolve = (resource: string, link: string) => {
|
|
58
|
+
const r = resources.find((x) => x.slug === resource || x.id === resource);
|
|
59
|
+
const l = r ? links.find((x) => x.resourceId === r.id && (x.slug === link || x.id === link)) : undefined;
|
|
60
|
+
if (!r || !l) throw new ApiError(404, 'Unknown resource or link. Call list_resources.');
|
|
61
|
+
return { r, l };
|
|
62
|
+
};
|
|
63
|
+
const flow = { origin: ctx.origin, waitUntil: ctx.waitUntil };
|
|
64
|
+
const out = (b: Booking) => bookingJson(b, { resourceSlug: resources.find((r) => r.id === b.resourceId)?.slug, linkSlug: links.find((l) => l.id === b.linkId)?.slug });
|
|
65
|
+
try {
|
|
66
|
+
switch (input.tool) {
|
|
67
|
+
case 'list_resources':
|
|
68
|
+
return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: l.autoConfirm, active: l.active, visibility: l.visibility })) })) };
|
|
69
|
+
case 'check_availability': {
|
|
70
|
+
const { r, l } = resolve(input.arguments.resource, input.arguments.link);
|
|
71
|
+
const c = await availabilityContext(repo, r, l, settings);
|
|
72
|
+
const tz = r.timezone || settings.timezone || 'UTC';
|
|
73
|
+
const from = input.arguments.from ?? ymd(Date.now(), tz);
|
|
74
|
+
const days = Array.from({ length: input.arguments.days }, (_, i) => addDaysYmd(from, i)).map((date) => ({
|
|
75
|
+
date, slots: slotsForDay(c, date, input.arguments.minutes).slice(0, 60).map((s) => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
|
|
76
|
+
}));
|
|
77
|
+
return { timezone: tz, autoConfirm: l.autoConfirm, days };
|
|
78
|
+
}
|
|
79
|
+
case 'list_bookings': {
|
|
80
|
+
const a = input.arguments;
|
|
81
|
+
const r = a.resource ? resources.find((x) => x.slug === a.resource) : undefined;
|
|
82
|
+
if (a.resource && !r) throw new ApiError(404, 'Unknown resource.');
|
|
83
|
+
const list = await repo.listBookings({ resourceId: r?.id, from: a.from ? ms(a.from) : Date.now() - 86_400_000, statuses: a.status ? [a.status] : undefined });
|
|
84
|
+
return { total: list.length, items: list.slice(0, a.limit).map(out) };
|
|
85
|
+
}
|
|
86
|
+
case 'create_booking': {
|
|
87
|
+
const a = input.arguments;
|
|
88
|
+
const { r, l } = resolve(a.resource, a.link);
|
|
89
|
+
const start = ms(a.start);
|
|
90
|
+
const minutes = a.minutes ?? (l.mode === 'day' ? 1440 : l.mode === 'range' ? l.lengths[0] ?? l.duration : l.duration);
|
|
91
|
+
const b = await createResourceBooking(repo, { resource: r, link: l, start, end: start + minutes * 60_000, source: 'agent', booker: { name: a.name, email: a.email, phone: a.phone, answers: a.answers } }, flow);
|
|
92
|
+
return out(b);
|
|
93
|
+
}
|
|
94
|
+
case 'update_booking': {
|
|
95
|
+
const a = input.arguments;
|
|
96
|
+
let b = await repo.getBooking(a.id);
|
|
97
|
+
if (!b) throw new ApiError(404, 'Booking not found.');
|
|
98
|
+
if (a.notes !== undefined) await repo.updateBooking(b.id, { notes: a.notes });
|
|
99
|
+
if (a.start) b = await rescheduleBooking(repo, b, ms(a.start), ms(a.start) + (b.end - b.start), { ...flow, skipRules: false });
|
|
100
|
+
if (a.status) b = await setBookingStatus(repo, b, a.status, flow, a.message);
|
|
101
|
+
return out((await repo.getBooking(b.id))!);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err instanceof BookingError) throw new ApiError(err.status, err.message);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|