@headroom-cms/api 0.1.3 → 0.1.5

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/astro.js CHANGED
@@ -1,5 +1,73 @@
1
1
  // src/client.ts
2
2
  import { createHmac } from "crypto";
3
+
4
+ // src/auth.ts
5
+ var HeadroomAuth = class {
6
+ /** @internal */
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+ /** Request an OTP code be sent to the email address */
11
+ async requestOTP(email) {
12
+ await this.client._fetchPost("/auth/otp/request", { email });
13
+ }
14
+ /** Verify OTP code and receive a long-lived session token */
15
+ async verifyOTP(email, code) {
16
+ return this.client._fetchPost("/auth/otp/verify", {
17
+ email,
18
+ code
19
+ });
20
+ }
21
+ /**
22
+ * Validate a session token and return the user.
23
+ * Automatically refreshes the token if it's past 75% of its lifetime,
24
+ * so callers can update their cookie with the new token if wasRefreshed is true.
25
+ */
26
+ async getSession(token) {
27
+ const res = await this.client._fetchWithAuth(
28
+ "/auth/session",
29
+ token
30
+ );
31
+ const now = Math.floor(Date.now() / 1e3);
32
+ const remaining = res.expiresAt - now;
33
+ const lifetime = res.expiresAt - res.issuedAt;
34
+ if (remaining < lifetime * 0.25) {
35
+ try {
36
+ const refreshed = await this.refreshSession(token);
37
+ return { ...refreshed, wasRefreshed: true };
38
+ } catch {
39
+ return {
40
+ user: res.user,
41
+ expiresAt: res.expiresAt,
42
+ token,
43
+ wasRefreshed: false
44
+ };
45
+ }
46
+ }
47
+ return {
48
+ user: res.user,
49
+ expiresAt: res.expiresAt,
50
+ token,
51
+ wasRefreshed: false
52
+ };
53
+ }
54
+ /** Refresh a session token for a new expiry period */
55
+ async refreshSession(token) {
56
+ const res = await this.client._fetchPostWithAuth(
57
+ "/auth/session/refresh",
58
+ token,
59
+ {}
60
+ );
61
+ return {
62
+ user: res.user,
63
+ expiresAt: res.expiresAt,
64
+ token: res.token,
65
+ wasRefreshed: true
66
+ };
67
+ }
68
+ };
69
+
70
+ // src/client.ts
3
71
  var HeadroomError = class extends Error {
4
72
  status;
5
73
  code;
@@ -12,11 +80,14 @@ var HeadroomError = class extends Error {
12
80
  };
13
81
  var HeadroomClient = class {
14
82
  config;
83
+ /** Site user authentication methods */
84
+ auth;
15
85
  constructor(config) {
16
86
  this.config = {
17
87
  ...config,
18
88
  url: config.url.replace(/\/+$/, "")
19
89
  };
90
+ this.auth = new HeadroomAuth(this);
20
91
  }
21
92
  /** Build the full URL for a public API path */
22
93
  apiUrl(path) {
@@ -79,7 +150,7 @@ var HeadroomClient = class {
79
150
  message = body.error || message;
80
151
  } catch {
81
152
  }
82
- throw new HeadroomError(res.status, code, message);
153
+ throw new HeadroomError(res.status, code, `${message} (GET ${url})`);
83
154
  }
84
155
  return res.json();
85
156
  }
@@ -101,7 +172,58 @@ var HeadroomClient = class {
101
172
  message = errBody.error || message;
102
173
  } catch {
103
174
  }
104
- throw new HeadroomError(res.status, code, message);
175
+ throw new HeadroomError(res.status, code, `${message} (POST ${this.apiUrl(path)})`);
176
+ }
177
+ return res.json();
178
+ }
179
+ /** @internal Used by HeadroomAuth */
180
+ async _fetchPost(path, body) {
181
+ return this.fetchPost(path, body);
182
+ }
183
+ /** @internal Used by HeadroomAuth — GET with Bearer token */
184
+ async _fetchWithAuth(path, token) {
185
+ const url = this.apiUrl(path);
186
+ const res = await fetch(url, {
187
+ headers: {
188
+ "X-Headroom-Key": this.config.apiKey,
189
+ Authorization: `Bearer ${token}`
190
+ }
191
+ });
192
+ if (!res.ok) {
193
+ let code = "UNKNOWN";
194
+ let message = `HTTP ${res.status}`;
195
+ try {
196
+ const body = await res.json();
197
+ code = body.code || code;
198
+ message = body.error || message;
199
+ } catch {
200
+ }
201
+ throw new HeadroomError(res.status, code, `${message} (GET ${url})`);
202
+ }
203
+ return res.json();
204
+ }
205
+ /** @internal Used by HeadroomAuth — POST with Bearer token */
206
+ async _fetchPostWithAuth(path, token, body) {
207
+ const url = this.apiUrl(path);
208
+ const res = await fetch(url, {
209
+ method: "POST",
210
+ headers: {
211
+ "X-Headroom-Key": this.config.apiKey,
212
+ Authorization: `Bearer ${token}`,
213
+ "Content-Type": "application/json"
214
+ },
215
+ body: JSON.stringify(body)
216
+ });
217
+ if (!res.ok) {
218
+ let code = "UNKNOWN";
219
+ let message = `HTTP ${res.status}`;
220
+ try {
221
+ const errBody = await res.json();
222
+ code = errBody.code || code;
223
+ message = errBody.error || message;
224
+ } catch {
225
+ }
226
+ throw new HeadroomError(res.status, code, `${message} (POST ${url})`);
105
227
  }
106
228
  return res.json();
107
229
  }
