@acms-subir/client 0.1.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/dist/editor.js ADDED
@@ -0,0 +1,354 @@
1
+ import { AcmsError } from './errors.js';
2
+ export const webCrypto = {
3
+ randomBytes(length) {
4
+ const c = globalThis.crypto;
5
+ if (!c?.getRandomValues)
6
+ throw new TypeError('No secure random source: pass a PkceCrypto (expo-crypto) implementation.');
7
+ return c.getRandomValues(new Uint8Array(length));
8
+ },
9
+ async sha256(data) {
10
+ const subtle = globalThis.crypto?.subtle;
11
+ if (!subtle)
12
+ throw new TypeError('No Web Crypto SHA-256: pass a PkceCrypto (expo-crypto) implementation.');
13
+ return subtle.digest('SHA-256', data);
14
+ },
15
+ };
16
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
17
+ /** base64url without padding (no btoa: works in every JS runtime) */
18
+ export function base64url(bytes) {
19
+ let out = '';
20
+ for (let i = 0; i < bytes.length; i += 3) {
21
+ const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
22
+ out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < bytes.length ? B64[(n >> 6) & 63] : '') + (i + 2 < bytes.length ? B64[n & 63] : '');
23
+ }
24
+ return out;
25
+ }
26
+ async function random(crypto, length) {
27
+ const bytes = await crypto.randomBytes(length);
28
+ if (!(bytes instanceof Uint8Array) || bytes.length !== length)
29
+ throw new TypeError('randomBytes must return a Uint8Array of the requested length.');
30
+ return bytes;
31
+ }
32
+ /** a 43-character verifier (32 random bytes), RFC 7636 */
33
+ export async function createCodeVerifier(crypto = webCrypto) { return base64url(await random(crypto, 32)); }
34
+ export async function codeChallengeFor(verifier, crypto = webCrypto) {
35
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
36
+ throw new TypeError('A PKCE verifier is 43–128 unreserved characters.');
37
+ const ascii = new Uint8Array(verifier.length);
38
+ for (let i = 0; i < verifier.length; i++)
39
+ ascii[i] = verifier.charCodeAt(i);
40
+ return base64url(new Uint8Array(await crypto.sha256(ascii)));
41
+ }
42
+ export async function createPkce(crypto = webCrypto) {
43
+ const verifier = await createCodeVerifier(crypto);
44
+ return { verifier, challenge: await codeChallengeFor(verifier, crypto), method: 'S256' };
45
+ }
46
+ export async function createState(crypto = webCrypto) { return base64url(await random(crypto, 16)); }
47
+ export const toTokens = (t, now = Date.now()) => ({ accessToken: t.access_token, refreshToken: t.refresh_token, expiresAt: now + t.expires_in * 1000, user: t.user, device: t.device });
48
+ const origin = (baseUrl) => {
49
+ const u = new URL(baseUrl);
50
+ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.search || u.hash)
51
+ throw new TypeError('baseUrl must be an HTTP(S) URL without credentials, query or fragment.');
52
+ return { site: u.origin + u.pathname.replace(/\/+$/, '').replace(/\/api\/v1$/, ''), api: (u.origin + u.pathname.replace(/\/+$/, '').replace(/\/api\/v1$/, '')) + '/api/v1' };
53
+ };
54
+ function withTimeout(ms, signal) {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(new AcmsError(0, 'timeout', 'Request timed out.')), ms);
57
+ const abort = () => controller.abort(new AcmsError(0, 'aborted', 'Request aborted.'));
58
+ if (signal?.aborted)
59
+ abort();
60
+ else
61
+ signal?.addEventListener('abort', abort, { once: true });
62
+ return { signal: controller.signal, done: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); } };
63
+ }
64
+ export function createEditorAuth(options) {
65
+ const { site, api } = origin(options.baseUrl);
66
+ const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
67
+ if (!fetcher)
68
+ throw new TypeError('A fetch implementation is required.');
69
+ const crypto = options.crypto ?? webCrypto;
70
+ const timeoutMs = options.timeoutMs ?? 30_000;
71
+ async function post(path, body) {
72
+ const t = withTimeout(timeoutMs);
73
+ try {
74
+ let res;
75
+ try {
76
+ res = await fetcher(api + path, { method: 'POST', credentials: 'omit', signal: t.signal,
77
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: new URLSearchParams(body).toString() });
78
+ }
79
+ catch (error) {
80
+ if (t.signal.aborted)
81
+ throw t.signal.reason;
82
+ throw new AcmsError(0, 'network_error', error instanceof Error ? error.message : 'Network request failed.');
83
+ }
84
+ let json = null;
85
+ try {
86
+ json = await res.json();
87
+ }
88
+ catch { /* handled below */ }
89
+ if (!res.ok) {
90
+ const retry = Number(res.headers.get('Retry-After')) || undefined;
91
+ throw new AcmsError(res.status, String(json?.error ?? 'http_error'), String(json?.error_description ?? `HTTP ${res.status}`), retry);
92
+ }
93
+ if (!json)
94
+ throw new AcmsError(res.status, 'invalid_response', 'Expected a JSON response.');
95
+ return json;
96
+ }
97
+ finally {
98
+ t.done();
99
+ }
100
+ }
101
+ const auth = {
102
+ buildAuthorizeUrl({ codeChallenge, state, deviceName }) {
103
+ const url = new URL(site + '/admin/app-auth');
104
+ url.searchParams.set('response_type', 'code');
105
+ url.searchParams.set('client_id', options.clientId);
106
+ url.searchParams.set('redirect_uri', options.redirectUri);
107
+ url.searchParams.set('code_challenge', codeChallenge);
108
+ url.searchParams.set('code_challenge_method', 'S256');
109
+ url.searchParams.set('state', state);
110
+ if (deviceName)
111
+ url.searchParams.set('device_name', deviceName.slice(0, 60));
112
+ return url.href;
113
+ },
114
+ async start(o = {}) {
115
+ const pkce = await createPkce(crypto);
116
+ const state = await createState(crypto);
117
+ return { url: auth.buildAuthorizeUrl({ codeChallenge: pkce.challenge, state, deviceName: o.deviceName }), verifier: pkce.verifier, state };
118
+ },
119
+ parseRedirect(url, expectedState) {
120
+ let params;
121
+ try {
122
+ params = new URL(url).searchParams;
123
+ }
124
+ catch {
125
+ throw new AcmsError(0, 'invalid_response', 'The sign-in redirect is not a URL.');
126
+ }
127
+ const state = params.get('state') ?? '';
128
+ // compare without early exit
129
+ let diff = state.length ^ expectedState.length;
130
+ for (let i = 0; i < Math.max(state.length, expectedState.length); i++)
131
+ diff |= (state.charCodeAt(i) || 0) ^ (expectedState.charCodeAt(i) || 0);
132
+ if (!expectedState || diff !== 0)
133
+ throw new AcmsError(0, 'state_mismatch', 'The sign-in response did not match this request. Try again.');
134
+ const error = params.get('error');
135
+ if (error)
136
+ throw new AcmsError(error === 'access_denied' ? 0 : 400, error, params.get('error_description') ?? 'Sign-in failed.');
137
+ const code = params.get('code');
138
+ if (!code)
139
+ throw new AcmsError(0, 'invalid_response', 'The sign-in response has no code.');
140
+ return { code };
141
+ },
142
+ async exchangeCode({ code, codeVerifier }) {
143
+ return toTokens(await post('/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: options.redirectUri, client_id: options.clientId }));
144
+ },
145
+ async refresh(refreshToken) {
146
+ return toTokens(await post('/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: options.clientId }));
147
+ },
148
+ async revoke(token) { await post('/auth/revoke', { token }); },
149
+ };
150
+ return auth;
151
+ }
152
+ /** refresh failures that mean "sign in again" (as opposed to being offline) */
153
+ const signedOut = (e) => e instanceof AcmsError && (e.code === 'invalid_grant' || e.status === 400 || e.status === 401 || e.status === 404);
154
+ export function createEditorSession(o) {
155
+ const now = o.now ?? Date.now;
156
+ const skew = o.refreshSkewMs ?? 60_000;
157
+ let inflight = null;
158
+ const session = {
159
+ tokens: async () => o.storage.get(),
160
+ async getAccessToken() {
161
+ const t = await o.storage.get();
162
+ if (!t)
163
+ return null;
164
+ return t.expiresAt - skew > now() ? t.accessToken : session.refresh();
165
+ },
166
+ refresh() {
167
+ inflight ??= (async () => {
168
+ const t = await o.storage.get();
169
+ if (!t)
170
+ return null;
171
+ try {
172
+ const next = await o.auth.refresh(t.refreshToken);
173
+ await o.storage.set(next);
174
+ return next.accessToken;
175
+ }
176
+ catch (error) {
177
+ if (signedOut(error)) {
178
+ await o.storage.set(null);
179
+ o.onSignedOut?.();
180
+ return null;
181
+ }
182
+ throw error;
183
+ }
184
+ })().finally(() => { inflight = null; });
185
+ return inflight;
186
+ },
187
+ async signIn(tokens) { await o.storage.set(tokens); },
188
+ async signOut() {
189
+ const t = await o.storage.get();
190
+ await o.storage.set(null);
191
+ if (t)
192
+ await o.auth.revoke(t.refreshToken).catch(() => { });
193
+ },
194
+ };
195
+ return session;
196
+ }
197
+ // ---- the admin API -----------------------------------------------------------
198
+ export const authEndpoints = {
199
+ exchangeCode: { method: 'POST', path: '/auth/token' }, revoke: { method: 'POST', path: '/auth/revoke' },
200
+ };
201
+ export const editorEndpoints = {
202
+ me: { method: 'GET', path: '/admin/me' },
203
+ listDocuments: { method: 'GET', path: '/admin/documents' }, createDocument: { method: 'POST', path: '/admin/documents' },
204
+ getDocument: { method: 'GET', path: '/admin/documents/{kind}/{id}' }, saveDocument: { method: 'PUT', path: '/admin/documents/{kind}/{id}' },
205
+ publishDocument: { method: 'POST', path: '/admin/documents/{kind}/{id}/publish' }, unpublishDocument: { method: 'POST', path: '/admin/documents/{kind}/{id}/unpublish' },
206
+ scheduleDocument: { method: 'POST', path: '/admin/documents/{kind}/{id}/schedule' }, discardDocument: { method: 'POST', path: '/admin/documents/{kind}/{id}/discard' },
207
+ getDocumentTranslation: { method: 'GET', path: '/admin/documents/{kind}/{id}/translations/{locale}' },
208
+ setDocumentTranslation: { method: 'PUT', path: '/admin/documents/{kind}/{id}/translations/{locale}' },
209
+ reviewDocumentTranslation: { method: 'POST', path: '/admin/documents/{kind}/{id}/translations/{locale}/review' },
210
+ draftDocumentTranslation: { method: 'POST', path: '/admin/documents/{kind}/{id}/translations/{locale}/ai-draft' },
211
+ listSections: { method: 'GET', path: '/admin/sections' },
212
+ updateSection: { method: 'PATCH', path: '/admin/sections/{pageId}/{sectionId}' },
213
+ publishSection: { method: 'POST', path: '/admin/sections/{pageId}/{sectionId}/publish' },
214
+ getSectionTranslation: { method: 'GET', path: '/admin/sections/{pageId}/{sectionId}/translations/{locale}' },
215
+ setSectionTranslation: { method: 'PUT', path: '/admin/sections/{pageId}/{sectionId}/translations/{locale}' },
216
+ reviewSectionTranslation: { method: 'POST', path: '/admin/sections/{pageId}/{sectionId}/translations/{locale}/review' },
217
+ draftSectionTranslation: { method: 'POST', path: '/admin/sections/{pageId}/{sectionId}/translations/{locale}/ai-draft' },
218
+ listMedia: { method: 'GET', path: '/admin/media' }, uploadMedia: { method: 'POST', path: '/admin/media' },
219
+ listBookings: { method: 'GET', path: '/admin/bookings' },
220
+ approveBooking: { method: 'POST', path: '/admin/bookings/{id}/approve' },
221
+ declineBooking: { method: 'POST', path: '/admin/bookings/{id}/decline' },
222
+ cancelBooking: { method: 'POST', path: '/admin/bookings/{id}/cancel' },
223
+ };
224
+ export function createEditorClient(options) {
225
+ const { api } = origin(options.baseUrl);
226
+ const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
227
+ if (!fetcher)
228
+ throw new TypeError('A fetch implementation is required.');
229
+ const getToken = options.session ? () => options.session.getAccessToken() : options.getAccessToken;
230
+ const refreshToken = options.session ? () => options.session.refresh() : options.refresh;
231
+ if (!getToken)
232
+ throw new TypeError('Pass a session or getAccessToken.');
233
+ const timeoutMs = options.timeoutMs ?? 30_000;
234
+ // one refresh at a time even when several requests hit 401 together
235
+ let refreshing = null;
236
+ const refresh = () => {
237
+ if (!refreshToken)
238
+ return Promise.resolve(null);
239
+ refreshing ??= refreshToken().finally(() => { refreshing = null; });
240
+ return refreshing;
241
+ };
242
+ const authError = (message) => { const e = new AcmsError(401, 'unauthorized', message); options.onAuthError?.(e); return e; };
243
+ async function call(name, params = {}, o = {}) {
244
+ const endpoint = editorEndpoints[name];
245
+ const path = endpoint.path.replace(/\{(\w+)\}/g, (_, key) => {
246
+ const v = params[key];
247
+ if (!v || v === '.' || v === '..')
248
+ throw new TypeError(`A nonempty ${key} is required.`);
249
+ return encodeURIComponent(v);
250
+ });
251
+ const url = new URL(api + path);
252
+ for (const [k, v] of Object.entries(o.query ?? {}))
253
+ if (v !== undefined && v !== null)
254
+ url.searchParams.set(k, String(v));
255
+ let token = await getToken();
256
+ if (!token)
257
+ token = await refresh();
258
+ if (!token)
259
+ throw authError('Sign in to continue.');
260
+ for (let attempt = 0;; attempt++) {
261
+ const headers = new Headers({ Accept: 'application/json', Authorization: `Bearer ${token}` });
262
+ if (o.body !== undefined)
263
+ headers.set('Content-Type', 'application/json');
264
+ const t = withTimeout(timeoutMs, o.signal);
265
+ let res;
266
+ try {
267
+ try {
268
+ res = await fetcher(url.href, { method: endpoint.method, headers, credentials: 'omit', signal: t.signal,
269
+ body: o.form ?? (o.body !== undefined ? JSON.stringify(o.body) : undefined) });
270
+ }
271
+ catch (error) {
272
+ if (t.signal.aborted)
273
+ throw t.signal.reason;
274
+ // reads retry transient network failures; writes never replay blindly
275
+ if (endpoint.method === 'GET' && attempt < 2) {
276
+ await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
277
+ continue;
278
+ }
279
+ throw new AcmsError(0, 'network_error', error instanceof Error ? error.message : 'Network request failed.');
280
+ }
281
+ // an expired or rotated access token: refresh once and replay (the server rejected it before doing anything)
282
+ if (res.status === 401 && attempt === 0) {
283
+ await res.body?.cancel().catch(() => { });
284
+ const next = await refresh();
285
+ if (!next)
286
+ throw authError('Your session ended. Sign in again.');
287
+ token = next;
288
+ continue;
289
+ }
290
+ let json = null;
291
+ try {
292
+ json = await res.json();
293
+ }
294
+ catch { /* below */ }
295
+ if (!res.ok) {
296
+ const e = new AcmsError(res.status, json?.error?.code ?? 'http_error', json?.error?.message ?? `HTTP ${res.status}`, Number(res.headers.get('Retry-After')) || undefined, json?.error?.issues);
297
+ if (res.status === 401)
298
+ options.onAuthError?.(e);
299
+ throw e;
300
+ }
301
+ if (json?.version !== 1 || !Object.prototype.hasOwnProperty.call(json, 'data'))
302
+ throw new AcmsError(res.status, 'invalid_response', 'Expected an ACMS v1 response.');
303
+ return json.data;
304
+ }
305
+ finally {
306
+ t.done();
307
+ }
308
+ }
309
+ }
310
+ const R = (o) => ({ signal: o?.signal });
311
+ return {
312
+ me: (o) => call('me', {}, R(o)),
313
+ listDocuments: (q = {}, o) => call('listDocuments', {}, { ...R(o), query: q }),
314
+ createDocument: (input, o) => call('createDocument', {}, { ...R(o), body: input }),
315
+ getDocument: (kind, id, o) => call('getDocument', { kind, id }, R(o)),
316
+ /** send the revision you loaded; 409 means someone saved first */
317
+ saveDocument: (kind, id, input, o) => call('saveDocument', { kind, id }, { ...R(o), body: input }),
318
+ publishDocument: (kind, id, revision, o) => call('publishDocument', { kind, id }, { ...R(o), body: { revision } }),
319
+ unpublishDocument: (kind, id, revision, o) => call('unpublishDocument', { kind, id }, { ...R(o), body: { revision } }),
320
+ scheduleDocument: (kind, id, revision, at, o) => call('scheduleDocument', { kind, id }, { ...R(o), body: { revision, at: at instanceof Date ? at.toISOString() : at } }),
321
+ discardDocument: (kind, id, revision, o) => call('discardDocument', { kind, id }, { ...R(o), body: { revision } }),
322
+ getDocumentTranslation: (kind, id, locale, o) => call('getDocumentTranslation', { kind, id, locale }, R(o)),
323
+ setDocumentTranslation: (kind, id, locale, input, o) => call('setDocumentTranslation', { kind, id, locale }, { ...R(o), body: input }),
324
+ reviewDocumentTranslation: (kind, id, locale, input, o) => call('reviewDocumentTranslation', { kind, id, locale }, { ...R(o), body: input }),
325
+ draftDocumentTranslation: (kind, id, locale, input, o) => call('draftDocumentTranslation', { kind, id, locale }, { ...R(o), body: input }),
326
+ listSections: (q = {}, o) => call('listSections', {}, { ...R(o), query: q }),
327
+ updateSection: (pageId, sectionId, input, o) => call('updateSection', { pageId, sectionId }, { ...R(o), body: input }),
328
+ publishSection: (pageId, sectionId, revision, o) => call('publishSection', { pageId, sectionId }, { ...R(o), body: { revision } }),
329
+ getSectionTranslation: (pageId, sectionId, locale, o) => call('getSectionTranslation', { pageId, sectionId, locale }, R(o)),
330
+ setSectionTranslation: (pageId, sectionId, locale, input, o) => call('setSectionTranslation', { pageId, sectionId, locale }, { ...R(o), body: input }),
331
+ reviewSectionTranslation: (pageId, sectionId, locale, input, o) => call('reviewSectionTranslation', { pageId, sectionId, locale }, { ...R(o), body: input }),
332
+ draftSectionTranslation: (pageId, sectionId, locale, input, o) => call('draftSectionTranslation', { pageId, sectionId, locale }, { ...R(o), body: input }),
333
+ listMedia: (q = {}, o) => call('listMedia', {}, { ...R(o), query: q }),
334
+ /** compress images on the device first (same limits as the admin: 30 MB images, 25 MB documents) */
335
+ uploadMedia: (file, meta = {}, o) => {
336
+ const form = new FormData();
337
+ if (typeof Blob !== 'undefined' && file instanceof Blob)
338
+ form.append('file', file, meta.name ?? file.name ?? 'upload');
339
+ else
340
+ form.append('file', file);
341
+ if (meta.alt)
342
+ form.append('alt', meta.alt);
343
+ if (meta.width)
344
+ form.append('width', String(meta.width));
345
+ if (meta.height)
346
+ form.append('height', String(meta.height));
347
+ return call('uploadMedia', {}, { ...R(o), form });
348
+ },
349
+ listBookings: (q = {}, o) => call('listBookings', {}, { ...R(o), query: q }),
350
+ approveBooking: (id, input = {}, o) => call('approveBooking', { id }, { ...R(o), body: input }),
351
+ declineBooking: (id, input = {}, o) => call('declineBooking', { id }, { ...R(o), body: input }),
352
+ cancelBooking: (id, input = {}, o) => call('cancelBooking', { id }, { ...R(o), body: input }),
353
+ };
354
+ }
@@ -0,0 +1,16 @@
1
+ import type { ApiErrorResponse } from './types.js';
2
+ export declare class AcmsError extends Error {
3
+ readonly status: number;
4
+ readonly code: string;
5
+ /** Retry-After in seconds (HTTP dates are converted relative to receipt). */
6
+ readonly retryAfter?: number | undefined;
7
+ readonly issues?: ApiErrorResponse['error']['issues'];
8
+ /** Retain this key for a later retry if a booking result is uncertain. */
9
+ readonly idempotencyKey?: string | undefined;
10
+ constructor(status: number, code: string, message: string,
11
+ /** Retry-After in seconds (HTTP dates are converted relative to receipt). */
12
+ retryAfter?: number | undefined, issues?: ApiErrorResponse['error']['issues'],
13
+ /** Retain this key for a later retry if a booking result is uncertain. */
14
+ idempotencyKey?: string | undefined);
15
+ }
16
+ export declare const isAcmsError: (error: unknown) => error is AcmsError;
package/dist/errors.js ADDED
@@ -0,0 +1,21 @@
1
+ export class AcmsError extends Error {
2
+ status;
3
+ code;
4
+ retryAfter;
5
+ issues;
6
+ idempotencyKey;
7
+ constructor(status, code, message,
8
+ /** Retry-After in seconds (HTTP dates are converted relative to receipt). */
9
+ retryAfter, issues,
10
+ /** Retain this key for a later retry if a booking result is uncertain. */
11
+ idempotencyKey) {
12
+ super(message);
13
+ this.status = status;
14
+ this.code = code;
15
+ this.retryAfter = retryAfter;
16
+ this.issues = issues;
17
+ this.idempotencyKey = idempotencyKey;
18
+ this.name = 'AcmsError';
19
+ }
20
+ }
21
+ export const isAcmsError = (error) => error instanceof AcmsError;
@@ -0,0 +1,171 @@
1
+ import type { Site, Theme, Page, Content, Post, ContentList, Events, Event, Calendars, Resources, Resource, Availability, BookingInput, BookingResult, Device, DeviceInput, DeviceUpdate, DeviceRegistration, DeviceRemoved, PushTopics } from './types.js';
2
+ export { AcmsError, isAcmsError } from './errors.js';
3
+ export type * from './types.js';
4
+ export * from './editor.js';
5
+ export interface RequestOptions {
6
+ locale?: string;
7
+ signal?: AbortSignal;
8
+ }
9
+ export interface ListOptions extends RequestOptions {
10
+ page?: number;
11
+ limit?: number; /** A page number, not an opaque server cursor. */
12
+ cursor?: number;
13
+ }
14
+ export interface ContentOptions extends ListOptions {
15
+ kind?: 'post' | 'page';
16
+ collection?: string;
17
+ }
18
+ export interface PostOptions extends Omit<ContentOptions, 'kind'> {
19
+ tag?: string;
20
+ }
21
+ export interface EventOptions extends ListOptions {
22
+ from?: string;
23
+ to?: string;
24
+ calendar?: string;
25
+ q?: string;
26
+ }
27
+ export interface AvailabilityOptions extends RequestOptions {
28
+ resource: string;
29
+ link: string;
30
+ from?: string;
31
+ days?: number;
32
+ minutes?: number;
33
+ }
34
+ export type BookOptions = BookingInput & RequestOptions & {
35
+ idempotencyKey?: string;
36
+ };
37
+ export interface ApiResponse<T> {
38
+ version: 1;
39
+ data: T;
40
+ }
41
+ export interface OpenApiDocument {
42
+ openapi: string;
43
+ info: Record<string, unknown>;
44
+ paths: Record<string, unknown>;
45
+ components: Record<string, unknown>;
46
+ }
47
+ export interface CacheEntry {
48
+ etag: string;
49
+ body: unknown;
50
+ }
51
+ export interface ClientCache {
52
+ get(url: string): CacheEntry | null | undefined | Promise<CacheEntry | null | undefined>;
53
+ /** null invalidates a previous entry. */
54
+ set(url: string, entry: CacheEntry | null): void | Promise<void>;
55
+ }
56
+ export interface ClientOptions {
57
+ /** Site origin or full /api/v1 base URL (including an optional deployment prefix). */
58
+ baseUrl: string;
59
+ locale?: string;
60
+ fetch?: typeof globalThis.fetch;
61
+ headers?: HeadersInit;
62
+ cache?: 'memory' | false | ClientCache;
63
+ /** Total deadline including retries, response reading and cache access; default 30s. */
64
+ timeoutMs?: number;
65
+ }
66
+ export declare const endpoints: {
67
+ readonly site: {
68
+ readonly method: "GET";
69
+ readonly path: "/site";
70
+ };
71
+ readonly theme: {
72
+ readonly method: "GET";
73
+ readonly path: "/theme";
74
+ };
75
+ readonly home: {
76
+ readonly method: "GET";
77
+ readonly path: "/pages/home";
78
+ };
79
+ readonly page: {
80
+ readonly method: "GET";
81
+ readonly path: "/pages/{slug}";
82
+ };
83
+ readonly content: {
84
+ readonly method: "GET";
85
+ readonly path: "/content";
86
+ };
87
+ readonly contentById: {
88
+ readonly method: "GET";
89
+ readonly path: "/content/{id}";
90
+ };
91
+ readonly events: {
92
+ readonly method: "GET";
93
+ readonly path: "/events";
94
+ };
95
+ readonly calendars: {
96
+ readonly method: "GET";
97
+ readonly path: "/calendars";
98
+ };
99
+ readonly resources: {
100
+ readonly method: "GET";
101
+ readonly path: "/resources";
102
+ };
103
+ readonly availability: {
104
+ readonly method: "GET";
105
+ readonly path: "/availability";
106
+ };
107
+ readonly book: {
108
+ readonly method: "POST";
109
+ readonly path: "/bookings";
110
+ };
111
+ readonly openapi: {
112
+ readonly method: "GET";
113
+ readonly path: "/openapi.json";
114
+ };
115
+ readonly registerDevice: {
116
+ readonly method: "POST";
117
+ readonly path: "/devices";
118
+ };
119
+ readonly updateDevice: {
120
+ readonly method: "PUT";
121
+ readonly path: "/devices/{token}";
122
+ };
123
+ readonly unregisterDevice: {
124
+ readonly method: "DELETE";
125
+ readonly path: "/devices/{token}";
126
+ };
127
+ readonly pushTopics: {
128
+ readonly method: "GET";
129
+ readonly path: "/push/topics";
130
+ };
131
+ };
132
+ export declare function createClient(options: ClientOptions): AcmsClient;
133
+ export interface AcmsClient {
134
+ site(o?: RequestOptions): Promise<Site>;
135
+ theme(o?: RequestOptions): Promise<Theme>;
136
+ home(o?: RequestOptions): Promise<Page>;
137
+ page(slug: string, o?: RequestOptions): Promise<Page>;
138
+ content(o?: ContentOptions): Promise<ContentList>;
139
+ contentById(id: string, o?: RequestOptions & {
140
+ kind?: 'post' | 'page';
141
+ }): Promise<Content>;
142
+ posts(o?: PostOptions): Promise<ContentList>;
143
+ post(slug: string, o?: RequestOptions & {
144
+ collection?: string;
145
+ }): Promise<Post>;
146
+ pages(o?: ListOptions): Promise<ContentList>;
147
+ events(o?: EventOptions): Promise<Events>;
148
+ event(slug: string, o?: EventOptions): Promise<Event>;
149
+ calendars(o?: RequestOptions): Promise<Calendars>;
150
+ resources(o?: ListOptions): Promise<Resources>;
151
+ resource(slug: string, o?: RequestOptions): Promise<Resource>;
152
+ availability(o: AvailabilityOptions): Promise<Availability>;
153
+ book(o: BookOptions): Promise<BookingResult>;
154
+ openapi(o?: RequestOptions): Promise<OpenApiDocument>;
155
+ /** Register or refresh this device's Expo push token. Call on every app start. Requires push on the site. */
156
+ registerDevice(input: DeviceInput, o?: RequestOptions): Promise<DeviceRegistration>;
157
+ /** Change the device's topics or language. */
158
+ updateDevice(token: string, input: DeviceUpdate, o?: RequestOptions): Promise<Device>;
159
+ /** Opt out: removes the token from the site (idempotent). */
160
+ unregisterDevice(token: string, o?: RequestOptions): Promise<DeviceRemoved>;
161
+ /** Topics the app can offer as notification toggles. */
162
+ pushTopics(o?: RequestOptions): Promise<PushTopics>;
163
+ withLocale(locale: string): AcmsClient;
164
+ paginate: {
165
+ content(o?: ContentOptions): AsyncGenerator<ContentList['items'][number]>;
166
+ posts(o?: PostOptions): AsyncGenerator<ContentList['items'][number]>;
167
+ pages(o?: ListOptions): AsyncGenerator<ContentList['items'][number]>;
168
+ events(o?: EventOptions): AsyncGenerator<Events['occurrences'][number]>;
169
+ resources(o?: ListOptions): AsyncGenerator<Resource>;
170
+ };
171
+ }