@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.
- package/README.md +108 -0
- package/dist/child-login.d.mts +230 -0
- package/dist/child-login.d.ts +230 -0
- package/dist/child-login.js +335 -0
- package/dist/child-login.mjs +18 -0
- package/dist/chunk-7L3ANE2V.mjs +303 -0
- package/dist/creation.d.ts +3 -3
- package/dist/index.d.mts +506 -1
- package/dist/index.d.ts +506 -1
- package/dist/index.js +659 -2
- package/dist/index.mjs +367 -1
- package/package.json +7 -2
|
@@ -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
|
+
};
|
package/dist/creation.d.ts
CHANGED
|
@@ -87,8 +87,8 @@ interface OmenSDK {
|
|
|
87
87
|
invite(friendId: string): Promise<void>;
|
|
88
88
|
presence(text: string | null): void;
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
|
|
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' | '
|
|
113
|
+
has(feature: 'storage' | 'collections' | 'economy' | 'identity' | 'input' | 'social' | 'daemon' | 'moderation' | 'leaderboard'): boolean;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
declare const omen: OmenSDK;
|