Skip to content

Commit b82754f

Browse files
committed
sdk: add reportAuthEvent (plane-2 usage-auth -> POST /api/authEvent)
Allow2Api.reportAuthEvent + DeviceDaemon.reportAuthEvent, mirroring logUsage (same pairToken seam, best-effort POST, no offline queue). The consumer decides when to call it; not auto-fired. Server is not idempotent so no replay queue (would double-notify). Bump 2.0.0-alpha.6 -> alpha.8 (v2.0.0-alpha.7 tag already exists with a stale alpha.6 manifest; skip to .8 to avoid collision).
1 parent f822c51 commit b82754f

5 files changed

Lines changed: 226 additions & 1 deletion

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,30 @@ for (const thread of discussions) {
119119
await daemon.replyToFeedback(discussionId, 'This happens every Tuesday.');
120120
```
121121

122+
## Usage-Auth Events (plane-2)
123+
124+
When someone identifies themselves to **start a usage session** on the device — enters the
125+
account/child PIN, passes an offline 6-digit / QR self-auth, or is locally auto-identified —
126+
report it so the server can alert the account holder (and other parents) and keep an audit trail:
127+
128+
```js
129+
// Call this the moment a usage-auth succeeds locally (e.g. on PIN success).
130+
// The device has ALREADY authorized locally (offline-first); this is a
131+
// notification + audit signal, not an authorization.
132+
await daemon.reportAuthEvent({
133+
method: 'pin', // 'pin' | 'offline_code' | 'qr' (anything else => generic 'token')
134+
// childId defaults to the currently selected child
135+
});
136+
137+
daemon.on('auth-event-reported', ({ childId, method }) => {
138+
console.log(`Reported ${method} auth for child ${childId}`);
139+
});
140+
```
141+
142+
Best-effort, exactly like `logUsage`: a single POST over the paired-device seam, with **no offline
143+
queue or replay** — the server does not deduplicate, so a replayed event would double-notify the
144+
parent. The SDK exposes the capability; your enforcer decides when to call it.
145+
122146
## Warnings
123147

124148
The SDK fires progressive warnings as time runs out:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "allow2",
3-
"version": "2.0.0-alpha.6",
3+
"version": "2.0.0-alpha.8",
44
"description": "Allow2 Device SDK — parental controls for apps and devices",
55
"type": "module",
66
"main": "src/index.js",

src/api.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,45 @@ export class Allow2Api {
365365
}),
366366
});
367367
}
368+
369+
// ----------------------------------------------------------------
370+
// Usage-Auth Events (plane-2)
371+
// ----------------------------------------------------------------
372+
373+
/**
374+
* Report a plane-2 usage-auth event: someone identified themselves to START a usage
375+
* session on this paired device (entered the account/child PIN, passed an offline
376+
* 6-digit / QR self-auth, or was locally auto-identified).
377+
*
378+
* This is a NOTIFICATION + audit signal, NOT an authorization. The device has already
379+
* authorized locally (offline-first); the server merely records a login-history row and
380+
* fires the plane-2 notify so the account holder / other parents are alerted (a compromise
381+
* — "someone authed as you on X" — gets caught). Server: `src/controllers/authEvent.ts`,
382+
* `POST /api/authEvent` over the SAME pairToken seam as logUsage.
383+
*
384+
* Best-effort, like logUsage: a single direct POST. The server does NOT dedup — each call
385+
* fires a fresh notify — so callers MUST NOT blindly retry/replay this (a replayed auth
386+
* event would double-notify the parent). There is deliberately no offline store-and-forward.
387+
*
388+
* @param {object} params
389+
* @param {string} params.userId - Account owner id (from pairing credentials)
390+
* @param {number} params.pairId - Paired device id (from pairing credentials)
391+
* @param {string} params.pairToken - Per-pairing secret (from pairing credentials)
392+
* @param {string} [params.childId] - The child/person who authed, when known AND within the device's scope
393+
* @param {string} [params.method] - Auth method: 'pin' | 'offline_code' | 'qr' (anything else => generic 'token')
394+
* @returns {Promise<{ status: string }>}
395+
*/
396+
async reportAuthEvent(params) {
397+
return this._fetch('/api/authEvent', {
398+
method: 'POST',
399+
body: JSON.stringify({
400+
userId: params.userId,
401+
pairId: params.pairId,
402+
pairToken: params.pairToken,
403+
deviceToken: this.token,
404+
childId: params.childId,
405+
method: params.method,
406+
}),
407+
});
408+
}
368409
}

src/daemon.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,50 @@ export class DeviceDaemon extends EventEmitter {
363363
return result;
364364
}
365365

366+
// ----------------------------------------------------------------
367+
// Usage-Auth Events (plane-2)
368+
// ----------------------------------------------------------------
369+
370+
/**
371+
* Report a plane-2 usage-auth: the consuming enforcer calls this WHEN someone identifies
372+
* to start a usage session on this device (account/child PIN accepted, an offline 6-digit /
373+
* QR self-auth verified locally, or a local auto-identify). The server records a
374+
* login-history row and notifies the account holder / other parents. NOTIFICATION + audit
375+
* only — the device has already authorized locally (offline-first).
376+
*
377+
* Best-effort, mirroring logUsage: a single POST with NO offline queue/replay — the server
378+
* does NOT dedup, so a replayed event would double-notify the parent. The SDK exposes the
379+
* capability; the consumer decides exactly when to call it (e.g. on PIN success).
380+
*
381+
* @param {object} [params]
382+
* @param {string} [params.method] - 'pin' | 'offline_code' | 'qr' (anything else => generic 'token')
383+
* @param {number} [params.childId] - The person who authed; defaults to the currently selected child
384+
* @returns {Promise<{ status: string }>}
385+
*/
386+
async reportAuthEvent(params) {
387+
if (!this._credentials || !this._credentials.pairId || !this._credentials.pairToken) {
388+
throw new Error('Device not paired');
389+
}
390+
391+
var opts = params || {};
392+
var childId = opts.childId != null ? opts.childId : this._childId;
393+
394+
var result = await this._api.reportAuthEvent({
395+
userId: this._credentials.userId,
396+
pairId: this._credentials.pairId,
397+
pairToken: this._credentials.pairToken,
398+
childId: childId != null ? childId : undefined,
399+
method: opts.method,
400+
});
401+
402+
this.emit('auth-event-reported', {
403+
childId: childId != null ? childId : null,
404+
method: opts.method || null,
405+
});
406+
407+
return result;
408+
}
409+
366410
// ----------------------------------------------------------------
367411
// Feedback
368412
// ----------------------------------------------------------------

tests/api.authEvent.test.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Tests for the plane-2 usage-auth report path:
3+
* - Allow2Api.reportAuthEvent -> POST /api/authEvent with the pairToken seam + method/childId
4+
* - DeviceDaemon.reportAuthEvent -> pulls creds, defaults childId to the selected child, emits event
5+
*
6+
* The HTTP layer is mocked by stubbing global.fetch, so no live endpoint is touched.
7+
*/
8+
9+
import { test } from 'node:test';
10+
import assert from 'node:assert/strict';
11+
12+
import { Allow2Api } from '../src/api.js';
13+
import { DeviceDaemon } from '../src/daemon.js';
14+
15+
/** Install a fake fetch that records the call and returns a JSON 200. Returns { calls, restore }. */
16+
function stubFetch(responseBody = { status: 'success' }, status = 200) {
17+
const original = global.fetch;
18+
const calls = [];
19+
global.fetch = async function (url, options) {
20+
calls.push({ url, options });
21+
return {
22+
ok: status >= 200 && status < 300,
23+
status,
24+
async json() { return responseBody; },
25+
};
26+
};
27+
return { calls, restore() { global.fetch = original; } };
28+
}
29+
30+
test('Allow2Api.reportAuthEvent POSTs the exact /api/authEvent contract', async () => {
31+
const { calls, restore } = stubFetch({ status: 'success' });
32+
try {
33+
const api = new Allow2Api({ apiUrl: 'https://example.test', vid: 42, token: 'devtok' });
34+
const res = await api.reportAuthEvent({
35+
userId: 'owner-uuid',
36+
pairId: 7,
37+
pairToken: 'pair-secret',
38+
childId: 'child-uuid',
39+
method: 'pin',
40+
});
41+
42+
assert.equal(res.status, 'success');
43+
assert.equal(calls.length, 1);
44+
45+
const { url, options } = calls[0];
46+
assert.equal(url, 'https://example.test/api/authEvent');
47+
assert.equal(options.method, 'POST');
48+
assert.equal(options.headers['Content-Type'], 'application/json');
49+
50+
const body = JSON.parse(options.body);
51+
assert.deepEqual(body, {
52+
userId: 'owner-uuid',
53+
pairId: 7,
54+
pairToken: 'pair-secret',
55+
deviceToken: 'devtok', // pulled from the client's version token (like logUsage)
56+
childId: 'child-uuid',
57+
method: 'pin',
58+
});
59+
} finally {
60+
restore();
61+
}
62+
});
63+
64+
test('Allow2Api.reportAuthEvent surfaces a 401 as an error (best-effort, no swallow)', async () => {
65+
const { restore } = stubFetch({ status: 'error', message: 'Invalid request.' }, 401);
66+
try {
67+
const api = new Allow2Api({ apiUrl: 'https://example.test', vid: 42, token: 'devtok' });
68+
await assert.rejects(
69+
() => api.reportAuthEvent({ userId: 'o', pairId: 1, pairToken: 'p', method: 'pin' }),
70+
(err) => err.status === 401,
71+
);
72+
} finally {
73+
restore();
74+
}
75+
});
76+
77+
test('DeviceDaemon.reportAuthEvent uses stored creds, defaults childId, and emits', async () => {
78+
const daemon = new DeviceDaemon({
79+
activities: [{ id: 1 }],
80+
credentialBackend: { async load() { return null; }, async store() {}, async clear() {} },
81+
childResolver: { resolve() { return null; } },
82+
});
83+
84+
// Simulate a paired + child-selected device without touching the network.
85+
daemon._credentials = { userId: 'owner-uuid', pairId: 7, pairToken: 'pair-secret', children: [] };
86+
daemon._childId = 'selected-child';
87+
88+
let apiParams = null;
89+
daemon._api.reportAuthEvent = async (params) => { apiParams = params; return { status: 'success' }; };
90+
91+
let emitted = null;
92+
daemon.on('auth-event-reported', (e) => { emitted = e; });
93+
94+
const res = await daemon.reportAuthEvent({ method: 'offline_code' });
95+
96+
assert.equal(res.status, 'success');
97+
assert.deepEqual(apiParams, {
98+
userId: 'owner-uuid',
99+
pairId: 7,
100+
pairToken: 'pair-secret',
101+
childId: 'selected-child', // defaulted from the selected child
102+
method: 'offline_code',
103+
});
104+
assert.deepEqual(emitted, { childId: 'selected-child', method: 'offline_code' });
105+
});
106+
107+
test('DeviceDaemon.reportAuthEvent throws when the device is not paired', async () => {
108+
const daemon = new DeviceDaemon({
109+
activities: [{ id: 1 }],
110+
credentialBackend: { async load() { return null; }, async store() {}, async clear() {} },
111+
childResolver: { resolve() { return null; } },
112+
});
113+
daemon._credentials = null;
114+
115+
await assert.rejects(() => daemon.reportAuthEvent({ method: 'pin' }), /not paired/i);
116+
});

0 commit comments

Comments
 (0)