@@ -163,6 +285,40 @@ var HeadroomClient = class {
163
285
  const result = await this.fetch("/version");
164
286
  return result.contentVersion;
165
287
  }
288
+ // --- Submissions ---
289
+ /**
290
+ * Submit content to a submission collection.
291
+ * Sends a POST to `/v1/{site}/submit/{collection}`.
292
+ * If a sessionToken is provided, it is sent as the `X-Headroom-Session` header
293
+ * to associate the submission with an authenticated site user.
294
+ */
295
+ async submit(options) {
296
+ const url = this.apiUrl(`/submit/${encodeURIComponent(options.collection)}`);
297
+ const headers = {
298
+ "X-Headroom-Key": this.config.apiKey,
299
+ "Content-Type": "application/json"
300
+ };
301
+ if (options.sessionToken) {
302
+ headers["X-Headroom-Session"] = options.sessionToken;
303
+ }
304
+ const res = await fetch(url, {
305
+ method: "POST",
306
+ headers,
307
+ body: JSON.stringify({ fields: options.fields })
308
+ });
309
+ if (!res.ok) {
310
+ let code = "UNKNOWN";
311
+ let message = `HTTP ${res.status}`;
312
+ try {
313
+ const errBody = await res.json();
314
+ code = errBody.code || code;
315
+ message = errBody.error || message;
316
+ } catch {
317
+ }
318
+ throw new HeadroomError(res.status, code, `${message} (POST ${url})`);
319
+ }
320
+ return res.json();
321
+ }
166
322
  };
167
323
 
168
324
  // src/astro/env.ts
