@omen.dog/sdk 1.0.0 → 1.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.
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/child-login.ts
21
+ var child_login_exports = {};
22
+ __export(child_login_exports, {
23
+ ASK_GROWN_UP_STATES: () => ASK_GROWN_UP_STATES,
24
+ CHILD_LOGIN_STATES: () => CHILD_LOGIN_STATES,
25
+ ChildLogin: () => ChildLogin,
26
+ ENTRY_STATES: () => ENTRY_STATES,
27
+ deriveInitialState: () => deriveInitialState,
28
+ displayGroup: () => displayGroup,
29
+ reduce: () => reduce
30
+ });
31
+ module.exports = __toCommonJS(child_login_exports);
32
+
33
+ // src/childLogin/rfc8628.js
34
+ function mapPollResponse(httpStatus, body) {
35
+ body = body || {};
36
+ if (httpStatus >= 200 && httpStatus < 300 && body.access_token) {
37
+ return {
38
+ status: "approved",
39
+ tokens: {
40
+ access_token: body.access_token,
41
+ refresh_token: body.refresh_token,
42
+ token_type: body.token_type || "Bearer",
43
+ expires_in: typeof body.expires_in === "number" ? body.expires_in : void 0,
44
+ scope: body.scope
45
+ },
46
+ rebind: body.rebind || null,
47
+ deviceToken: body.device_token || null
48
+ };
49
+ }
50
+ switch (body.error) {
51
+ case "authorization_pending":
52
+ return { status: "pending" };
53
+ case "slow_down":
54
+ return { status: "slow_down" };
55
+ case "access_denied":
56
+ return { status: "denied" };
57
+ case "expired_token":
58
+ return { status: "expired" };
59
+ default:
60
+ return { status: "error", error: body.error || `http_${httpStatus}` };
61
+ }
62
+ }
63
+ var SLOW_DOWN_INCREMENT_SECONDS = 5;
64
+ var MIN_POLL_INTERVAL_SECONDS = 1;
65
+ function nextInterval(currentInterval, status) {
66
+ const base = Number.isFinite(currentInterval) && currentInterval >= MIN_POLL_INTERVAL_SECONDS ? currentInterval : MIN_POLL_INTERVAL_SECONDS;
67
+ if (status === "slow_down") return base + SLOW_DOWN_INCREMENT_SECONDS;
68
+ return base;
69
+ }
70
+ function isTerminalStatus(status) {
71
+ return status === "approved" || status === "denied" || status === "expired" || status === "error";
72
+ }
73
+
74
+ // src/childLogin/session.js
75
+ var DEFAULT_ACCESS_TTL_SECONDS = 86400;
76
+ var DEFAULT_SKEW_MS = 3e4;
77
+ function seal(tokens, opts) {
78
+ if (!tokens || !tokens.access_token) {
79
+ throw new Error("seal(): tokens.access_token is required");
80
+ }
81
+ const now = opts && typeof opts.now === "number" ? opts.now : Date.now();
82
+ const ttlSeconds = typeof tokens.expires_in === "number" ? tokens.expires_in : DEFAULT_ACCESS_TTL_SECONDS;
83
+ const record = {
84
+ accessToken: tokens.access_token,
85
+ refreshToken: tokens.refresh_token || null,
86
+ scope: tokens.scope || "child",
87
+ accessExpiresAt: now + ttlSeconds * 1e3
88
+ };
89
+ const deviceToken = opts && opts.deviceToken || tokens.device_token || null;
90
+ if (deviceToken) record.deviceToken = deviceToken;
91
+ return record;
92
+ }
93
+ function accessExpired(record, opts) {
94
+ if (!record || typeof record.accessExpiresAt !== "number") return true;
95
+ const now = opts && typeof opts.now === "number" ? opts.now : Date.now();
96
+ const skew = opts && typeof opts.skewMs === "number" ? opts.skewMs : DEFAULT_SKEW_MS;
97
+ return now >= record.accessExpiresAt - skew;
98
+ }
99
+
100
+ // src/childLogin/webhook.js
101
+ var import_node_crypto = require("crypto");
102
+ function verifyWebhook(payload, signature, secret) {
103
+ if (typeof payload !== "string" || !signature || !secret) return false;
104
+ const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
105
+ const a = Buffer.from(expected, "utf8");
106
+ const b = Buffer.from(String(signature), "utf8");
107
+ if (a.length !== b.length) return false;
108
+ return (0, import_node_crypto.timingSafeEqual)(a, b);
109
+ }
110
+ function parseChildLoginApproved(payload, signature, secret) {
111
+ if (!verifyWebhook(payload, signature, secret)) return null;
112
+ try {
113
+ return JSON.parse(payload);
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ // src/childLogin/stateMachine.js
120
+ var CHILD_LOGIN_STATES = [
121
+ "trusted",
122
+ // this device is pre-authorised → one tap
123
+ "known",
124
+ // we know who she is (cached identity) → "ask a grown-up"
125
+ "cold",
126
+ // no hint → type a username
127
+ "pending",
128
+ // a grown-up has been asked; waiting (calm, no timer)
129
+ "approved",
130
+ // she's in
131
+ "denied",
132
+ // a grown-up said "not now"
133
+ "blocked",
134
+ // a grown-up blocked this app
135
+ "paused",
136
+ // her account is paused everywhere
137
+ "expired",
138
+ // the request timed out; let's try again
139
+ "offline"
140
+ // no connection; render her card from cache, can't log in
141
+ ];
142
+ var ASK_GROWN_UP_STATES = ["denied", "blocked", "paused"];
143
+ var ENTRY_STATES = ["trusted", "known", "cold"];
144
+ function deriveInitialState(ctx) {
145
+ ctx = ctx || {};
146
+ if (ctx.online === false) return "offline";
147
+ if (ctx.trustedDevice) return "trusted";
148
+ if (ctx.identity) return "known";
149
+ return "cold";
150
+ }
151
+ var TERMINAL = {
152
+ approved: "approved",
153
+ denied: "denied",
154
+ blocked: "blocked",
155
+ paused: "paused",
156
+ expired: "expired",
157
+ offline: "offline"
158
+ };
159
+ function reduce(state, signal, ctx) {
160
+ if (signal in TERMINAL) return TERMINAL[signal];
161
+ if (signal === "reset") return deriveInitialState(ctx);
162
+ if (signal === "online") return state === "offline" ? deriveInitialState(ctx) : state;
163
+ if (ENTRY_STATES.includes(state) && (signal === "ask" || signal === "pending")) {
164
+ return "pending";
165
+ }
166
+ if (state === "pending" && (signal === "pending" || signal === "slow_down")) {
167
+ return "pending";
168
+ }
169
+ return state;
170
+ }
171
+ function displayGroup(state) {
172
+ if (ENTRY_STATES.includes(state)) return "ready";
173
+ if (state === "pending") return "waiting";
174
+ if (state === "approved") return "approved";
175
+ if (ASK_GROWN_UP_STATES.includes(state)) return "ask-grown-up";
176
+ if (state === "expired") return "expired";
177
+ return "offline";
178
+ }
179
+
180
+ // src/childLogin/index.ts
181
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
182
+ var ChildLogin = class {
183
+ clientId;
184
+ clientSecret;
185
+ webhookSecret;
186
+ baseUrl;
187
+ constructor(options) {
188
+ if (!options?.clientId) throw new Error("ChildLogin: clientId is required");
189
+ this.clientId = options.clientId;
190
+ this.clientSecret = options.clientSecret;
191
+ this.webhookSecret = options.webhookSecret;
192
+ this.baseUrl = (options.baseUrl ?? "https://omen.dog").replace(/\/$/, "");
193
+ }
194
+ /** Start a child device-authorization grant. `scope:'child'` is always sent. */
195
+ async deviceStart(options = {}) {
196
+ const { status, body } = await this.post("/api/oauth/device", {
197
+ client_id: this.clientId,
198
+ scope: "child",
199
+ login_hint: options.loginHint,
200
+ device_id: options.deviceId,
201
+ device_name: options.deviceName,
202
+ device_token: options.deviceToken
203
+ });
204
+ if (status >= 400) throw oauthError(status, body);
205
+ return body;
206
+ }
207
+ /**
208
+ * Tell Omen which child is logging in. With `username` this is the cold-start
209
+ * path (no prior `login_hint`); without it, it re-pings the family for a known
210
+ * child. Always resolves `{ ok: true }` (enumeration-safe) on the cold path.
211
+ */
212
+ async notify(args) {
213
+ const { status, body } = await this.post("/api/oauth/device/notify", {
214
+ device_code: args.device_code,
215
+ username: args.username
216
+ });
217
+ if (status >= 400) throw oauthError(status, body);
218
+ return body;
219
+ }
220
+ /**
221
+ * Poll the token endpoint once. Returns a calm status union — `pending`,
222
+ * `slow_down`, `denied`, `expired`, `error`, or `approved` (with tokens,
223
+ * rebind blob and one-time device_token). Never throws on a normal RFC 8628
224
+ * polling response; only network failures reject.
225
+ */
226
+ async poll(deviceCode) {
227
+ const { status, body } = await this.post("/api/oauth/token", {
228
+ grant_type: DEVICE_GRANT_TYPE,
229
+ device_code: deviceCode,
230
+ client_id: this.clientId
231
+ });
232
+ return mapPollResponse(status, body);
233
+ }
234
+ /**
235
+ * Poll until the grant reaches a terminal state, honouring `interval` and
236
+ * widening it by 5s on every `slow_down` (RFC 8628 §3.5). Prefer driving the
237
+ * flip from the `child.login.approved` webhook; use this as the fallback.
238
+ */
239
+ async pollUntil(deviceCode, options = {}) {
240
+ let interval = options.intervalSeconds ?? 5;
241
+ for (; ; ) {
242
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
243
+ const result = await this.poll(deviceCode);
244
+ if (isTerminalStatus(result.status)) return result;
245
+ options.onStatus?.(result.status);
246
+ interval = nextInterval(interval, result.status);
247
+ await sleep(interval * 1e3, options.signal);
248
+ }
249
+ }
250
+ /**
251
+ * Refresh a child access token. `scope:'child'` is preserved across rotation,
252
+ * and the old refresh token is single-use (Omen rotates it).
253
+ */
254
+ async refresh(refreshToken) {
255
+ const { status, body } = await this.post("/api/oauth/token", {
256
+ grant_type: "refresh_token",
257
+ refresh_token: refreshToken,
258
+ client_id: this.clientId,
259
+ client_secret: this.clientSecret
260
+ });
261
+ if (status >= 400) throw oauthError(status, body);
262
+ return body;
263
+ }
264
+ /** Fetch the consent-gated identity bundle with the child's access token. */
265
+ async childIdentity(accessToken) {
266
+ const res = await fetch(`${this.baseUrl}/api/oauth/child-identity`, {
267
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }
268
+ });
269
+ const body = await res.json().catch(() => ({}));
270
+ if (!res.ok) throw oauthError(res.status, body);
271
+ return body;
272
+ }
273
+ /** Normalise a token grant into a record for your httpOnly session store. */
274
+ seal(tokens, opts) {
275
+ return seal(tokens, opts);
276
+ }
277
+ /** Whether a sealed session's access token needs a refresh before use. */
278
+ accessExpired(record, opts) {
279
+ return accessExpired(record, opts);
280
+ }
281
+ /** Verify a `child.login.approved` webhook signature (uses your `webhookSecret`). */
282
+ verifyWebhook(payload, signature, secret = this.webhookSecret) {
283
+ if (!secret) throw new Error("ChildLogin.verifyWebhook: no webhookSecret configured");
284
+ return verifyWebhook(payload, signature, secret);
285
+ }
286
+ /** Verify + parse a `child.login.approved` webhook. Returns null if the signature is invalid. */
287
+ parseApproval(payload, signature, secret = this.webhookSecret) {
288
+ if (!secret) throw new Error("ChildLogin.parseApproval: no webhookSecret configured");
289
+ return parseChildLoginApproved(payload, signature, secret);
290
+ }
291
+ async post(path, body) {
292
+ const clean = {};
293
+ for (const [k, v] of Object.entries(body)) if (v !== void 0) clean[k] = v;
294
+ const res = await fetch(`${this.baseUrl}${path}`, {
295
+ method: "POST",
296
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
297
+ body: JSON.stringify(clean)
298
+ });
299
+ const text = await res.text();
300
+ let json = {};
301
+ try {
302
+ json = text ? JSON.parse(text) : {};
303
+ } catch {
304
+ json = { error: "invalid_response", raw: text };
305
+ }
306
+ return { status: res.status, body: json };
307
+ }
308
+ };
309
+ function oauthError(status, body) {
310
+ const code = body?.error || `http_${status}`;
311
+ const err = new Error(body?.error_description || code);
312
+ err.status = status;
313
+ err.code = code;
314
+ return err;
315
+ }
316
+ function sleep(ms, signal) {
317
+ return new Promise((resolve, reject) => {
318
+ if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
319
+ const t = setTimeout(resolve, ms);
320
+ signal?.addEventListener("abort", () => {
321
+ clearTimeout(t);
322
+ reject(new DOMException("Aborted", "AbortError"));
323
+ }, { once: true });
324
+ });
325
+ }
326
+ // Annotate the CommonJS export names for ESM import in node:
327
+ 0 && (module.exports = {
328
+ ASK_GROWN_UP_STATES,
329
+ CHILD_LOGIN_STATES,
330
+ ChildLogin,
331
+ ENTRY_STATES,
332
+ deriveInitialState,
333
+ displayGroup,
334
+ reduce
335
+ });
@@ -0,0 +1,18 @@
1
+ import {
2
+ ASK_GROWN_UP_STATES,
3
+ CHILD_LOGIN_STATES,
4
+ ChildLogin,
5
+ ENTRY_STATES,
6
+ deriveInitialState,
7
+ displayGroup,
8
+ reduce
9
+ } from "./chunk-7L3ANE2V.mjs";
10
+ export {
11
+ ASK_GROWN_UP_STATES,
12
+ CHILD_LOGIN_STATES,
13
+ ChildLogin,
14
+ ENTRY_STATES,
15
+ deriveInitialState,
16
+ displayGroup,
17
+ reduce
18
+ };
@@ -0,0 +1,303 @@
1
+ // src/childLogin/stateMachine.js
2
+ var CHILD_LOGIN_STATES = [
3
+ "trusted",
4
+ // this device is pre-authorised → one tap
5
+ "known",
6
+ // we know who she is (cached identity) → "ask a grown-up"
7
+ "cold",
8
+ // no hint → type a username
9
+ "pending",
10
+ // a grown-up has been asked; waiting (calm, no timer)
11
+ "approved",
12
+ // she's in
13
+ "denied",
14
+ // a grown-up said "not now"
15
+ "blocked",
16
+ // a grown-up blocked this app
17
+ "paused",
18
+ // her account is paused everywhere
19
+ "expired",
20
+ // the request timed out; let's try again
21
+ "offline"
22
+ // no connection; render her card from cache, can't log in
23
+ ];
24
+ var ASK_GROWN_UP_STATES = ["denied", "blocked", "paused"];
25
+ var ENTRY_STATES = ["trusted", "known", "cold"];
26
+ function deriveInitialState(ctx) {
27
+ ctx = ctx || {};
28
+ if (ctx.online === false) return "offline";
29
+ if (ctx.trustedDevice) return "trusted";
30
+ if (ctx.identity) return "known";
31
+ return "cold";
32
+ }
33
+ var TERMINAL = {
34
+ approved: "approved",
35
+ denied: "denied",
36
+ blocked: "blocked",
37
+ paused: "paused",
38
+ expired: "expired",
39
+ offline: "offline"
40
+ };
41
+ function reduce(state, signal, ctx) {
42
+ if (signal in TERMINAL) return TERMINAL[signal];
43
+ if (signal === "reset") return deriveInitialState(ctx);
44
+ if (signal === "online") return state === "offline" ? deriveInitialState(ctx) : state;
45
+ if (ENTRY_STATES.includes(state) && (signal === "ask" || signal === "pending")) {
46
+ return "pending";
47
+ }
48
+ if (state === "pending" && (signal === "pending" || signal === "slow_down")) {
49
+ return "pending";
50
+ }
51
+ return state;
52
+ }
53
+ function displayGroup(state) {
54
+ if (ENTRY_STATES.includes(state)) return "ready";
55
+ if (state === "pending") return "waiting";
56
+ if (state === "approved") return "approved";
57
+ if (ASK_GROWN_UP_STATES.includes(state)) return "ask-grown-up";
58
+ if (state === "expired") return "expired";
59
+ return "offline";
60
+ }
61
+
62
+ // src/childLogin/rfc8628.js
63
+ function mapPollResponse(httpStatus, body) {
64
+ body = body || {};
65
+ if (httpStatus >= 200 && httpStatus < 300 && body.access_token) {
66
+ return {
67
+ status: "approved",
68
+ tokens: {
69
+ access_token: body.access_token,
70
+ refresh_token: body.refresh_token,
71
+ token_type: body.token_type || "Bearer",
72
+ expires_in: typeof body.expires_in === "number" ? body.expires_in : void 0,
73
+ scope: body.scope
74
+ },
75
+ rebind: body.rebind || null,
76
+ deviceToken: body.device_token || null
77
+ };
78
+ }
79
+ switch (body.error) {
80
+ case "authorization_pending":
81
+ return { status: "pending" };
82
+ case "slow_down":
83
+ return { status: "slow_down" };
84
+ case "access_denied":
85
+ return { status: "denied" };
86
+ case "expired_token":
87
+ return { status: "expired" };
88
+ default:
89
+ return { status: "error", error: body.error || `http_${httpStatus}` };
90
+ }
91
+ }
92
+ var SLOW_DOWN_INCREMENT_SECONDS = 5;
93
+ var MIN_POLL_INTERVAL_SECONDS = 1;
94
+ function nextInterval(currentInterval, status) {
95
+ const base = Number.isFinite(currentInterval) && currentInterval >= MIN_POLL_INTERVAL_SECONDS ? currentInterval : MIN_POLL_INTERVAL_SECONDS;
96
+ if (status === "slow_down") return base + SLOW_DOWN_INCREMENT_SECONDS;
97
+ return base;
98
+ }
99
+ function isTerminalStatus(status) {
100
+ return status === "approved" || status === "denied" || status === "expired" || status === "error";
101
+ }
102
+
103
+ // src/childLogin/session.js
104
+ var DEFAULT_ACCESS_TTL_SECONDS = 86400;
105
+ var DEFAULT_SKEW_MS = 3e4;
106
+ function seal(tokens, opts) {
107
+ if (!tokens || !tokens.access_token) {
108
+ throw new Error("seal(): tokens.access_token is required");
109
+ }
110
+ const now = opts && typeof opts.now === "number" ? opts.now : Date.now();
111
+ const ttlSeconds = typeof tokens.expires_in === "number" ? tokens.expires_in : DEFAULT_ACCESS_TTL_SECONDS;
112
+ const record = {
113
+ accessToken: tokens.access_token,
114
+ refreshToken: tokens.refresh_token || null,
115
+ scope: tokens.scope || "child",
116
+ accessExpiresAt: now + ttlSeconds * 1e3
117
+ };
118
+ const deviceToken = opts && opts.deviceToken || tokens.device_token || null;
119
+ if (deviceToken) record.deviceToken = deviceToken;
120
+ return record;
121
+ }
122
+ function accessExpired(record, opts) {
123
+ if (!record || typeof record.accessExpiresAt !== "number") return true;
124
+ const now = opts && typeof opts.now === "number" ? opts.now : Date.now();
125
+ const skew = opts && typeof opts.skewMs === "number" ? opts.skewMs : DEFAULT_SKEW_MS;
126
+ return now >= record.accessExpiresAt - skew;
127
+ }
128
+
129
+ // src/childLogin/webhook.js
130
+ import { createHmac, timingSafeEqual } from "crypto";
131
+ function verifyWebhook(payload, signature, secret) {
132
+ if (typeof payload !== "string" || !signature || !secret) return false;
133
+ const expected = createHmac("sha256", secret).update(payload).digest("hex");
134
+ const a = Buffer.from(expected, "utf8");
135
+ const b = Buffer.from(String(signature), "utf8");
136
+ if (a.length !== b.length) return false;
137
+ return timingSafeEqual(a, b);
138
+ }
139
+ function parseChildLoginApproved(payload, signature, secret) {
140
+ if (!verifyWebhook(payload, signature, secret)) return null;
141
+ try {
142
+ return JSON.parse(payload);
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ // src/childLogin/index.ts
149
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
150
+ var ChildLogin = class {
151
+ clientId;
152
+ clientSecret;
153
+ webhookSecret;
154
+ baseUrl;
155
+ constructor(options) {
156
+ if (!options?.clientId) throw new Error("ChildLogin: clientId is required");
157
+ this.clientId = options.clientId;
158
+ this.clientSecret = options.clientSecret;
159
+ this.webhookSecret = options.webhookSecret;
160
+ this.baseUrl = (options.baseUrl ?? "https://omen.dog").replace(/\/$/, "");
161
+ }
162
+ /** Start a child device-authorization grant. `scope:'child'` is always sent. */
163
+ async deviceStart(options = {}) {
164
+ const { status, body } = await this.post("/api/oauth/device", {
165
+ client_id: this.clientId,
166
+ scope: "child",
167
+ login_hint: options.loginHint,
168
+ device_id: options.deviceId,
169
+ device_name: options.deviceName,
170
+ device_token: options.deviceToken
171
+ });
172
+ if (status >= 400) throw oauthError(status, body);
173
+ return body;
174
+ }
175
+ /**
176
+ * Tell Omen which child is logging in. With `username` this is the cold-start
177
+ * path (no prior `login_hint`); without it, it re-pings the family for a known
178
+ * child. Always resolves `{ ok: true }` (enumeration-safe) on the cold path.
179
+ */
180
+ async notify(args) {
181
+ const { status, body } = await this.post("/api/oauth/device/notify", {
182
+ device_code: args.device_code,
183
+ username: args.username
184
+ });
185
+ if (status >= 400) throw oauthError(status, body);
186
+ return body;
187
+ }
188
+ /**
189
+ * Poll the token endpoint once. Returns a calm status union — `pending`,
190
+ * `slow_down`, `denied`, `expired`, `error`, or `approved` (with tokens,
191
+ * rebind blob and one-time device_token). Never throws on a normal RFC 8628
192
+ * polling response; only network failures reject.
193
+ */
194
+ async poll(deviceCode) {
195
+ const { status, body } = await this.post("/api/oauth/token", {
196
+ grant_type: DEVICE_GRANT_TYPE,
197
+ device_code: deviceCode,
198
+ client_id: this.clientId
199
+ });
200
+ return mapPollResponse(status, body);
201
+ }
202
+ /**
203
+ * Poll until the grant reaches a terminal state, honouring `interval` and
204
+ * widening it by 5s on every `slow_down` (RFC 8628 §3.5). Prefer driving the
205
+ * flip from the `child.login.approved` webhook; use this as the fallback.
206
+ */
207
+ async pollUntil(deviceCode, options = {}) {
208
+ let interval = options.intervalSeconds ?? 5;
209
+ for (; ; ) {
210
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
211
+ const result = await this.poll(deviceCode);
212
+ if (isTerminalStatus(result.status)) return result;
213
+ options.onStatus?.(result.status);
214
+ interval = nextInterval(interval, result.status);
215
+ await sleep(interval * 1e3, options.signal);
216
+ }
217
+ }
218
+ /**
219
+ * Refresh a child access token. `scope:'child'` is preserved across rotation,
220
+ * and the old refresh token is single-use (Omen rotates it).
221
+ */
222
+ async refresh(refreshToken) {
223
+ const { status, body } = await this.post("/api/oauth/token", {
224
+ grant_type: "refresh_token",
225
+ refresh_token: refreshToken,
226
+ client_id: this.clientId,
227
+ client_secret: this.clientSecret
228
+ });
229
+ if (status >= 400) throw oauthError(status, body);
230
+ return body;
231
+ }
232
+ /** Fetch the consent-gated identity bundle with the child's access token. */
233
+ async childIdentity(accessToken) {
234
+ const res = await fetch(`${this.baseUrl}/api/oauth/child-identity`, {
235
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }
236
+ });
237
+ const body = await res.json().catch(() => ({}));
238
+ if (!res.ok) throw oauthError(res.status, body);
239
+ return body;
240
+ }
241
+ /** Normalise a token grant into a record for your httpOnly session store. */
242
+ seal(tokens, opts) {
243
+ return seal(tokens, opts);
244
+ }
245
+ /** Whether a sealed session's access token needs a refresh before use. */
246
+ accessExpired(record, opts) {
247
+ return accessExpired(record, opts);
248
+ }
249
+ /** Verify a `child.login.approved` webhook signature (uses your `webhookSecret`). */
250
+ verifyWebhook(payload, signature, secret = this.webhookSecret) {
251
+ if (!secret) throw new Error("ChildLogin.verifyWebhook: no webhookSecret configured");
252
+ return verifyWebhook(payload, signature, secret);
253
+ }
254
+ /** Verify + parse a `child.login.approved` webhook. Returns null if the signature is invalid. */
255
+ parseApproval(payload, signature, secret = this.webhookSecret) {
256
+ if (!secret) throw new Error("ChildLogin.parseApproval: no webhookSecret configured");
257
+ return parseChildLoginApproved(payload, signature, secret);
258
+ }
259
+ async post(path, body) {
260
+ const clean = {};
261
+ for (const [k, v] of Object.entries(body)) if (v !== void 0) clean[k] = v;
262
+ const res = await fetch(`${this.baseUrl}${path}`, {
263
+ method: "POST",
264
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
265
+ body: JSON.stringify(clean)
266
+ });
267
+ const text = await res.text();
268
+ let json = {};
269
+ try {
270
+ json = text ? JSON.parse(text) : {};
271
+ } catch {
272
+ json = { error: "invalid_response", raw: text };
273
+ }
274
+ return { status: res.status, body: json };
275
+ }
276
+ };
277
+ function oauthError(status, body) {
278
+ const code = body?.error || `http_${status}`;
279
+ const err = new Error(body?.error_description || code);
280
+ err.status = status;
281
+ err.code = code;
282
+ return err;
283
+ }
284
+ function sleep(ms, signal) {
285
+ return new Promise((resolve, reject) => {
286
+ if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
287
+ const t = setTimeout(resolve, ms);
288
+ signal?.addEventListener("abort", () => {
289
+ clearTimeout(t);
290
+ reject(new DOMException("Aborted", "AbortError"));
291
+ }, { once: true });
292
+ });
293
+ }
294
+
295
+ export {
296
+ CHILD_LOGIN_STATES,
297
+ ASK_GROWN_UP_STATES,
298
+ ENTRY_STATES,
299
+ deriveInitialState,
300
+ reduce,
301
+ displayGroup,
302
+ ChildLogin
303
+ };
@@ -87,8 +87,8 @@ interface OmenSDK {
87
87
  invite(friendId: string): Promise<void>;
88
88
  presence(text: string | null): void;
89
89
 
90
- // Companion
91
- companion(): Promise<{ name: string; mood: string; traits: string[] } | null>;
90
+ // Daemon
91
+ daemon(): Promise<{ name: string; mood: string; traits: string[] } | null>;
92
92
 
93
93
  // Input
94
94
  input: OmenInputAPI;
@@ -110,7 +110,7 @@ interface OmenSDK {
110
110
  vibrate(type: 'light' | 'medium' | 'heavy' | 'success' | 'error'): void;
111
111
 
112
112
  // Feature detection
113
- has(feature: 'storage' | 'collections' | 'economy' | 'identity' | 'input' | 'social' | 'companion' | 'moderation' | 'leaderboard'): boolean;
113
+ has(feature: 'storage' | 'collections' | 'economy' | 'identity' | 'input' | 'social' | 'daemon' | 'moderation' | 'leaderboard'): boolean;
114
114
  }
115
115
 
116
116
  declare const omen: OmenSDK;