@@ -216,7 +372,7 @@ function headroomLoader(opts) {
216
372
  return {
217
373
  name: "headroom",
218
374
  schema: opts.schema,
219
- load: async ({ store, meta, generateDigest, parseData }) => {
375
+ load: async ({ store, meta, generateDigest, parseData, logger }) => {
220
376
  const config = opts.config || configFromEnv();
221
377
  const client = new HeadroomClient(config);
222
378
  try {
@@ -230,14 +386,20 @@ function headroomLoader(opts) {
230
386
  }
231
387
  const allMetadata = [];
232
388
  let cursor;
233
- do {
234
- const result = await client.listContent(opts.collection, {
235
- limit: 1e3,
236
- cursor
237
- });
238
- allMetadata.push(...result.items);
239
- cursor = result.hasMore ? result.cursor : void 0;
240
- } while (cursor);
389
+ try {
390
+ do {
391
+ const result = await client.listContent(opts.collection, {
392
+ limit: 1e3,
393
+ cursor
394
+ });
395
+ allMetadata.push(...result.items);
396
+ cursor = result.hasMore ? result.cursor : void 0;
397
+ } while (cursor);
398
+ } catch (e) {
399
+ const msg = `Failed to load Headroom collection "${opts.collection}" from ${config.url}: ${e instanceof Error ? e.message : e}`;
400
+ logger.error(msg);
401
+ throw new Error(msg, { cause: e });
402
+ }
241
403
  const seen = /* @__PURE__ */ new Set();
242
404
  const storeId = (item) => item.slug || opts.collection;
243
405
  if (opts.bodies) {
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ HeadroomAuth: () => HeadroomAuth,
23
24
  HeadroomClient: () => HeadroomClient,
24
25
  HeadroomError: () => HeadroomError
25
26
  });
@@ -27,6 +28,74 @@ module.exports = __toCommonJS(src_exports);
27
28
 
28
29
  // src/client.ts
29
30
  var import_node_crypto = require("crypto");
31
+
32
+ // src/auth.ts
33
+ var HeadroomAuth = class {
34
+ /** @internal */
35
+ constructor(client) {
36
+ this.client = client;
37
+ }
38
+ /** Request an OTP code be sent to the email address */
39
+ async requestOTP(email) {
40
+ await this.client._fetchPost("/auth/otp/request", { email });
41
+ }
42
+ /** Verify OTP code and receive a long-lived session token */
43
+ async verifyOTP(email, code) {
44
+ return this.client._fetchPost("/auth/otp/verify", {
45
+ email,
46
+ code
47
+ });
48
+ }
49
+ /**
50
+ * Validate a session token and return the user.
51
+ * Automatically refreshes the token if it's past 75% of its lifetime,
52
+ * so callers can update their cookie with the new token if wasRefreshed is true.
53
+ */
54
+ async getSession(token) {
55
+ const res = await this.client._fetchWithAuth(
56
+ "/auth/session",
57
+ token
58
+ );
59
+ const now = Math.floor(Date.now() / 1e3);
60
+ const remaining = res.expiresAt - now;
61
+ const lifetime = res.expiresAt - res.issuedAt;
62
+ if (remaining < lifetime * 0.25) {
63
+ try {
64
+ const refreshed = await this.refreshSession(token);
65
+ return { ...refreshed, wasRefreshed: true };
66
+ } catch {
67
+ return {
68
+ user: res.user,
69
+ expiresAt: res.expiresAt,
70
+ token,
71
+ wasRefreshed: false
72
+ };
73
+ }
74
+ }
75
+ return {
76
+ user: res.user,
77
+ expiresAt: res.expiresAt,
78
+ token,
79
+ wasRefreshed: false
80
+ };
81
+ }
82
+ /** Refresh a session token for a new expiry period */
83
+ async refreshSession(token) {
84
+ const res = await this.client._fetchPostWithAuth(
85
+ "/auth/session/refresh",
86
+ token,
87
+ {}
88
+ );
89
+ return {
90
+ user: res.user,
91
+ expiresAt: res.expiresAt,
92
+ token: res.token,
93
+ wasRefreshed: true
94
+ };
95
+ }
96
+ };
97
+
98
+ // src/client.ts
30
99
  var HeadroomError = class extends Error {
31
100
  status;
32
101
  code;
@@ -39,11 +108,14 @@ var HeadroomError = class extends Error {
39
108
  };
40
109
  var HeadroomClient = class {
41
110
  config;
111
+ /** Site user authentication methods */
112
+ auth;
42
113
  constructor(config) {
43
114
  this.config = {
44
115
  ...config,
45
116
  url: config.url.replace(/\/+$/, "")
46
117
  };
118
+ this.auth = new HeadroomAuth(this);
47
119
  }
48
120
  /** Build the full URL for a public API path */
49
121
  apiUrl(path) {
@@ -106,7 +178,7 @@ var HeadroomClient = class {
106
178
  message = body.error || message;
107
179
  } catch {
108
180
  }
109
- throw new HeadroomError(res.status, code, message);
181
+ throw new HeadroomError(res.status, code, `${message} (GET ${url})`);
110
182
  }
111
183
  return res.json();
112
184
  }
@@ -128,7 +200,58 @@ var HeadroomClient = class {
128
200
  message = errBody.error || message;
129
201
  } catch {
130
202
  }
131
- throw new HeadroomError(res.status, code, message);
203
+ throw new HeadroomError(res.status, code, `${message} (POST ${this.apiUrl(path)})`);
204
+ }
205
+ return res.json();
206
+ }
207
+ /** @internal Used by HeadroomAuth */
208
+ async _fetchPost(path, body) {
209
+ return this.fetchPost(path, body);
210
+ }
211
+ /** @internal Used by HeadroomAuth — GET with Bearer token */
212
+ async _fetchWithAuth(path, token) {
213
+ const url = this.apiUrl(path);
214
+ const res = await fetch(url, {
215
+ headers: {
216
+ "X-Headroom-Key": this.config.apiKey,
217
+ Authorization: `Bearer ${token}`
218
+ }
219
+ });
220
+ if (!res.ok) {
221
+ let code = "UNKNOWN";
222
+ let message = `HTTP ${res.status}`;
223
+ try {
224
+ const body = await res.json();
225
+ code = body.code || code;
226
+ message = body.error || message;
227
+ } catch {
228
+ }
229
+ throw new HeadroomError(res.status, code, `${message} (GET ${url})`);
230
+ }
231
+ return res.json();
232
+ }
233
+ /** @internal Used by HeadroomAuth — POST with Bearer token */
234
+ async _fetchPostWithAuth(path, token, body) {
235
+ const url = this.apiUrl(path);
236
+ const res = await fetch(url, {
237
+ method: "POST",
238
+ headers: {
239
+ "X-Headroom-Key": this.config.apiKey,
240
+ Authorization: `Bearer ${token}`,
241
+ "Content-Type": "application/json"
242
+ },
243
+ body: JSON.stringify(body)
244
+ });
245
+ if (!res.ok) {
246
+ let code = "UNKNOWN";
247
+ let message = `HTTP ${res.status}`;
248
+ try {
249
+ const errBody = await res.json();
250
+ code = errBody.code || code;
251
+ message = errBody.error || message;
252
+ } catch {
253
+ }
254
+ throw new HeadroomError(res.status, code, `${message} (POST ${url})`);
132
255
  }
133
256
  return res.json();
134
257
  }
@@ -190,9 +313,44 @@ var HeadroomClient = class {
190
313
  const result = await this.fetch("/version");
191
314
  return result.contentVersion;
192
315
  }
316
+ // --- Submissions ---
317
+ /**
318
+ * Submit content to a submission collection.
319
+ * Sends a POST to `/v1/{site}/submit/{collection}`.
320
+ * If a sessionToken is provided, it is sent as the `X-Headroom-Session` header
321
+ * to associate the submission with an authenticated site user.
322
+ */
323
+ async submit(options) {
324
+ const url = this.apiUrl(`/submit/${encodeURIComponent(options.collection)}`);
325
+ const headers = {
326
+ "X-Headroom-Key": this.config.apiKey,
327
+ "Content-Type": "application/json"
328
+ };
329
+ if (options.sessionToken) {
330
+ headers["X-Headroom-Session"] = options.sessionToken;
331
+ }
332
+ const res = await fetch(url, {
333
+ method: "POST",
334
+ headers,
335
+ body: JSON.stringify({ fields: options.fields })
336
+ });
337
+ if (!res.ok) {
338
+ let code = "UNKNOWN";
339
+ let message = `HTTP ${res.status}`;
340
+ try {
341
+ const errBody = await res.json();
342
+ code = errBody.code || code;
343
+ message = errBody.error || message;
344
+ } catch {
345
+ }
346
+ throw new HeadroomError(res.status, code, `${message} (POST ${url})`);
347
+ }
348
+ return res.json();
349
+ }
193
350
  };
194
351
  // Annotate the CommonJS export names for ESM import in node:
195
352
  0 && (module.exports = {
353
+ HeadroomAuth,
196
354
  HeadroomClient,
197
355
  HeadroomError
198
356
  });
package/dist/index.d.cts CHANGED
@@ -151,11 +151,69 @@ interface TransformOptions {
151
151
  /** Quality (1-100) */
152
152
  quality?: number;
153
153
  }
154
+ /** Options for submitting content to a submission collection. */
155
+ interface SubmitOptions {
156
+ /** The submission collection name */
157
+ collection: string;
158
+ /** Field values for the submission */
159
+ fields: Record<string, unknown>;
160
+ /** Optional site user session token (adds X-Headroom-Session header) */
161
+ sessionToken?: string;
162
+ }
163
+ /** Result returned from a successful submission. */
164
+ interface SubmitResult {
165
+ /** The ID of the created content item */
166
+ contentId: string;
167
+ /** Unix timestamp of creation */
168
+ createdAt: number;
169
+ /** Site user ID if the submission was authenticated */
170
+ siteUserId?: string;
171
+ }
154
172
  interface HeadroomErrorBody {
155
173
  error: string;
156
174
  code?: string;
157
175
  }
158
176
 
177
+ interface AuthResult {
178
+ token: string;
179
+ expiresAt: number;
180
+ user: AuthUser;
181
+ }
182
+ interface AuthUser {
183
+ userId: string;
184
+ email: string;
185
+ name?: string;
186
+ tags?: string[];
187
+ fields?: Record<string, unknown>;
188
+ }
189
+ interface AuthSession {
190
+ user: AuthUser;
191
+ expiresAt: number;
192
+ token: string;
193
+ wasRefreshed: boolean;
194
+ }
195
+ /**
196
+ * HeadroomAuth provides site user authentication methods.
197
+ * Access via `client.auth`.
198
+ */
199
+ declare class HeadroomAuth {
200
+ private client;
201
+ /** @internal */
202
+ constructor(client: HeadroomClient);
203
+ /** Request an OTP code be sent to the email address */
204
+ requestOTP(email: string): Promise<void>;
205
+ /** Verify OTP code and receive a long-lived session token */
206
+ verifyOTP(email: string, code: string): Promise<AuthResult>;
207
+ /**
208
+ * Validate a session token and return the user.
209
+ * Automatically refreshes the token if it's past 75% of its lifetime,
210
+ * so callers can update their cookie with the new token if wasRefreshed is true.
211
+ */
212
+ getSession(token: string): Promise<AuthSession>;
213
+ /** Refresh a session token for a new expiry period */
214
+ refreshSession(token: string): Promise<AuthSession>;
215
+ }
216
+
159
217
  declare class HeadroomError extends Error {
160
218
  status: number;
161
219
  code: string;
@@ -163,6 +221,8 @@ declare class HeadroomError extends Error {
163
221
  }
164
222
  declare class HeadroomClient {
165
223
  private config;
224
+ /** Site user authentication methods */
225
+ readonly auth: HeadroomAuth;
166
226
  constructor(config: HeadroomConfig);
167
227
  /** Build the full URL for a public API path */
168
228
  private apiUrl;
@@ -189,6 +249,12 @@ declare class HeadroomClient {
189
249
  transformUrl(path: string | undefined, opts?: TransformOptions): string;
190
250
  private fetch;
191
251
  private fetchPost;
252
+ /** @internal Used by HeadroomAuth */
253
+ _fetchPost<T>(path: string, body: unknown): Promise<T>;
254
+ /** @internal Used by HeadroomAuth — GET with Bearer token */
255
+ _fetchWithAuth<T>(path: string, token: string): Promise<T>;
256
+ /** @internal Used by HeadroomAuth — POST with Bearer token */
257
+ _fetchPostWithAuth<T>(path: string, token: string, body: unknown): Promise<T>;
192
258
  listContent(collection: string, opts?: {
193
259
  limit?: number;
194
260
  cursor?: string;
@@ -218,6 +284,13 @@ declare class HeadroomClient {
218
284
  getCollection(name: string): Promise<Collection>;
219
285
  listBlockTypes(): Promise<BlockTypeListResult>;
220
286
  getVersion(): Promise<number>;
287
+ /**
288
+ * Submit content to a submission collection.
289
+ * Sends a POST to `/v1/{site}/submit/{collection}`.
290
+ * If a sessionToken is provided, it is sent as the `X-Headroom-Session` header
291
+ * to associate the submission with an authenticated site user.
292
+ */
293
+ submit(options: SubmitOptions): Promise<SubmitResult>;
221
294
  }
222
295
 
223
- export { type BatchContentResult, type Block, type BlockTypeDef, type BlockTypeListResult, type Collection, type CollectionListResult, type CollectionSummary, type ContentItem, type ContentListResult, type ContentMetadata, type ContentRef, type FieldDef, HeadroomClient, type HeadroomConfig, HeadroomError, type HeadroomErrorBody, type InlineContent, type LinkContent, type PublicContentRef, type RefsMap, type RelationshipDef, type TableContent, type TableRow, type TextContent, type TextStyles, type TransformOptions };
296
+ export { type AuthResult, type AuthSession, type AuthUser, type BatchContentResult, type Block, type BlockTypeDef, type BlockTypeListResult, type Collection, type CollectionListResult, type CollectionSummary, type ContentItem, type ContentListResult, type ContentMetadata, type ContentRef, type FieldDef, HeadroomAuth, HeadroomClient, type HeadroomConfig, HeadroomError, type HeadroomErrorBody, type InlineContent, type LinkContent, type PublicContentRef, type RefsMap, type RelationshipDef, type SubmitOptions, type SubmitResult, type TableContent, type TableRow, type TextContent, type TextStyles, type TransformOptions };
package/dist/index.d.ts CHANGED
@@ -151,11 +151,69 @@ interface TransformOptions {
151
151
  /** Quality (1-100) */
152
152
  quality?: number;
153
153
  }
154
+ /** Options for submitting content to a submission collection. */
155
+ interface SubmitOptions {
156
+ /** The submission collection name */
157
+ collection: string;
158
+ /** Field values for the submission */
159
+ fields: Record<string, unknown>;
160
+ /** Optional site user session token (adds X-Headroom-Session header) */
161
+ sessionToken?: string;
162
+ }
163
+ /** Result returned from a successful submission. */
164
+ interface SubmitResult {
165
+ /** The ID of the created content item */
166
+ contentId: string;
167
+ /** Unix timestamp of creation */
168
+ createdAt: number;
169
+ /** Site user ID if the submission was authenticated */
170
+ siteUserId?: string;
171
+ }
154
172
  interface HeadroomErrorBody {
155
173
  error: string;
156
174
  code?: string;
157
175
  }
158
176
 
177
+ interface AuthResult {
178
+ token: string;
179
+ expiresAt: number;
180
+ user: AuthUser;
181
+ }
182
+ interface AuthUser {
183
+ userId: string;
184
+ email: string;
185
+ name?: string;
186
+ tags?: string[];
187
+ fields?: Record<string, unknown>;
188
+ }
189
+ interface AuthSession {
190
+ user: AuthUser;
191
+ expiresAt: number;
192
+ token: string;
193
+ wasRefreshed: boolean;
194
+ }
195
+ /**
196
+ * HeadroomAuth provides site user authentication methods.
197
+ * Access via `client.auth`.
198
+ */
199
+ declare class HeadroomAuth {
200
+ private client;
201
+ /** @internal */
202
+ constructor(client: HeadroomClient);
203
+ /** Request an OTP code be sent to the email address */
204
+ requestOTP(email: string): Promise<void>;
205
+ /** Verify OTP code and receive a long-lived session token */
206
+ verifyOTP(email: string, code: string): Promise<AuthResult>;
207
+ /**
208
+ * Validate a session token and return the user.
209
+ * Automatically refreshes the token if it's past 75% of its lifetime,
210
+ * so callers can update their cookie with the new token if wasRefreshed is true.
211
+ */
212
+ getSession(token: string): Promise<AuthSession>;
213
+ /** Refresh a session token for a new expiry period */
214
+ refreshSession(token: string): Promise<AuthSession>;
215
+ }
216
+
159
217
  declare class HeadroomError extends Error {
160
218
  status: number;
161
219
  code: string;
@@ -163,6 +221,8 @@ declare class HeadroomError extends Error {
163
221
  }
164
222
  declare class HeadroomClient {
165
223
  private config;
224
+ /** Site user authentication methods */
225
+ readonly auth: HeadroomAuth;
166
226
  constructor(config: HeadroomConfig);
167
227
  /** Build the full URL for a public API path */
168
228
  private apiUrl;
@@ -189,6 +249,12 @@ declare class HeadroomClient {
189
249
  transformUrl(path: string | undefined, opts?: TransformOptions): string;
190
250
  private fetch;
191
251
  private fetchPost;
252
+ /** @internal Used by HeadroomAuth */
253
+ _fetchPost<T>(path: string, body: unknown): Promise<T>;
254
+ /** @internal Used by HeadroomAuth — GET with Bearer token */
255
+ _fetchWithAuth<T>(path: string, token: string): Promise<T>;
256
+ /** @internal Used by HeadroomAuth — POST with Bearer token */
257
+ _fetchPostWithAuth<T>(path: string, token: string, body: unknown): Promise<T>;
192
258
  listContent(collection: string, opts?: {
193
259
  limit?: number;
194
260
  cursor?: string;
@@ -218,6 +284,13 @@ declare class HeadroomClient {
218
284
  getCollection(name: string): Promise<Collection>;
219
285
  listBlockTypes(): Promise<BlockTypeListResult>;
220
286
  getVersion(): Promise<number>;
287
+ /**
288
+ * Submit content to a submission collection.
289
+ * Sends a POST to `/v1/{site}/submit/{collection}`.
290
+ * If a sessionToken is provided, it is sent as the `X-Headroom-Session` header
291
+ * to associate the submission with an authenticated site user.
292
+ */
293
+ submit(options: SubmitOptions): Promise<SubmitResult>;
221
294
  }
222
295
 
223
- export { type BatchContentResult, type Block, type BlockTypeDef, type BlockTypeListResult, type Collection, type CollectionListResult, type CollectionSummary, type ContentItem, type ContentListResult, type ContentMetadata, type ContentRef, type FieldDef, HeadroomClient, type HeadroomConfig, HeadroomError, type HeadroomErrorBody, type InlineContent, type LinkContent, type PublicContentRef, type RefsMap, type RelationshipDef, type TableContent, type TableRow, type TextContent, type TextStyles, type TransformOptions };
296
+ export { type AuthResult, type AuthSession, type AuthUser, type BatchContentResult, type Block, type BlockTypeDef, type BlockTypeListResult, type Collection, type CollectionListResult, type CollectionSummary, type ContentItem, type ContentListResult, type ContentMetadata, type ContentRef, type FieldDef, HeadroomAuth, HeadroomClient, type HeadroomConfig, HeadroomError, type HeadroomErrorBody, type InlineContent, type LinkContent, type PublicContentRef, type RefsMap, type RelationshipDef, type SubmitOptions, type SubmitResult, type TableContent, type TableRow, type TextContent, type TextStyles, type TransformOptions };
package/dist/index.js CHANGED
@@ -1,5 +1,73 @@
1
1
  // src/client.ts
2
2
  import { createHmac } from "crypto";
3
+
4
+ // src/auth.ts
5
+ var HeadroomAuth = class {
6
+ /** @internal */
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+ /** Request an OTP code be sent to the email address */
11
+ async requestOTP(email) {
12
+ await this.client._fetchPost("/auth/otp/request", { email });
13
+ }
14
+ /** Verify OTP code and receive a long-lived session token */
15
+ async verifyOTP(email, code) {
16
+ return this.client._fetchPost("/auth/otp/verify", {
17
+ email,
18
+ code
19
+ });
20
+ }
21
+ /**
22
+ * Validate a session token and return the user.
23
+ * Automatically refreshes the token if it's past 75% of its lifetime,
24
+ * so callers can update their cookie with the new token if wasRefreshed is true.
25
+ */
26
+ async getSession(token) {
27
+ const res = await this.client._fetchWithAuth(
28
+ "/auth/session",
29
+ token
30
+ );
31
+ const now = Math.floor(Date.now() / 1e3);
32
+ const remaining = res.expiresAt - now;
33
+ const lifetime = res.expiresAt - res.issuedAt;
34
+ if (remaining < lifetime * 0.25) {
35
+ try {
36
+ const refreshed = await this.refreshSession(token);
37
+ return { ...refreshed, wasRefreshed: true };
38
+ } catch {
39
+ return {
40
+ user: res.user,
41
+ expiresAt: res.expiresAt,
42
+ token,
43
+ wasRefreshed: false
44
+ };
45
+ }
46
+ }
47
+ return {
48
+ user: res.user,
49
+ expiresAt: res.expiresAt,
50
+ token,
51
+ wasRefreshed: false
52
+ };
53
+ }
54
+ /** Refresh a session token for a new expiry period */
55
+ async refreshSession(token) {
56
+ const res = await this.client._fetchPostWithAuth(
57
+ "/auth/session/refresh",
58
+ token,
59
+ {}
60
+ );
61
+ return {
62
+ user: res.user,
63
+ expiresAt: res.expiresAt,
64
+ token: res.token,
65
+ wasRefreshed: true
66
+ };
67
+ }
68
+ };
69
+
70
+ // src/client.ts
3
71
  var HeadroomError = class extends Error {
4
72
  status;
5
73
  code;
@@ -12,11 +80,14 @@ var HeadroomError = class extends Error {
12
80
  };
13
81
  var HeadroomClient = class {
14
82
  config;
83
+ /** Site user authentication methods */
84
+ auth;
15
85
  constructor(config) {
16
86
  this.config = {
17
87
  ...config,
18
88
  url: config.url.replace(/\/+$/, "")
19
89
  };
90
+ this.auth = new HeadroomAuth(this);
20
91
  }
21
92
  /** Build the full URL for a public API path */
22
93
  apiUrl(path) {
@@ -79,7 +150,7 @@ var HeadroomClient = class {
79
150
  message = body.error || message;
80
151
  } catch {
81
152
  }
82
- throw new HeadroomError(res.status, code, message);
153
+ throw new HeadroomError(res.status, code, `${message} (GET ${url})`);
83
154
  }
84
155
  return res.json();
85
156
  }
@@ -101,7 +172,58 @@ var HeadroomClient = class {
101
172
  message = errBody.error || message;
102
173
  } catch {
103
174
  }
104
- throw new HeadroomError(res.status, code, message);
175
+ throw new HeadroomError(res.status, code, `${message} (POST ${this.apiUrl(path)})`);
176
+ }
177
+ return res.json();
178
+ }
179
+ /** @internal Used by HeadroomAuth */
180
+ async _fetchPost(path, body) {
181
+ return this.fetchPost(path, body);
182
+ }
183
+ /** @internal Used by HeadroomAuth — GET with Bearer token */
184
+ async _fetchWithAuth(path, token) {
185
+ const url = this.apiUrl(path);
186
+ const res = await fetch(url, {
187
+ headers: {
188
+ "X-Headroom-Key": this.config.apiKey,
189
+ Authorization: `Bearer ${token}`
190
+ }
191
+ });
192
+ if (!res.ok) {
193
+ let code = "UNKNOWN";
194
+ let message = `HTTP ${res.status}`;
195
+ try {
196
+ const body = await res.json();
197
+ code = body.code || code;
198
+ message = body.error || message;
199
+ } catch {
200
+ }
201
+ throw new HeadroomError(res.status, code, `${message} (GET ${url})`);
202
+ }
203
+ return res.json();
204
+ }
205
+ /** @internal Used by HeadroomAuth — POST with Bearer token */
206
+ async _fetchPostWithAuth(path, token, body) {
207
+ const url = this.apiUrl(path);
208
+ const res = await fetch(url, {
209
+ method: "POST",
210
+ headers: {
211
+ "X-Headroom-Key": this.config.apiKey,
212
+ Authorization: `Bearer ${token}`,
213
+ "Content-Type": "application/json"
214
+ },
215
+ body: JSON.stringify(body)
216
+ });
217
+ if (!res.ok) {
218
+ let code = "UNKNOWN";
219
+ let message = `HTTP ${res.status}`;
220
+ try {
221
+ const errBody = await res.json();
222
+ code = errBody.code || code;
223
+ message = errBody.error || message;
224
+ } catch {
225
+ }
226
+ throw new HeadroomError(res.status, code, `${message} (POST ${url})`);
105
227
  }
106
228
  return res.json();
107
229
  }
@@ -163,8 +285,43 @@ var HeadroomClient = class {
163
285
  const result = await this.fetch("/version");
164
286
  return result.contentVersion;
165
287
  }
288
+ // --- Submissions ---
289
+ /**
290
+ * Submit content to a submission collection.
291
+ * Sends a POST to `/v1/{site}/submit/{collection}`.
292
+ * If a sessionToken is provided, it is sent as the `X-Headroom-Session` header
293
+ * to associate the submission with an authenticated site user.
294
+ */
295
+ async submit(options) {
296
+ const url = this.apiUrl(`/submit/${encodeURIComponent(options.collection)}`);
297
+ const headers = {
298
+ "X-Headroom-Key": this.config.apiKey,
299
+ "Content-Type": "application/json"
300
+ };
301
+ if (options.sessionToken) {
302
+ headers["X-Headroom-Session"] = options.sessionToken;
303
+ }
304
+ const res = await fetch(url, {
305
+ method: "POST",
306
+ headers,
307
+ body: JSON.stringify({ fields: options.fields })
308
+ });
309
+ if (!res.ok) {
310
+ let code = "UNKNOWN";
311
+ let message = `HTTP ${res.status}`;
312
+ try {
313
+ const errBody = await res.json();
314
+ code = errBody.code || code;
315
+ message = errBody.error || message;
316
+ } catch {
317
+ }
318
+ throw new HeadroomError(res.status, code, `${message} (POST ${url})`);
319
+ }
320
+ return res.json();
321
+ }
166
322
  };
167
323
  export {
324
+ HeadroomAuth,
168
325
  HeadroomClient,
169
326
  HeadroomError
170
327
  };
package/dist/next.cjs ADDED
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/next.ts
22
+ var next_exports = {};
23
+ __export(next_exports, {
24
+ DevRefresh: () => DevRefresh
25
+ });
26
+ module.exports = __toCommonJS(next_exports);
27
+
28
+ // src/next/DevRefresh.tsx
29
+ var import_react = require("react");
30
+ var import_navigation = require("next/navigation");
31
+ function DevRefresh({
32
+ versionUrl,
33
+ apiKey,
34
+ interval = 2e3
35
+ }) {
36
+ const router = (0, import_navigation.useRouter)();
37
+ const lastVersion = (0, import_react.useRef)(null);
38
+ (0, import_react.useEffect)(() => {
39
+ const controller = new AbortController();
40
+ const poll = async () => {
41
+ try {
42
+ const res = await fetch(versionUrl, {
43
+ headers: { "X-Headroom-Key": apiKey },
44
+ signal: controller.signal
45
+ });
46
+ if (!res.ok) return;
47
+ const data = await res.json();
48
+ const version = data.contentVersion;
49
+ if (lastVersion.current !== null && version !== lastVersion.current) {
50
+ router.refresh();
51
+ }
52
+ lastVersion.current = version;
53
+ } catch {
54
+ }
55
+ };
56
+ const id = setInterval(poll, interval);
57
+ poll();
58
+ return () => {
59
+ controller.abort();
60
+ clearInterval(id);
61
+ };
62
+ }, [versionUrl, apiKey, interval, router]);
63
+ return null;
64
+ }
65
+ // Annotate the CommonJS export names for ESM import in node:
66
+ 0 && (module.exports = {
67
+ DevRefresh
68
+ });
@@ -0,0 +1,11 @@
1
+ interface DevRefreshProps {
2
+ /** URL to poll for version changes (full URL to /v1/{site}/version) */
3
+ versionUrl: string;
4
+ /** API key header value */
5
+ apiKey: string;
6
+ /** Poll interval in milliseconds (default: 2000) */
7
+ interval?: number;
8
+ }
9
+ declare function DevRefresh({ versionUrl, apiKey, interval, }: DevRefreshProps): null;
10
+
11
+ export { DevRefresh, type DevRefreshProps };
package/dist/next.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ interface DevRefreshProps {
2
+ /** URL to poll for version changes (full URL to /v1/{site}/version) */
3
+ versionUrl: string;
4
+ /** API key header value */
5
+ apiKey: string;
6
+ /** Poll interval in milliseconds (default: 2000) */
7
+ interval?: number;
8
+ }
9
+ declare function DevRefresh({ versionUrl, apiKey, interval, }: DevRefreshProps): null;
10
+
11
+ export { DevRefresh, type DevRefreshProps };
package/dist/next.js ADDED
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ // src/next/DevRefresh.tsx
4
+ import { useEffect, useRef } from "react";
5
+ import { useRouter } from "next/navigation";
6
+ function DevRefresh({
7
+ versionUrl,
8
+ apiKey,
9
+ interval = 2e3
10
+ }) {
11
+ const router = useRouter();
12
+ const lastVersion = useRef(null);
13
+ useEffect(() => {
14
+ const controller = new AbortController();
15
+ const poll = async () => {
16
+ try {
17
+ const res = await fetch(versionUrl, {
18
+ headers: { "X-Headroom-Key": apiKey },
19
+ signal: controller.signal
20
+ });
21
+ if (!res.ok) return;
22
+ const data = await res.json();
23
+ const version = data.contentVersion;
24
+ if (lastVersion.current !== null && version !== lastVersion.current) {
25
+ router.refresh();
26
+ }
27
+ lastVersion.current = version;
28
+ } catch {
29
+ }
30
+ };
31
+ const id = setInterval(poll, interval);
32
+ poll();
33
+ return () => {
34
+ controller.abort();
35
+ clearInterval(id);
36
+ };
37
+ }, [versionUrl, apiKey, interval, router]);
38
+ return null;
39
+ }
40
+ export {
41
+ DevRefresh
42
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headroom-cms/api",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,6 +19,11 @@
19
19
  "types": "./dist/astro.d.ts",
20
20
  "import": "./dist/astro.js"
21
21
  },
22
+ "./next": {
23
+ "types": "./dist/next.d.ts",
24
+ "import": "./dist/next.js",
25
+ "require": "./dist/next.cjs"
26
+ },
22
27
  "./codegen": {
23
28
  "types": "./dist/codegen.d.ts",
24
29
  "import": "./dist/codegen.js",
@@ -41,9 +46,10 @@
41
46
  "typecheck": "tsc --noEmit"
42
47
  },
43
48
  "peerDependencies": {
49
+ "astro": ">=5",
50
+ "next": ">=14",
44
51
  "react": ">=18",
45
- "react-dom": ">=18",
46
- "astro": ">=5"
52
+ "react-dom": ">=18"
47
53
  },
48
54
  "peerDependenciesMeta": {
49
55
  "react": {
@@ -54,19 +60,23 @@
54
60
  },
55
61
  "astro": {
56
62
  "optional": true
63
+ },
64
+ "next": {
65
+ "optional": true
57
66
  }
58
67
  },
59
68
  "devDependencies": {
60
69
  "@testing-library/jest-dom": "~6.6.3",
61
70
  "@testing-library/react": "~16.3.0",
71
+ "@types/node": "~22.0.0",
62
72
  "@types/react": "~19.1.0",
73
+ "astro": "^5.0.0",
63
74
  "jsdom": "~26.1.0",
75
+ "next": "^16.2.1",
64
76
  "react": "~19.1.0",
65
77
  "react-dom": "~19.1.0",
66
78
  "tsup": "~8.5.0",
67
79
  "typescript": "~5.9.3",
68
- "astro": "^5.0.0",
69
- "@types/node": "~22.0.0",
70
80
  "vitest": "~4.0.18"
71
81
  }
72
82
  }