diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2aac60d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main, master, 'v*'] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: package-lock.json + + - run: npm ci + + - name: Run tests + run: | + if [ -d tests ] || [ -d test ]; then + npm test + else + echo "No test directory found, skipping tests" + fi + + - name: Lint + if: matrix.node-version == 20 + run: npx eslint src/ || true diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml deleted file mode 100644 index 3bf0430..0000000 --- a/.github/workflows/npm-publish.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: npm-publish -on: - push: - branches: - - master - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 - with: - node-version: 10 - - run: npm install - # - run: npm test - - uses: JS-DevTools/npm-publish@v1 - with: - token: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2edaf87 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,116 @@ +name: Publish + +on: + push: + tags: ['v*'] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: package-lock.json + + - run: npm ci + + - name: Run tests + run: | + if [ -d tests ] || [ -d test ]; then + npm test + else + echo "No test directory found, skipping tests" + fi + + - name: Determine version metadata + id: meta + run: | + VERSION=${GITHUB_REF#refs/tags/v} + if echo "$VERSION" | grep -q "alpha"; then + echo "dist_tag=alpha" >> $GITHUB_OUTPUT + elif echo "$VERSION" | grep -q "beta"; then + echo "dist_tag=beta" >> $GITHUB_OUTPUT + elif echo "$VERSION" | grep -q "rc"; then + echo "dist_tag=next" >> $GITHUB_OUTPUT + else + echo "dist_tag=latest" >> $GITHUB_OUTPUT + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Verify version matches tag + run: | + PKG_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION=${{ steps.meta.outputs.version }} + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "::error::package.json version ($PKG_VERSION) does not match tag (v$TAG_VERSION)" + exit 1 + fi + + - name: Pack tarball + run: npm pack + + - name: Upload tarball + uses: actions/upload-artifact@v4 + with: + name: npm-tarball + path: '*.tgz' + retention-days: 30 + + publish-npm: + needs: build + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + cache-dependency-path: package-lock.json + + - run: npm ci + + - name: Determine dist-tag + id: tag + run: | + VERSION=${GITHUB_REF#refs/tags/v} + if echo "$VERSION" | grep -q "alpha"; then + echo "dist_tag=alpha" >> $GITHUB_OUTPUT + elif echo "$VERSION" | grep -q "beta"; then + echo "dist_tag=beta" >> $GITHUB_OUTPUT + elif echo "$VERSION" | grep -q "rc"; then + echo "dist_tag=next" >> $GITHUB_OUTPUT + else + echo "dist_tag=latest" >> $GITHUB_OUTPUT + fi + + - name: Publish to npm + run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.dist_tag }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + github-release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download tarball + uses: actions/download-artifact@v4 + with: + name: npm-tarball + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: '*.tgz' + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 9c32364..745684e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,109 +1,19 @@ -# Created by https://www.gitignore.io/api/phpstorm - -### PhpStorm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff: -.idea/workspace.xml -.idea/tasks.xml - -# Sensitive or high-churn files: -.idea/dataSources/ -.idea/dataSources.ids -.idea/dataSources.xml -.idea/dataSources.local.xml -.idea/sqlDataSources.xml -.idea/dynamic.xml -.idea/uiDesigner.xml - -# Gradle: -.idea/gradle.xml -.idea/libraries - -# Mongo Explorer plugin: -.idea/mongoSettings.xml - -## File-based project format: -*.iws - -## Plugin-specific files: - -# IntelliJ -/out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -### PhpStorm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# End of https://www.gitignore.io/api/phpstorm - -# Created by https://www.gitignore.io/api/node - -### Node ### -# Logs -logs +node_modules/ +dist/ +coverage/ +.nyc_output/ +.env +.env.* *.log npm-debug.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules -jspm_packages - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' *.tgz -# Yarn Integrity file -.yarn-integrity - - -# End of https://www.gitignore.io/api/node +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo +*~ +*.iws +*.iml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index f56a700..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -allow2node \ No newline at end of file diff --git a/.idea/allow2node.iml b/.idea/allow2node.iml deleted file mode 100644 index ef62c9c..0000000 --- a/.idea/allow2node.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml deleted file mode 100644 index c8f67ff..0000000 --- a/.idea/jsLibraryMappings.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cc902dd..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 2b6f469..0000000 --- a/.jshintrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "esversion": 6 -} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f3d0d21..0000000 --- a/.travis.yml +++ /dev/null @@ -1,3 +0,0 @@ -language: node_js -node_js: - - "node" diff --git a/README.md b/README.md index c261b70..242058b 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,226 @@ -# Allow2 - Free and Powerful Parental Controls for your apps and devices +# Allow2 SDK for Node.js -[![npm package](https://nodei.co/npm/allow2.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/allow2/) +[![npm version](https://img.shields.io/npm/v/allow2.svg?style=flat-square)](https://www.npmjs.com/package/allow2) +[![npm downloads](https://img.shields.io/npm/dm/allow2.svg?style=flat-square)](https://www.npmjs.com/package/allow2) +[![Node.js CI](https://img.shields.io/github/actions/workflow/status/Allow2/allow2node/ci.yml?style=flat-square)](https://github.com/Allow2/allow2node/actions) -[![Build Status](https://img.shields.io/travis/Allow2/Allow2node/master.svg?style=flat-square)](https://travis-ci.org/Allow2/Allow2node) -[![Coverage](https://img.shields.io/codecov/c/github/allow2node/allow2.svg?style=flat-square)](https://codecov.io/github/Allow2/Allow2node?branch=master) -[![Coverage](https://img.shields.io/coveralls/allow2/allow2.svg?style=flat-square)](https://coveralls.io/r/Allow2/Allow2node) -[![Dependency Status](https://img.shields.io/david/allow2/allow2.svg?style=flat-square)](https://david-dm.org/Allow2/Allow2node) -[![Known Vulnerabilities](https://snyk.io/test/npm/allow2/badge.svg?style=flat-square)](https://snyk.io/test/npm/allow2) -[![Join the chat at https://gitter.im/Allow2/Allow2node](https://badges.gitter.im/Allow2/Allow2node.svg)](https://gitter.im/Allow2/Allow2node?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +> **Developer Resources** -- The [Allow2 MCP Server](https://mcp.allow2.com) provides comprehensive API documentation, integration guides, architecture overviews, and interactive examples. Connect it to your AI coding assistant for the best development experience. **Start there.** -Allow2 makes it easy to add parental controls to your apps. +Official Allow2 Parental Freedom **Device SDK** for Node.js — for software that runs on a child's device (games, desktop apps, IoT, set-top boxes). -1. [Why should you use Allow2?](#why-should-you-use-Allow2) -2. [Installation](#installation) -3. [Concepts](#concepts) -4. [Usage](#usage) -5. [Playing](#playing) +> **Building a web service with user accounts?** Use [`allow2-service`](https://github.com/Allow2/Allow2node-service) (the [Service SDK](https://www.npmjs.com/package/allow2-service)) instead. Device and Service SDKs are separate packages. -# Why should you use Allow2? +| | | +|---|---| +| **Package** | `allow2` | +| **Targets** | Node.js 18+ (ESM, `"type": "module"`) | +| **Dependencies** | None (uses native `fetch`) | +| **Language** | JavaScript (ES Modules) | -We remove friction and barriers to entry. Parents are scared of technology and not being able to avoid screen-time addiction or have their children gaming instead of doing homework and chores. GIVE THEM the ability to be in control which will INCREASE your user base and INCREASE your paying users. +## Installation -But good parental controls are incredibly complex and difficult to get correct, and for a parent there is nothing worse than having to log in or open up yet another parental control interface on another app and reconfigure it every other day. - -Allow2 solves these problems once and for all: +```bash +npm install allow2 +``` -1. Leverage the powerful Allow2 platform completely for free (no developer licensing fees) -2. Add parental controls in a matter of hours and don't worry about implementing heaps of interfaces. -3. Show your community responsibility and support parents, this helps to bring more users to your apps. +## Quick Start -Really, you should be able to add extensive and powerful parental controls to your apps in a matter of hours or (at most) a couple of days. +```js +import { DeviceDaemon } from 'allow2'; +import { PlaintextBackend } from 'allow2/credentials/plaintext.js'; + +const daemon = new DeviceDaemon({ + deviceName: 'Living Room PC', + activities: [{ id: 1 }, { id: 8 }], // Internet + Screen Time + credentialBackend: new PlaintextBackend(), + childResolver: { resolve: (children) => null }, // interactive selection +}); -The best part is it's not only powerful, but completely free for developers and manufacturers. +daemon.on('pairing-required', ({ pin, qrUrl }) => { + console.log(`Enter PIN: ${pin}`); +}); +daemon.on('child-select-required', ({ children }) => { + console.log('Select a child:', children.map(c => c.name)); +}); +daemon.on('warning', ({ level, remaining }) => { + console.log(`Warning: ${level}, ${remaining}s left`); +}); +daemon.on('soft-lock', () => console.log('Time is up!')); -# Try it out +await daemon.start(); +await daemon.openApp(); // triggers pairing if unpaired +``` -Test Allow2 in your browser: [https://npm.runkit.com/allow2](https://npm.runkit.com/allow2) +## Modules -Use the Allow2 server to create a parent account and add children: [https://app.allow2.com](https://app.allow2.com) +| Module | File | Purpose | +|--------|------|---------| +| **Daemon** | `daemon.js` | Main orchestrator managing the full device lifecycle | +| **API Client** | `api.js` | Fetch-based REST client for all Allow2 endpoints | +| **Pairing** | `pairing.js` | Express-based pairing wizard (QR code + PIN display) | +| **Child Shield** | `child-shield.js` | PIN hashing (SHA-256 + salt), rate limiting, session timeout | +| **Checker** | `checker.js` | Permission check loop with per-activity enforcement and stacking | +| **Warnings** | `warnings.js` | Configurable progressive warning scheduler | +| **Offline** | `offline.js` | Response cache, grace period, deny-by-default fallback | +| **Request** | `request.js` | Request flow (more time, day type change, ban lift) with polling | +| **Updates** | `updates.js` | Poll for children, quota, ban, and day type changes | +| **Credentials** | `credentials/` | `PlaintextBackend` default + pluggable `createBackend()` factory | +| **Child Resolvers** | `child-resolver/` | OS username mapping (`linux-user.js`) and interactive selector | -# Installation +## Permission Checks ```js -npm install --save allow2 +// The check loop runs automatically once a child is selected. +// Listen for results: +daemon.on('check-result', (result) => { + for (const [id, activity] of Object.entries(result.activities)) { + console.log(`${id}: allowed=${activity.allowed}, remaining=${activity.remaining}s`); + } + console.log(`Today: ${result.dayTypes.today}, Tomorrow: ${result.dayTypes.tomorrow}`); +}); ``` -# Concepts +## Request More Time + +```js +// Child requests 30 more minutes of gaming +const { requestId, statusSecret } = await daemon.requestMoreTime({ + activity: 3, // Gaming + duration: 30, // minutes + message: "Can I please have more time? Almost done with this level.", +}); -The APIs for Allow2 operate in 2 modes. +// Poll until parent responds +const status = await daemon.pollRequestStatus(requestId, statusSecret); -## DEVICE MODE +if (status.status === 'approved') { + console.log(`Approved! ${status.duration} extra minutes.`); +} else if (status.status === 'denied') { + console.log('Request denied.'); +} +``` -In this first API mode, you pair the device or app with the Allow2 Service and use that pairing credential to report all usage. +## Feedback -This mode is used for toasters, lights, routers, gaming consoles, apps, etc. Things that are typically owned by one account or family. +```js +// Submit feedback +const { discussionId } = await daemon.submitFeedback({ + category: 'not_working', + message: 'The block screen appears even when time is remaining.', +}); -To use this mode, you pair the device/app FIRST and you supply the pairing credentials to the "check" call. +// Load feedback threads +const { discussions } = await daemon.loadDeviceFeedback(); +for (const thread of discussions) { + console.log(`[${thread.category}] ${thread.status} - ${thread.messageCount} messages`); +} +// Reply to a thread +await daemon.replyToFeedback(discussionId, 'This happens every Tuesday.'); +``` -## SERVICE MODE +## Usage-Auth Events (plane-2) -Inthis second API mode, you instead typically only install it once, and it is for service-based systems, social media platforms, web sites, etc. These are not owned by any one family or account, but are used by potentially thousands of people that may not be related (Facebook, Twitter, www.google.com, youtube, etc). +When someone identifies themselves to **start a usage session** on the device — enters the +account/child PIN, passes an offline 6-digit / QR self-auth, or is locally auto-identified — +report it so the server can alert the account holder (and other parents) and keep an audit trail: -To use this mode, you create a key/secret pair on the Allow2 Developer portal and supply the serviceID and Secret Key to the "check" call. +```js +// Call this the moment a usage-auth succeeds locally (e.g. on PIN success). +// The device has ALREADY authorized locally (offline-first); this is a +// notification + audit signal, not an authorization. +await daemon.reportAuthEvent({ + method: 'pin', // 'pin' | 'offline_code' | 'qr' (anything else => generic 'token') + // childId defaults to the currently selected child +}); +daemon.on('auth-event-reported', ({ childId, method }) => { + console.log(`Reported ${method} auth for child ${childId}`); +}); +``` -# Usage +Best-effort, exactly like `logUsage`: a single POST over the paired-device seam, with **no offline +queue or replay** — the server does not deduplicate, so a replayed event would double-notify the +parent. The SDK exposes the capability; your enforcer decides when to call it. -There are essentially 2 steps to usage: +## Warnings -1. pair with the platform (see the example) -2. use the "check" routine to check permissions and log usage. +The SDK fires progressive warnings as time runs out: -With Allow2 all you have to do to check if something can be used and record it's usage is: +``` +15 min -> 5 min -> 1 min -> 30 sec -> 10 sec -> BLOCKED +``` ```js -var allow2 = require('allow2'); -allow2.check({ - userId: 1, - pairToken: "98hbieg87-ilulieugil-dilufkucy", - deviceToken: "iug893-kjg-fiug23", - tz: 'Australia/Brisbane', // note, timezone is crucial to correctly calculate allowed times and day types - childId: 10, - activities: [ - { id: 1, log: true }, // 1 = Internet - { id: 2, log: true }, // 2 = Conputer - { id: 3, log: true }, // 3 = Gaming - { id: 8, log: true } // 8 = Screen Time - see all "activities" at https://developer.allow2.com - ], - log: true // use this to say you want usage recorded (logged) as well as checked - //, staging: true // specify staging environment (BETA - use at your OWN risk) -}, function(err, result) { - ... // this is the callback with results of the check +daemon.on('warning', ({ level, remaining }) => { + // level: '15min', '5min', '1min', '30sec', '10sec' + showWarningBanner(`${remaining} seconds remaining`); +}); + +daemon.on('soft-lock', () => { + showBlockScreen(); }); ``` -Callback: +## Credential Storage -```js -function callback(err, result) { - // result = { allowed: true, - activities: { '7': [Object], ... }, - dayTypes: { today: [Object], tomorrow: [Object] } - // }, - if (err) { - // can look into the err object to determine what action to take, do you allow usage? - // (Children may deliberately kill internet to get free use), or do you require access? - // Or do you give a grace period and cache the last response while offline? - return; - } - // result.allowed: true/false // this is the macro feedback on approved/denied. - // you can dig in to the result.activities object to see the restrictions and bans/etc on each activity, - // determine when times will run out or next be available and how much quota is remaining for each activity. - - // result.dayTypes provides details on what day type it is today and tomorrow. -} +The SDK uses a pluggable credential backend. The default `PlaintextBackend` writes to `~/.allow2/credentials.json` with `chmod 600`. + +For production, implement the interface with platform-specific secure storage: +```js +const myBackend = { + async load() { + // Return { userId, pairId, pairToken, children } or null + }, + async store(credentials) { + // Persist credentials (Keychain, Secret Service, DPAPI, etc.) + }, + async clear() { + // Remove stored credentials + }, +}; ``` -# Playing +## Target Platforms + +| Platform | Notes | +|----------|-------| +| **Linux** | allow2linux daemon (Steam Deck, desktop) | +| **macOS** | Desktop apps, Electron | +| **Windows** | Desktop apps, Electron | +| **Embedded** | Any device with Node.js 18+ | +| **Server** | Service-side integrations | + +## Architecture + +The SDK follows the Allow2 Device Operational Lifecycle: + +1. **Pairing** (one-time) -- QR code or 6-digit PIN, parent never enters credentials on device +2. **Child Identification** (every session) -- OS account mapping, child selector with PIN, or verification via the child's Allow2 app (iOS/Android) or web portal +3. **Parent Access** -- parent verifies via their Allow2 app (iOS/Android), web portal, or locally with PIN for unrestricted mode +4. **Permission Checks** (continuous) -- POST to service URL every 30-60s with `log: true` +5. **Warnings & Countdowns** -- progressive alerts before blocking +6. **Requests** -- child requests changes (more time, day type change, ban lift), parent approves/denies from their phone (also works offline via voice codes) +7. **Feedback** -- bug reports and feature requests sent directly to you, the developer + +All API communication uses native `fetch` with no external dependencies. The check endpoint POSTs to the **service URL** (`service.allow2.com`), while all other endpoints use the **API URL** (`api.allow2.com`). + +Environment overrides via `ALLOW2_API_URL`, `ALLOW2_VID`, and `ALLOW2_TOKEN` environment variables. + +## Offline Operation + +Once a device is paired, Allow2 remains fully configurable even when the device is offline. The parent can still manage the child's limits, approve requests, and change settings from their Allow2 app or the web portal -- changes are synchronised the next time the device connects. + +On the device side: + +- **Cached permissions** -- the last successful check result is cached locally. During a configurable grace period (default 5 minutes), the device continues to enforce the cached result. +- **Deny-by-default** -- after the grace period expires without connectivity, all activities are blocked. This prevents children from bypassing controls by disabling Wi-Fi or enabling airplane mode. +- **Requests (offline)** -- children can still submit all request types (more time, day type change, ban lift) even when the device is offline. The request is presented to the parent via their app or a voice code that can be read over the phone. The parent approves or denies from their end, and the device applies the result when connectivity resumes (or immediately via a voice code response entered locally). +- **Automatic resync** -- when the device comes back online, it immediately fetches the latest permissions, processes any queued requests, and resumes normal check polling. + +This means a paired device is never "unmanageable" -- the parent always has control, regardless of the device's network state. + +## License -The best way to 'play' with the sdk and get familiar with it is via the runkit: -https://npm.runkit.com/allow2 +See [LICENSE](LICENSE) for details. diff --git a/a2pair.js b/a2pair.js deleted file mode 100755 index 4bf945e..0000000 --- a/a2pair.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -var co = require('co'); -var prompt = require('co-prompt'); -var program = require('commander'); -var request = require('request'); -var allow2 = require('./index.js'); - -program - .arguments(' ') - .option('-u, --username ', 'The allow2 parent account user to authenticate as') - .option('-p, --password ', 'The allow2 parent account user\'s password') - .option('-s, --staging', 'Use the staging server, not production') - .action(pair) - .parse(process.argv); - -function pair(deviceToken, deviceName) { - /*co(function *() { - var username = yield prompt('username: '); - var password = yield prompt.password('password: '); - console.log('user: %s pass: %s file: %s', username, password, deviceName); - });*/ - - //console.log('user: %s pass: %s file: %s', program.username, program.password, deviceName); - allow2.pair({ - user: program.username, - pass: program.password, - staging: program.staging, - deviceToken: deviceToken, - deviceName: deviceName - }, function(err, response){ - console.log(err, response); - }); -} diff --git a/certs/AddTrustExternalCARoot.crt b/certs/AddTrustExternalCARoot.crt deleted file mode 100644 index 20585f1..0000000 --- a/certs/AddTrustExternalCARoot.crt +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIENjCCAx6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBvMQswCQYDVQQGEwJTRTEU -MBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFs -IFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290 -MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFowbzELMAkGA1UEBhMCU0Ux -FDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRUcnVzdCBFeHRlcm5h -bCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0EgUm9v -dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALf3GjPm8gAELTngTlvt -H7xsD821+iO2zt6bETOXpClMfZOfvUq8k+0DGuOPz+VtUFrWlymUWoCwSXrbLpX9 -uMq/NzgtHj6RQa1wVsfwTz/oMp50ysiQVOnGXw94nZpAPA6sYapeFI+eh6FqUNzX -mk6vBbOmcZSccbNQYArHE504B4YCqOmoaSYYkKtMsE8jqzpPhNjfzp/haW+710LX -a0Tkx63ubUFfclpxCDezeWWkWaCUN/cALw3CknLa0Dhy2xSoRcRdKn23tNbE7qzN -E0S3ySvdQwAl+mG5aWpYIxG3pzOPVnVZ9c0p10a3CitlttNCbxWyuHv77+ldU9U0 -WicCAwEAAaOB3DCB2TAdBgNVHQ4EFgQUrb2YejS0Jvf6xCZU7wO94CTLVBowCwYD -VR0PBAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wgZkGA1UdIwSBkTCBjoAUrb2YejS0 -Jvf6xCZU7wO94CTLVBqhc6RxMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRU -cnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsx -IjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3SCAQEwDQYJKoZIhvcN -AQEFBQADggEBALCb4IUlwtYj4g+WBpKdQZic2YR5gdkeWxQHIzZlj7DYd7usQWxH -YINRsPkyPef89iYTx4AWpb9a/IfPeHmJIZriTAcKhjW88t5RxNKWt9x+Tu5w/Rw5 -6wwCURQtjr0W4MHfRnXnJK3s9EK0hZNwEGe6nQY1ShjTK3rMUUKhemPR5ruhxSvC -Nr4TDea9Y355e6cJDUCrat2PisP29owaQgVR1EX1n6diIWgVIEM8med8vSTYqZEX -c4g/VhsxOBi0cQ+azcgOno4uG+GMmIPLHzHxREzGBHNJdmAPx/i9F4BrLunMTA5a -mnkPIAou1Z5jJh5VkpTYghdae9C8x49OhgQ= ------END CERTIFICATE----- diff --git a/certs/COMODORSAAddTrustCA.crt b/certs/COMODORSAAddTrustCA.crt deleted file mode 100644 index 6fbdf52..0000000 --- a/certs/COMODORSAAddTrustCA.crt +++ /dev/null @@ -1,32 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFdDCCBFygAwIBAgIQJ2buVutJ846r13Ci/ITeIjANBgkqhkiG9w0BAQwFADBv -MQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFk -ZFRydXN0IEV4dGVybmFsIFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBF -eHRlcm5hbCBDQSBSb290MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFow -gYUxCzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO -BgNVBAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNPTU9ETyBDQSBMaW1pdGVkMSswKQYD -VQQDEyJDT01PRE8gUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkq -hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAkehUktIKVrGsDSTdxc9EZ3SZKzejfSNw -AHG8U9/E+ioSj0t/EFa9n3Byt2F/yUsPF6c947AEYe7/EZfH9IY+Cvo+XPmT5jR6 -2RRr55yzhaCCenavcZDX7P0N+pxs+t+wgvQUfvm+xKYvT3+Zf7X8Z0NyvQwA1onr -ayzT7Y+YHBSrfuXjbvzYqOSSJNpDa2K4Vf3qwbxstovzDo2a5JtsaZn4eEgwRdWt -4Q08RWD8MpZRJ7xnw8outmvqRsfHIKCxH2XeSAi6pE6p8oNGN4Tr6MyBSENnTnIq -m1y9TBsoilwie7SrmNnu4FGDwwlGTm0+mfqVF9p8M1dBPI1R7Qu2XK8sYxrfV8g/ -vOldxJuvRZnio1oktLqpVj3Pb6r/SVi+8Kj/9Lit6Tf7urj0Czr56ENCHonYhMsT -8dm74YlguIwoVqwUHZwK53Hrzw7dPamWoUi9PPevtQ0iTMARgexWO/bTouJbt7IE -IlKVgJNp6I5MZfGRAy1wdALqi2cVKWlSArvX31BqVUa/oKMoYX9w0MOiqiwhqkfO -KJwGRXa/ghgntNWutMtQ5mv0TIZxMOmm3xaG4Nj/QN370EKIf6MzOi5cHkERgWPO -GHFrK+ymircxXDpqR+DDeVnWIBqv8mqYqnK8V0rSS527EPywTEHl7R09XiidnMy/ -s1Hap0flhFMCAwEAAaOB9DCB8TAfBgNVHSMEGDAWgBStvZh6NLQm9/rEJlTvA73g -JMtUGjAdBgNVHQ4EFgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQD -AgGGMA8GA1UdEwEB/wQFMAMBAf8wEQYDVR0gBAowCDAGBgRVHSAAMEQGA1UdHwQ9 -MDswOaA3oDWGM2h0dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9BZGRUcnVzdEV4dGVy -bmFsQ0FSb290LmNybDA1BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6 -Ly9vY3NwLnVzZXJ0cnVzdC5jb20wDQYJKoZIhvcNAQEMBQADggEBAGS/g/FfmoXQ -zbihKVcN6Fr30ek+8nYEbvFScLsePP9NDXRqzIGCJdPDoCpdTPW6i6FtxFQJdcfj -Jw5dhHk3QBN39bSsHNA7qxcS1u80GH4r6XnTq1dFDK8o+tDb5VCViLvfhVdpfZLY -Uspzgb8c8+a4bmYRBbMelC1/kZWSWfFMzqORcUx8Rww7Cxn2obFshj5cqsQugsv5 -B5a6SE2Q8pTIqXOi6wZ7I53eovNNVZ96YUWYGGjHXkBrI/V5eu+MtWuLt29G9Hvx -PUsE2JOAWVrgQSQdso8VYFhH2+9uRv0V9dlfmrPb2LjkQLPNlzmuhbsdjrzch5vR -pu/xO28QOG8= ------END CERTIFICATE----- diff --git a/certs/COMODORSADomainValidationSecureServerCA.crt b/certs/COMODORSADomainValidationSecureServerCA.crt deleted file mode 100644 index d81d72a..0000000 --- a/certs/COMODORSADomainValidationSecureServerCA.crt +++ /dev/null @@ -1,35 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIGCDCCA/CgAwIBAgIQKy5u6tl1NmwUim7bo3yMBzANBgkqhkiG9w0BAQwFADCB -hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G -A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV -BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTQwMjEy -MDAwMDAwWhcNMjkwMjExMjM1OTU5WjCBkDELMAkGA1UEBhMCR0IxGzAZBgNVBAgT -EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR -Q09NT0RPIENBIExpbWl0ZWQxNjA0BgNVBAMTLUNPTU9ETyBSU0EgRG9tYWluIFZh -bGlkYXRpb24gU2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBAI7CAhnhoFmk6zg1jSz9AdDTScBkxwtiBUUWOqigwAwCfx3M28Sh -bXcDow+G+eMGnD4LgYqbSRutA776S9uMIO3Vzl5ljj4Nr0zCsLdFXlIvNN5IJGS0 -Qa4Al/e+Z96e0HqnU4A7fK31llVvl0cKfIWLIpeNs4TgllfQcBhglo/uLQeTnaG6 -ytHNe+nEKpooIZFNb5JPJaXyejXdJtxGpdCsWTWM/06RQ1A/WZMebFEh7lgUq/51 -UHg+TLAchhP6a5i84DuUHoVS3AOTJBhuyydRReZw3iVDpA3hSqXttn7IzW3uLh0n -c13cRTCAquOyQQuvvUSH2rnlG51/ruWFgqUCAwEAAaOCAWUwggFhMB8GA1UdIwQY -MBaAFLuvfgI9+qbxPISOre44mOzZMjLUMB0GA1UdDgQWBBSQr2o6lFoL2JDqElZz -30O0Oija5zAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNV -HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGwYDVR0gBBQwEjAGBgRVHSAAMAgG -BmeBDAECATBMBgNVHR8ERTBDMEGgP6A9hjtodHRwOi8vY3JsLmNvbW9kb2NhLmNv -bS9DT01PRE9SU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDBxBggrBgEFBQcB -AQRlMGMwOwYIKwYBBQUHMAKGL2h0dHA6Ly9jcnQuY29tb2RvY2EuY29tL0NPTU9E -T1JTQUFkZFRydXN0Q0EuY3J0MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21v -ZG9jYS5jb20wDQYJKoZIhvcNAQEMBQADggIBAE4rdk+SHGI2ibp3wScF9BzWRJ2p -mj6q1WZmAT7qSeaiNbz69t2Vjpk1mA42GHWx3d1Qcnyu3HeIzg/3kCDKo2cuH1Z/ -e+FE6kKVxF0NAVBGFfKBiVlsit2M8RKhjTpCipj4SzR7JzsItG8kO3KdY3RYPBps -P0/HEZrIqPW1N+8QRcZs2eBelSaz662jue5/DJpmNXMyYE7l3YphLG5SEXdoltMY -dVEVABt0iN3hxzgEQyjpFv3ZBdRdRydg1vs4O2xyopT4Qhrf7W8GjEXCBgCq5Ojc -2bXhc3js9iPc0d1sjhqPpepUfJa3w/5Vjo1JXvxku88+vZbrac2/4EjxYoIQ5QxG -V/Iz2tDIY+3GH5QFlkoakdH368+PUq4NCNk+qKBR6cGHdNXJ93SrLlP7u3r7l+L4 -HyaPs9Kg4DdbKDsx5Q5XLVq4rXmsXiBmGqW5prU5wfWYQ//u+aen/e7KJD2AFsQX -j4rBYKEMrltDR5FL1ZoXX/nUh8HCjLfn4g8wGTeGrODcQgPmlKidrv0PJFGUzpII -0fxQ8ANAe4hZ7Q7drNJ3gjTcBpUC2JD5Leo31Rpg0Gcg19hCC0Wvgmje3WYkN5Ap -lBlGGSW4gNfL1IYoakRwJiNiqZ+Gb7+6kHDSVneFeO/qJakXzlByjAA6quPbYzSf -+AZxAeKCINT+b72x ------END CERTIFICATE----- diff --git a/index.js b/index.js deleted file mode 100755 index 9641307..0000000 --- a/index.js +++ /dev/null @@ -1,112 +0,0 @@ -/******************** - * Allow 2 web-based cloud api node module - * - * This is an example of how to call the web api. It is copyright Allow2, but opensourced to encourage porting and adoption by the community. - */ - -var request = require('request'); - -const apiUrl = 'https://api.allow2.com'; -const stagingUrl = 'https://staging-api.allow2.com'; - -var exports = {}; - -/** - * Set up a device pairing with the allow2 service. - * Device pairings are used for multiple devices of the same type (wemo light switches, playstation consoles, PC games, etc. - * Services use the Allow2 service differently as they tend to have one real "instance" - ie: facebook, twitter, etc. - * - * @name pair - * @static - * @param {Object} params - * @param callback - * @example - * - * allow2.pair({ - * user: "fred@gmail.com", - * pass: "my super secret password", - * deviceToken: "346-34269hcubi-187gigi8g-14i3ugkug", - * deviceName: "Fred's iPhone" - * staging: (set this to any value to use the staging server, if empty/undefined/missing then it will use production) - * }, function(err, result) { - * console.log(result); - * }); - */ -exports.pair = function pair(params, callback) { - //console.log('user: %s pass: %s file: %s', program.username, program.password, deviceName); - request({ - url: ( params.staging ? stagingUrl : apiUrl ) + '/api/pairDevice', - method: 'POST', - json: true, - body: { - user: params.user, - pass: params.pass, - deviceToken: params.deviceToken, - name: params.deviceName - } - }, function(err, httpResponse, body) { - if (err) { - return callback(err); - } - return callback(null, body); - }); -}; - -/** - * Check routine, call this to get an immediate response on accessibility and record usage. This is fail-resistant as it uses a cached last value - * and can be called as often as you like, but it will rate-limit calls to the web server regardless. - * In the event it cannot connect, it will allow grace access blah blah... - * - * @name check - * @static - * @param {Object} params - An object containing the userid and pairid for checking/logging and various settings - * @param callback - * @example - * - * allow2.check({ - ******************* OPTION 1: Device Check - * userId: 1, - * pairToken: "98hbieg87-ilulieugil-dilufkucy", - * deviceToken: "iug893-kjg-fiug23", - ******************* OPTION 2: Service Check - * token: 4ecf0c4e-defd-4c22-8e7c-2b3620053fa8, - * secret: 4ecf0c4e-defd-4c22-8e7c-2b3620053fa8, - ******************* - * tz: 'Australia/Brisbane', // note: timezone is crucial to correctly calculate allowed times and day types - * childId: 10, - * activities: [ 1, 2 ], - * log: true, // note: if set, record the usage (log it) and deduct quota, otherwise it only checks the access is permitted. - * staging: true // note: if set, use the staging environment, not production - * }, function(err, result) { - * console.log(result); - * }); - */ -exports.check = function pair(params, callback) { - // first simple version will always wait for a response - // if still valid in cache, don't check again - /*async.auto({ - cached: - })*/ - - request({ - url: ( params.staging ? stagingUrl : apiUrl ) + '/serviceapi/check', - method: 'POST', - json: true, - body: { - userId: params.userId, - pairId: params.pairId, - deviceToken: params.deviceToken, - tz: params.tz, - childId: params.childId, - activities: params.activities, - log: (params.log == undefined ? true : params.log) - } - }, function(err, httpResponse, body) { - if (err) { - return callback(err); - } - return callback(null, body); - }); -}; - -module.exports = exports; diff --git a/package-lock.json b/package-lock.json index e4132b8..d3ca0e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,672 +1,831 @@ { "name": "allow2", - "version": "1.0.1", - "lockfileVersion": 1, + "version": "2.0.0-alpha.6", + "lockfileVersion": 3, "requires": true, - "dependencies": { - "@coolaj86/urequest": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.7.tgz", - "integrity": "sha512-PPrVYra9aWvZjSCKl/x1pJ9ZpXda1652oJrPBYy5rQumJJMkmTBN3ux+sK2xAUwVvv2wnewDlaQaHLxLwSHnIA==" - }, - "ajv": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", - "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", - "requires": { - "co": "^4.6.0", - "fast-deep-equal": "^1.0.0", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.3.0" - } - }, - "asn1": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + "packages": { + "": { + "name": "allow2", + "version": "2.0.0-alpha.6", + "license": "SEE LICENSE IN LICENSE FILE", + "dependencies": { + "express": "^4.21.0" + }, + "engines": { + "node": ">=18.0.0" + } }, - "async": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", - "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", - "requires": { - "lodash": "^4.14.0" + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" } }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } }, - "aws4": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", - "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } }, - "bcrypt-pbkdf": { + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", - "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", - "optional": true, - "requires": { - "tweetnacl": "^0.14.3" + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" } }, - "boom": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", - "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", - "requires": { - "hoek": "4.x.x" + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "brace-expansion": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", - "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } }, - "cli": { + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "integrity": "sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ=", - "dev": true, - "requires": { - "exit": "0.1.2", - "glob": "^7.1.1" + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" } }, - "co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "co-prompt": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/co-prompt/-/co-prompt-1.0.0.tgz", - "integrity": "sha1-+zcOntrEhXayenMv5dfyHZ/G5vY=", - "requires": { - "keypress": "~0.2.1" + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "combined-stream": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", - "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", - "requires": { - "delayed-stream": "~1.0.0" + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" } }, - "commander": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", - "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==" + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } }, - "console-browserify": { + "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", - "dev": true, - "requires": { - "date-now": "^0.1.4" + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "cryptiles": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", - "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", - "requires": { - "boom": "5.x.x" + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { - "boom": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", - "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", - "requires": { - "hoek": "4.x.x" - } - } + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "requires": { - "assert-plus": "^1.0.0" + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, - "dom-serializer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", - "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", - "dev": true, - "requires": { - "domelementtype": "~1.1.1", - "entities": "~1.1.1" + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "domelementtype": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", - "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", - "dev": true + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, - "entities": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", - "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", - "dev": true + { + "type": "consulting", + "url": "https://feross.org/support" } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" } }, - "domelementtype": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", - "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", - "dev": true - }, - "domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", - "dev": true, - "requires": { - "domelementtype": "1" - } - }, - "domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", - "dev": true, - "requires": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, - "ecc-jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", - "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", - "optional": true, - "requires": { - "jsbn": "~0.1.0" - } - }, - "entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", - "dev": true - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true - }, - "extend": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", - "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" - }, - "fast-deep-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", - "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, - "fast-json-stable-stringify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, - "form-data": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.1.tgz", - "integrity": "sha1-b7lPvXGIUwbXPRXMSX/kzE7NRL8=", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.5", - "mime-types": "^2.1.12" + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" - }, - "har-validator": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", - "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", - "requires": { - "ajv": "^5.1.0", - "har-schema": "^2.0.0" - } - }, - "hawk": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", - "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", - "requires": { - "boom": "4.x.x", - "cryptiles": "3.x.x", - "hoek": "4.x.x", - "sntp": "2.x.x" - } - }, - "hoek": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.0.tgz", - "integrity": "sha512-v0XCLxICi9nPfYrS9RL8HbYnXi9obYAeLbSP00BmnZwCK9+Ih9WOjoZ8YoHCoav2csqn4FOz4Orldsy2dmDwmQ==" - }, - "htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", - "dev": true, - "requires": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - } - }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true - }, - "is-typedarray": { + "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "optional": true - }, - "jshint": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.5.tgz", - "integrity": "sha1-HnJSkVzmgbQIJ+4UJIxG006apiw=", - "dev": true, - "requires": { - "cli": "~1.0.0", - "console-browserify": "1.1.x", - "exit": "0.1.x", - "htmlparser2": "3.8.x", - "lodash": "3.7.x", - "minimatch": "~3.0.2", - "shelljs": "0.3.x", - "strip-json-comments": "1.0.x" - }, + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", "dependencies": { - "lodash": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz", - "integrity": "sha1-Nni9irmVBXwHreg27S7wh9qBHUU=", - "dev": true - } + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" - }, - "json-schema-traverse": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", - "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" - } - }, - "keypress": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/keypress/-/keypress-0.2.1.tgz", - "integrity": "sha1-HoBFQlABjbrUw/6USX1uZ7YmnHc=" - }, - "lodash": { - "version": "4.17.4", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" - }, - "mime-db": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", - "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE=" - }, - "mime-types": { - "version": "2.1.17", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz", - "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=", - "requires": { - "mime-db": "~1.30.0" - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "oauth-sign": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", - "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "path-is-absolute": { + "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" - }, - "qs": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", - "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" - }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "request": { - "version": "2.83.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.83.0.tgz", - "integrity": "sha512-lR3gD69osqm6EYLk9wB/G1W/laGWjzH90t1vEa2xuxHD5KUrSzp9pUSfTm+YC5Nxt2T8nMPEvKlhbQayU7bgFw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.6.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.5", - "extend": "~3.0.1", - "forever-agent": "~0.6.1", - "form-data": "~2.3.1", - "har-validator": "~5.0.3", - "hawk": "~6.0.2", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.17", - "oauth-sign": "~0.8.2", - "performance-now": "^2.1.0", - "qs": "~6.5.1", - "safe-buffer": "^5.1.1", - "stringstream": "~0.0.5", - "tough-cookie": "~2.3.3", - "tunnel-agent": "^0.6.0", - "uuid": "^3.1.0" - } - }, - "safe-buffer": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" - }, - "shelljs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", - "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", - "dev": true - }, - "sntp": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", - "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", - "requires": { - "hoek": "4.x.x" - } - }, - "sshpk": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", - "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "tweetnacl": "~0.14.0" - } - }, - "ssl-root-cas": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/ssl-root-cas/-/ssl-root-cas-1.3.1.tgz", - "integrity": "sha512-KR8J210Wfvjh+iNE9jcQEgbG0VG2713PHreItx6aNCPnkFO8XChz1cJ4iuCGeBj0+8wukLmgHgJqX+O5kRjPkQ==", - "requires": { - "@coolaj86/urequest": "^1.3.6" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - }, - "stringstream": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", - "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" - }, - "strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", - "dev": true - }, - "tough-cookie": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", - "integrity": "sha1-C2GKVWW23qkL80JdBNVe3EdadWE=", - "requires": { - "punycode": "^1.4.1" - } - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "optional": true - }, - "uuid": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", - "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "wrappy": { + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } } } } diff --git a/package.json b/package.json index 19d4288..52ab5ea 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,42 @@ { "name": "allow2", - "version": "1.2.0", - "description": "Free and Powerful Parental Freedom for your apps and devices", - "main": "index.js", - "keywords": [ - "Parental Controls", - "parental", - "freedom", - "time", - "limit", - "limits", - "quotas", - "gaming" - ], - "homepage": "https://github.com/Allow2/allow2node", + "version": "2.0.0-alpha.8", + "description": "Allow2 Device SDK — parental controls for apps and devices", + "type": "module", + "main": "src/index.js", + "exports": { + ".": "./src/index.js" + }, + "engines": { + "node": ">=18.0.0" + }, "scripts": { - "test": "./node_modules/jshint/bin/jshint index.js" + "test": "node --test tests/", + "lint": "eslint src/" }, - "maintainers": [ - { - "name": "Andrew", - "email": "ceo@allow2.com" - } + "keywords": [ + "parental-controls", + "allow2", + "screen-time", + "device-management", + "child-safety" ], + "homepage": "https://github.com/Allow2/allow2node", "repository": { "type": "git", "url": "git+https://github.com/Allow2/allow2node.git" }, "author": "Allow2 Pty Ltd", "license": "SEE LICENSE IN LICENSE FILE", - "dependencies": { - "async": "^2.6.0", - "co-prompt": "^1.0.0", - "commander": "^2.13.0", - "request": "^2.79.0" - }, - "devDependencies": { - "jshint": "^2.9.5" + "bugs": { + "url": "https://github.com/Allow2/allow2node/issues" }, - "runkitExampleFilename": "runkit-example.js" + "files": [ + "src/", + "LICENSE", + "README.md" + ], + "dependencies": { + "express": "^4.21.0" + } } diff --git a/runkit-example.js b/runkit-example.js deleted file mode 100644 index 1fb9fa0..0000000 --- a/runkit-example.js +++ /dev/null @@ -1,90 +0,0 @@ -var allow2 = require("allow2") - -// -// start by "pairing" the device or service to the allow2 platform -// -// For this, you need a free Allow2 account with an email/password. -// https://app.allow2.com -// -let email = "parent@email.address.com"; // The parent logs in to Allow2 with this email address -let password = "parentpassword"; // and this password - -// -// **** Once you have paired the device, -// update these creds and you can interact with Allow2 -// -let pairing = { -// pairId: 123, -// token: "AAAAAAAAA", -// userId: 234, -// childId: 862 // MANDATORY! = the child that is using the device or app -}; - -// -// this device token comes from the developer portal to describe your device -// the one here is valid and you can use it for testing, or replace with your own -// -let deviceToken = "B0hNax6VCFi9vphu"; - -if (!pairing.token) { - return allow2.pair({ - user: email, - pass: password, - deviceToken: deviceToken, - deviceName: 'Runkit Example Device' - }, function(err, response){ - if (err) { return console.log("Error: ", err, response); } - if (response.status != 'success') { return console.log("Error: ", response.message) }; - - console.log('pairing complete, please use:\n', - 'let pairing = {', - ' pairId: ' + response.pairId + ',', - ' token: "' + response.token + '",', - ' userId: ' + response.userId + '', - '}' - ); - - if (response.children.length < 1) { - console.log('Warning: You have no children on this parent account, you will be unable to test usage as a child is mandatory.\nSuggest you add at least 1 child and pair again.'); - } - console.log('and pick a child id out of:', response.children); - }); -} - - -if (!pairing.childId) { - return allow2.status({ - userId: pairing.userId, - pairId: pairing.pairId, - pairToken: pairing.token, - deviceToken: deviceToken, - }, function(err, result) { - if (err) { return console.log("Error: ", err, response); } - console.log('result from Allow2 status:\n', result); - console.log('use one child id to then call the check routine to check and log usage.') - }) -} - -// -// once the device is paired, AND you have a childId to record usage, -// you call "check" to check if access is currently allowed. -// logging the usage is optional - otherwise it just returns the ability to use the activity at this time. -// -allow2.check({ - userId: pairing.userId, - pairId: pairing.pairId, - pairToken: pairing.token, - deviceToken: deviceToken, - tz: 'Australia/Sydney', // note: timezone is crucial to correctly calculate allowed times and day types - childId: pairing.childId, // MANDATORY! - activities: [ - { id: 1, log: true }, // 1 = Internet - { id: 2, log: true }, // 2 = Conputer - { id: 3, log: true }, // 3 = Gaming - { id: 8, log: true } // 8 = Screen Time - ], - log: true // note: if set, record the usage (log it) and deduct quota, otherwise it only checks the access is permitted. -}, function(err, result) { - console.log('result from Allow2 check:', result); -}); - diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..b86aeb4 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# bump-version.sh — Standardised version bumping for Allow2 Node SDK +# Usage: ./scripts/bump-version.sh [prerelease|patch|minor|major] [--preid alpha|beta|rc] +# +# Examples: +# ./scripts/bump-version.sh prerelease --preid alpha # 2.0.0-alpha.6 → 2.0.0-alpha.7 +# ./scripts/bump-version.sh prerelease --preid beta # 2.0.0-alpha.7 → 2.0.0-beta.0 +# ./scripts/bump-version.sh patch # 2.0.0-alpha.7 → 2.0.1 +# ./scripts/bump-version.sh minor # 2.0.1 → 2.1.0 +# ./scripts/bump-version.sh major # 2.1.0 → 3.0.0 +set -euo pipefail +cd "$(dirname "$0")/.." + +BUMP="${1:-prerelease}" +PREID="" +if [[ "${2:-}" == "--preid" ]]; then PREID="${3:-alpha}"; fi + +OLD=$(node -p "require('./package.json').version") + +case "$BUMP" in + prerelease) + if [[ -n "$PREID" ]]; then + npm version prerelease --preid="$PREID" --no-git-tag-version + else + npm version prerelease --no-git-tag-version + fi + ;; + patch|minor|major) + npm version "$BUMP" --no-git-tag-version + ;; + *) + echo "Usage: $0 [prerelease|patch|minor|major] [--preid alpha|beta|rc]" >&2 + exit 1 + ;; +esac + +NEW=$(node -p "require('./package.json').version") +echo "$OLD → $NEW" + +git add package.json +git commit -m "v$NEW" +git tag "v$NEW" +echo "Tagged v$NEW — push with: git push origin main --tags" diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..3294d61 --- /dev/null +++ b/src/api.js @@ -0,0 +1,409 @@ +/** + * Allow2 API Client + * + * Low-level fetch-based client for the Allow2 REST API. + * Used internally by DeviceDaemon — not typically called directly. + * + * VID/Token: Each Allow2 integration has a registered Version ID (vid) and + * version token (deviceToken). These identify the APPLICATION (e.g., "allow2linux"), + * not the individual device. The per-device identity is the uuid field. + * + * Production defaults are baked in. In NON-PRODUCTION (dev) builds only, the target + * can be switched for testing: + * ALLOW2_ENV=staging (or production) ← preferred switch + * ALLOW2_API_URL=https://custom-api.example.com ← advanced raw-URL escape hatch + * ALLOW2_VID=12345 + * ALLOW2_TOKEN=mytoken + * + * ────────────────────────────────────────────────────────────────────────────── + * PRODUCTION SAFETY — HARD GUARD + * ────────────────────────────────────────────────────────────────────────────── + * Node has no compile step, so the production guard is a BUILD-BAKED FLAG that the + * release/installer build MUST set: + * + * ALLOW2_PRODUCTION=1 (preferred — set by the packaged/release build) + * or NODE_ENV=production + * + * When EITHER is in effect, every staging override (ALLOW2_ENV and ALLOW2_API_URL, + * including an explicit options.apiUrl) is IGNORED and the production endpoints are + * used unconditionally. A shipped product can therefore never attach to staging by + * any runtime input. Release/installer builds MUST bake ALLOW2_PRODUCTION=1. + */ + +// Production endpoints — always the default, and the ONLY option in a production build. +const PROD_API_URL = 'https://api.allow2.com'; +const PROD_SERVICE_URL = 'https://service.allow2.com'; + +// Staging endpoints — reachable ONLY from a non-production (dev) build. +const STAGING_API_URL = 'https://staging-api.allow2.com'; +const STAGING_SERVICE_URL = 'https://staging-service.allow2.com'; + +// Back-compat export (some callers reference DEFAULT_API_URL). +const DEFAULT_API_URL = PROD_API_URL; + +// Build-baked production flag. Release/installer builds MUST set ALLOW2_PRODUCTION=1 +// (NODE_ENV=production is also honoured). When true, all staging overrides are dead. +const IS_PRODUCTION_BUILD = + process.env.ALLOW2_PRODUCTION === '1' || + process.env.ALLOW2_PRODUCTION === 'true' || + process.env.NODE_ENV === 'production'; + +/** + * Resolve the target endpoints for the CURRENT BUILD. + * + * Hard guard: in a production build this ALWAYS returns the production endpoints and + * marks the result non-overridable — ALLOW2_ENV is ignored entirely. + */ +function resolveEnvironment() { + if (IS_PRODUCTION_BUILD) { + return { apiUrl: PROD_API_URL, serviceUrl: PROD_SERVICE_URL, name: 'production', overridable: false }; + } + const env = (process.env.ALLOW2_ENV || '').toLowerCase(); + if (env === 'staging') { + console.warn('⚠️ Allow2 SDK targeting ' + env.toUpperCase() + + ' — DEV ONLY; release builds (ALLOW2_PRODUCTION=1 / NODE_ENV=production) always use production.'); + return { apiUrl: STAGING_API_URL, serviceUrl: STAGING_SERVICE_URL, name: env, overridable: true }; + } + return { apiUrl: PROD_API_URL, serviceUrl: PROD_SERVICE_URL, name: 'production', overridable: true }; +} + +// Default production VID/Token for allow2linux. +// Register your own at https://developer.allow2.com for other integrations. +const DEFAULT_VID = 0; +const DEFAULT_TOKEN = ''; + +export class Allow2Api { + + /** + * @param {Object} options + * @param {string} [options.apiUrl] - API base URL (or set ALLOW2_API_URL env var) + * @param {number} [options.vid] - Version ID (or set ALLOW2_VID env var) + * @param {string} [options.token] - Version token (or set ALLOW2_TOKEN env var) + * @param {number} [options.timeout] - Request timeout in ms (default 15000) + */ + constructor(options = {}) { + // Resolve endpoints through the production guard. In a production build the + // result is non-overridable; in a dev build, a raw-URL override (options.apiUrl + // or ALLOW2_API_URL) is honoured as an advanced escape hatch. + const resolved = resolveEnvironment(); + let apiUrl = resolved.apiUrl; + let serviceUrl = resolved.serviceUrl; + if (resolved.overridable) { + const override = options.apiUrl || process.env.ALLOW2_API_URL; + if (override) { + console.warn('⚠️ Allow2 SDK using custom endpoint ' + override + ' — DEV ONLY.'); + apiUrl = override; + serviceUrl = override; + } + } + this.environment = resolved.name; + this.baseUrl = apiUrl; + this.serviceUrl = serviceUrl; + this.timeout = options.timeout || 15000; + + // VID/Token: explicit option > env var > baked-in default + this.vid = options.vid || parseInt(process.env.ALLOW2_VID, 10) || DEFAULT_VID; + this.token = options.token || process.env.ALLOW2_TOKEN || DEFAULT_TOKEN; + + if (!this.vid || !this.token) { + console.warn('Allow2 API: VID/Token not configured. Set ALLOW2_VID and ALLOW2_TOKEN environment variables, or pass vid/token in options.'); + } + } + + async _fetch(path, options = {}) { + const url = this.baseUrl + path; + const controller = new AbortController(); + const timer = setTimeout(function () { controller.abort(); }, this.timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + let body; + try { + body = await response.json(); + } catch (_parseErr) { + if (!response.ok) { + const err = new Error('API error ' + response.status); + err.status = response.status; + throw err; + } + throw new Error('Unexpected non-JSON response from API'); + } + + if (!response.ok) { + const err = new Error(body.message || 'API error ' + response.status); + err.status = response.status; + err.code = body.code; + err.body = body; + throw err; + } + + return body; + } finally { + clearTimeout(timer); + } + } + + /** + * Initiate QR-code pairing. + * Returns a pairing session that the parent completes from their phone. + * + * @param {Object} params + * @param {string} params.uuid - Unique device instance ID (generated once, stored locally) + * @param {string} params.deviceName - Human-readable device name ("Emma's Steam Deck") + */ + async initQRPairing(params) { + return this._fetch('/api/pair/qr/init', { + method: 'POST', + body: JSON.stringify({ + uuid: params.uuid, + name: params.deviceName, + deviceToken: this.token, + vid: this.vid, + platform: params.platform || 'linux', + }), + }); + } + + /** + * Initiate PIN-code pairing. + * Returns a PIN that the parent enters in their Allow2 app. + * + * @param {Object} params + * @param {string} params.uuid - Unique device instance ID + * @param {string} params.deviceName - Human-readable device name + */ + async initPINPairing(params) { + return this._fetch('/api/pair/pin/init', { + method: 'POST', + body: JSON.stringify({ + uuid: params.uuid, + name: params.deviceName, + deviceToken: this.token, + vid: this.vid, + platform: params.platform || 'linux', + }), + }); + } + + /** + * Poll pairing status (called while waiting for parent to confirm). + * + * @param {string} pairingSessionId - From initQRPairing/initPINPairing response + */ + async checkPairingStatus(pairingSessionId) { + return this._fetch('/api/pair/status/' + pairingSessionId); + } + + /** + * Check permissions for a child + activities. + * Returns per-activity allowed/remaining status. + */ + async check(params) { + return this._fetch('/serviceapi/check', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + deviceToken: this.token, + tz: params.tz, + childId: params.childId, + activities: params.activities, + log: params.log !== undefined ? params.log : true, + }), + }); + } + + /** + * Poll for updates (extensions, day type changes, quota updates, bans, child data). + */ + async getUpdates(params) { + const query = new URLSearchParams({ + userId: String(params.userId), + pairId: String(params.pairId), + pairToken: params.pairToken, + deviceToken: this.token, + }); + if (params.timestampMillis) { + query.set('timestampMillis', String(params.timestampMillis)); + } + return this._fetch('/api/getUpdates?' + query.toString()); + } + + /** + * Create a "Request More Time" request from a child. + */ + async createRequest(params) { + return this._fetch('/api/request/createRequest', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + childId: params.childId, + duration: params.duration, + activity: params.activity, + message: params.message, + }), + }); + } + + /** + * Poll request approval status. + */ + async getRequestStatus(requestId, statusSecret) { + return this._fetch('/api/request/' + requestId + '/status', { + headers: { + 'X-Status-Secret': statusSecret, + }, + }); + } + + // ---------------------------------------------------------------- + // Feedback + // ---------------------------------------------------------------- + + /** + * Submit feedback from a device/child to the Allow2 server. + * + * @param {object} params + * @param {number} params.userId + * @param {number} params.pairId + * @param {string} params.pairToken + * @param {number} [params.childId] + * @param {number} [params.vid] + * @param {string} params.category - One of: bypass, missing_feature, not_working, question, other + * @param {string} params.message + * @param {object} [params.deviceContext] + * @returns {Promise<{ discussionId: string }>} + */ + async submitFeedback(params) { + return this._fetch('/api/feedback/submit', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + childId: params.childId, + vid: params.vid || this.vid, + category: params.category, + message: params.message, + deviceContext: params.deviceContext, + }), + }); + } + + /** + * Load feedback discussions for a device. + * + * @param {object} params + * @param {number} params.userId + * @param {number} params.pairId + * @param {string} params.pairToken + * @returns {Promise<{ discussions: Array }>} + */ + async loadFeedback(params) { + return this._fetch('/api/feedback/load', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + }), + }); + } + + /** + * Reply to an existing feedback discussion. + * + * @param {object} params + * @param {number} params.userId + * @param {number} params.pairId + * @param {string} params.pairToken + * @param {string} params.discussionId + * @param {string} params.message + * @returns {Promise<{ messageId: string }>} + */ + async feedbackReply(params) { + return this._fetch('/api/feedback/reply', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + discussionId: params.discussionId, + message: params.message, + }), + }); + } + + // ---------------------------------------------------------------- + // Usage Logging + // ---------------------------------------------------------------- + + /** + * Log usage explicitly (e.g., reconcile after offline period). + */ + async logUsage(params) { + return this._fetch('/api/logUsage', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + deviceToken: this.token, + childId: params.childId, + activities: params.activities, + }), + }); + } + + // ---------------------------------------------------------------- + // Usage-Auth Events (plane-2) + // ---------------------------------------------------------------- + + /** + * Report a plane-2 usage-auth event: someone identified themselves to START a usage + * session on this paired device (entered the account/child PIN, passed an offline + * 6-digit / QR self-auth, or was locally auto-identified). + * + * This is a NOTIFICATION + audit signal, NOT an authorization. The device has already + * authorized locally (offline-first); the server merely records a login-history row and + * fires the plane-2 notify so the account holder / other parents are alerted (a compromise + * — "someone authed as you on X" — gets caught). Server: `src/controllers/authEvent.ts`, + * `POST /api/authEvent` over the SAME pairToken seam as logUsage. + * + * Best-effort, like logUsage: a single direct POST. The server does NOT dedup — each call + * fires a fresh notify — so callers MUST NOT blindly retry/replay this (a replayed auth + * event would double-notify the parent). There is deliberately no offline store-and-forward. + * + * @param {object} params + * @param {string} params.userId - Account owner id (from pairing credentials) + * @param {number} params.pairId - Paired device id (from pairing credentials) + * @param {string} params.pairToken - Per-pairing secret (from pairing credentials) + * @param {string} [params.childId] - The child/person who authed, when known AND within the device's scope + * @param {string} [params.method] - Auth method: 'pin' | 'offline_code' | 'qr' (anything else => generic 'token') + * @returns {Promise<{ status: string }>} + */ + async reportAuthEvent(params) { + return this._fetch('/api/authEvent', { + method: 'POST', + body: JSON.stringify({ + userId: params.userId, + pairId: params.pairId, + pairToken: params.pairToken, + deviceToken: this.token, + childId: params.childId, + method: params.method, + }), + }); + } +} diff --git a/src/checker.js b/src/checker.js new file mode 100644 index 0000000..b48c372 --- /dev/null +++ b/src/checker.js @@ -0,0 +1,332 @@ +/** + * Check Loop + Per-Activity Enforcement + * + * Periodically calls the Allow2 check API and tracks per-activity + * state transitions (allowed → blocked, soft-lock, hard-lock). + * Delegates warning scheduling to WarningScheduler. + */ + +import { WarningScheduler } from './warnings.js'; +import { OfflineHandler } from './offline.js'; + +// Activity ID 8 = Screen Time (device-level master switch) +const SCREEN_TIME_ACTIVITY = 8; + +export class Checker { + + /** + * @param {object} options + * @param {import('./api.js').Allow2Api} options.api + * @param {Function} options.emit - EventEmitter emit bound to daemon + * @param {object} options.credentials - { userId, pairId, pairToken, deviceToken } + * @param {number} options.childId + * @param {Array<{ id: number }>} options.activities - Activities to check + * @param {number} [options.checkInterval=60] - Seconds between checks + * @param {number} [options.hardLockTimeout=300] - Seconds after soft-lock before hard-lock + * @param {number} [options.gracePeriod=300] - Offline grace period in seconds + * @param {object} [options.warningThresholds] - Custom WarningScheduler thresholds + */ + constructor(options) { + this._api = options.api; + this._emit = options.emit; + this._credentials = options.credentials; + this._childId = options.childId; + this._activities = options.activities; + this._checkInterval = (options.checkInterval || 60) * 1000; + this._hardLockTimeout = (options.hardLockTimeout || 300) * 1000; + this._gracePeriod = (options.gracePeriod || 300) * 1000; + + // Disk-backed offline cache. The grace window is measured from the last + // SUCCESSFUL check (persisted to ~/.allow2/cache.json), so it survives a + // daemon restart during an outage — a restart can no longer reset the + // grace clock (which would let a child farm grace by rebooting). + this._offline = options.offlineHandler || new OfflineHandler({ + gracePeriod: options.gracePeriod || 300, + cachePath: options.cachePath, + }); + + this._tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Per-activity state: Map + this._state = new Map(); + + // Soft-lock tracking + this._softLocked = false; + this._softLockTimer = null; + + // Offline tracking + this._offlineSince = null; + this._offlineGraceEmitted = false; + + // Timer handle + this._timer = null; + this._running = false; + + this._warnings = new WarningScheduler({ + emit: this._emit, + thresholds: options.warningThresholds, + }); + } + + /** + * Start the check loop. Runs an immediate check, then repeats on interval. + */ + start() { + if (this._running) return; + this._running = true; + this._runCheck(); + } + + /** + * Stop the check loop and clean up timers. + */ + stop() { + this._running = false; + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + if (this._softLockTimer) { + clearTimeout(this._softLockTimer); + this._softLockTimer = null; + } + } + + /** + * Notify the checker that time was extended for an activity + * (e.g., parent approved a request). Resets warning state. + * + * @param {number} activityId + */ + onTimeExtended(activityId) { + this._warnings.resetActivity(String(activityId)); + + // If we were soft-locked, cancel the hard-lock timer and re-evaluate on next check + if (this._softLocked) { + this._softLocked = false; + if (this._softLockTimer) { + clearTimeout(this._softLockTimer); + this._softLockTimer = null; + } + this._emit('unlock', { reason: 'time-extended', activityId: activityId }); + } + } + + /** + * Get remaining time for all tracked activities. + * + * @returns {object} Map of activityId → { allowed, remaining } or null if no state + */ + getRemaining() { + if (this._state.size === 0) return null; + var result = {}; + this._state.forEach(function (val, key) { + result[key] = { allowed: val.allowed, remaining: val.remaining }; + }); + return result; + } + + /** + * Reset all state (e.g., new child selected). + */ + reset(childId) { + this._childId = childId; + this._state.clear(); + this._softLocked = false; + this._offlineSince = null; + this._offlineGraceEmitted = false; + this._warnings.resetAll(); + if (this._softLockTimer) { + clearTimeout(this._softLockTimer); + this._softLockTimer = null; + } + } + + // ---------------------------------------------------------------- + // Internal + // ---------------------------------------------------------------- + + async _runCheck() { + if (!this._running) return; + + try { + await this._doCheck(); + } catch (err) { + await this._handleError(err); + } + + if (this._running) { + this._timer = setTimeout(() => this._runCheck(), this._checkInterval); + } + } + + async _doCheck() { + const activityMap = {}; + for (let i = 0; i < this._activities.length; i++) { + const act = this._activities[i]; + activityMap[act.id] = 1; // 1 = active / requesting check + } + + const result = await this._api.check({ + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + deviceToken: this._credentials.deviceToken, + tz: this._tz, + childId: this._childId, + activities: activityMap, + log: true, + }); + + // Successful API call — clear offline state and persist the result so + // the offline grace window (and last-known-good decision) survive a + // daemon restart. + if (this._offlineSince) { + this._offlineSince = null; + this._offlineGraceEmitted = false; + } + try { + await this._offline.cacheResult(result); + } catch (_e) { + // Disk write failure is non-fatal — enforcement continues. + } + + this._processResult(result); + } + + _processResult(result) { + // The check API returns `activities` as an object keyed by activity ID. + // Each entry has: { id, activity, allowed, remaining, ... } + const activities = result.activities || {}; + const ids = Object.keys(activities); + + let allBlocked = true; + const warningData = {}; + + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const current = activities[id]; + const allowed = !!current.allowed; + const remaining = current.remaining != null ? current.remaining : Infinity; + + const prev = this._state.get(id); + const wasAllowed = prev ? prev.allowed : true; + + // Detect allowed → blocked transition + if (wasAllowed && !allowed) { + this._emit('activity-blocked', { + activityId: Number(id), + activity: current.activity || id, + remaining: 0, + }); + + // Screen Time (8) hitting 0 means full device lock + if (Number(id) === SCREEN_TIME_ACTIVITY) { + this._triggerSoftLock('screen-time-exhausted'); + } + } + + // Detect blocked → allowed transition (unlock) + if (!wasAllowed && allowed && prev) { + this._warnings.resetActivity(id); + if (this._softLocked) { + // Re-evaluate soft lock below after processing all activities + } + } + + // Update state + this._state.set(id, { allowed: allowed, remaining: remaining }); + + if (allowed) { + allBlocked = false; + warningData[id] = { remaining: remaining }; + } + } + + // If ALL activities are now blocked, trigger soft-lock + if (ids.length > 0 && allBlocked && !this._softLocked) { + this._triggerSoftLock('all-activities-blocked'); + } + + // If we were soft-locked but something is now allowed, unlock + if (this._softLocked && !allBlocked) { + this._softLocked = false; + if (this._softLockTimer) { + clearTimeout(this._softLockTimer); + this._softLockTimer = null; + } + this._emit('unlock', { reason: 'activity-unblocked' }); + } + + // Feed remaining times to warning scheduler for allowed activities + if (Object.keys(warningData).length > 0) { + this._warnings.update(warningData); + } + } + + _triggerSoftLock(reason) { + if (this._softLocked) return; + this._softLocked = true; + + this._emit('soft-lock', { reason: reason }); + + // Start hard-lock countdown + this._softLockTimer = setTimeout(() => { + if (this._softLocked && this._running) { + this._emit('hard-lock', { reason: 'soft-lock-timeout' }); + } + }, this._hardLockTimeout); + } + + async _handleError(err) { + // HTTP 401 = credentials revoked, device unpaired + if (err && err.status === 401) { + this._emit('unpaired', { error: err }); + this.stop(); + return; + } + + // Network / timeout errors → offline handling. + // + // Grace is measured from the last SUCCESSFUL check, read from the + // disk-backed cache, so it survives a daemon restart mid-outage. If the + // device has NEVER synced (no cache yet), fall back to in-memory + // first-failure tracking so a brand-new device still gets its grace + // window rather than an instant deny. + const now = Date.now(); + let offlineDuration; + + let elapsedSec = Infinity; + try { + elapsedSec = await this._offline.getGraceElapsed(); + } catch (_e) { + elapsedSec = Infinity; + } + + if (elapsedSec !== Infinity && Number.isFinite(elapsedSec)) { + offlineDuration = elapsedSec * 1000; + // Keep _offlineSince coherent for the event payload / diagnostics. + this._offlineSince = now - offlineDuration; + } else { + if (!this._offlineSince) { + this._offlineSince = now; + } + offlineDuration = now - this._offlineSince; + } + + if (offlineDuration < this._gracePeriod) { + if (!this._offlineGraceEmitted) { + this._offlineGraceEmitted = true; + this._emit('offline-grace', { + since: this._offlineSince, + graceRemaining: this._gracePeriod - offlineDuration, + }); + } + } else { + this._emit('offline-deny', { + since: this._offlineSince, + offlineDuration: offlineDuration, + }); + } + } +} diff --git a/src/child-resolver/linux-user.js b/src/child-resolver/linux-user.js new file mode 100644 index 0000000..678efe6 --- /dev/null +++ b/src/child-resolver/linux-user.js @@ -0,0 +1,80 @@ +/** + * Linux User Child Resolver + * + * Maps the current Linux OS user account to an Allow2 child. + * If the logged-in username matches a child's name (case-insensitive), + * that child is auto-selected without requiring a UI selector. + * + * If no match is found, returns null — the caller should fall back + * to the interactive selector and emit 'child-select-required'. + * + * @example + * import { resolveChild } from './child-resolver/linux-user.js'; + * + * const match = await resolveChild(children); + * if (match) { + * await childShield.selectChild(match.childId, pin); + * } + */ + +import { execSync } from 'node:child_process'; + +/** + * Get the current Linux username. + * Prefers the USER environment variable; falls back to `whoami`. + * + * @returns {string} The current username, or empty string on failure. + */ +function getLinuxUsername() { + if (process.env.USER) { + return process.env.USER; + } + try { + return execSync('whoami', { encoding: 'utf8' }).trim(); + } catch (_err) { + return ''; + } +} + +/** + * Resolve the current Linux user to an Allow2 child. + * + * Matching is case-insensitive against each child's `name` field. + * Children may also have an optional `osUsername` field for explicit mapping. + * + * @param {Array} children - Array of child objects from pairing data. + * @returns {{ childId: number, childName: string } | null} + */ +export function resolveChild(children) { + if (!children || children.length === 0) { + return null; + } + + const username = getLinuxUsername(); + if (!username) { + return null; + } + + const lower = username.toLowerCase(); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + // Skip the parent entry + if (child.id === 0 || child.name === '__parent__') { + continue; + } + + // Explicit OS username mapping takes priority + if (child.osUsername && child.osUsername.toLowerCase() === lower) { + return { childId: child.id, childName: child.name }; + } + + // Fall back to name match + if (child.name && child.name.toLowerCase() === lower) { + return { childId: child.id, childName: child.name }; + } + } + + return null; +} diff --git a/src/child-resolver/selector.js b/src/child-resolver/selector.js new file mode 100644 index 0000000..615f7d4 --- /dev/null +++ b/src/child-resolver/selector.js @@ -0,0 +1,49 @@ +/** + * Interactive Child Selector Resolver + * + * For devices without OS-level account mapping (shared single-login devices, + * IoT, consoles, kiosks). This resolver does not attempt automatic resolution; + * it simply signals that a child selector UI must be shown. + * + * The actual selection happens when the integration calls + * `childShield.selectChild(childId, pin)` after the user picks a child. + * + * @example + * import { resolveChild } from './child-resolver/selector.js'; + * import { ChildShield } from '../child-shield.js'; + * + * const shield = new ChildShield({ children }); + * const match = resolveChild(children); + * // match is always null — listen for the event instead: + * shield.on('child-select-required', (children) => { + * showSelectorUI(children); + * }); + * // Trigger it: + * requestSelection(shield); + */ + +/** + * Attempt to resolve a child automatically. + * Always returns null — this resolver requires manual selection. + * + * @param {Array} children - Array of child objects from pairing data. + * @returns {null} Always null; selection must happen via UI. + */ +export function resolveChild(children) { + // No automatic resolution possible on shared devices. + // The integration must show a child selector and call + // childShield.selectChild() when the user picks one. + return null; +} + +/** + * Request that the child shield emit a selection event. + * Call this at boot or session start on shared devices. + * + * @param {import('../child-shield.js').ChildShield} childShield - The ChildShield instance. + */ +export function requestSelection(childShield) { + if (childShield && typeof childShield.emit === 'function') { + childShield.emit('child-select-required', childShield.getChildren()); + } +} diff --git a/src/child-shield.js b/src/child-shield.js new file mode 100644 index 0000000..d5d3daa --- /dev/null +++ b/src/child-shield.js @@ -0,0 +1,411 @@ +/** + * ChildShield — Child identification, PIN verification, and session management. + * + * Port of the Brave browser's ChildShield/ChildManager pattern for Node.js. + * Manages which child is currently using the device, with PIN-based + * verification and automatic session timeout on inactivity. + * + * @example + * import { ChildShield } from './child-shield.js'; + * + * const shield = new ChildShield({ + * children: pairingData.children, + * verificationLevel: 'pin', + * sessionTimeout: 300000, + * }); + * + * shield.on('child-select-required', (children) => { ... }); + * shield.on('session-timeout', () => { ... }); + * + * await shield.selectChild(789, '1234'); + */ + +import { EventEmitter } from 'node:events'; +import { createHash, timingSafeEqual } from 'node:crypto'; + +const MAX_PIN_ATTEMPTS = 5; +const LOCKOUT_DURATION_MS = 300000; // 5 minutes +const DEFAULT_SESSION_TIMEOUT_MS = 300000; // 5 minutes + +/** + * Hash a PIN with the given salt using SHA-256. + * @param {string} pin - The raw PIN string. + * @param {string} salt - Hex-encoded salt. + * @returns {string} Hex-encoded SHA-256 hash. + */ +function hashPin(pin, salt) { + return createHash('sha256') + .update(pin + salt) + .digest('hex'); +} + +/** + * Constant-time comparison of two hex hash strings. + * @param {string} a - First hex string. + * @param {string} b - Second hex string. + * @returns {boolean} True if equal. + */ +function safeCompare(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') { + return false; + } + const bufA = Buffer.from(a, 'hex'); + const bufB = Buffer.from(b, 'hex'); + if (bufA.length !== bufB.length) { + return false; + } + return timingSafeEqual(bufA, bufB); +} + + +export class ChildShield extends EventEmitter { + + /** + * @param {object} options + * @param {Array} options.children - Child objects from pairing data. + * @param {string} [options.verificationLevel='pin'] - 'honour' | 'pin' | 'parent-only' + * @param {number} [options.sessionTimeout] - Inactivity timeout in ms (default 300000). + * @param {Function} [options.onSelectRequired] - Convenience callback for 'child-select-required'. + */ + constructor(options = {}) { + super(); + + this._children = (options.children || []).slice(); + this._verificationLevel = options.verificationLevel || 'pin'; + this._sessionTimeout = (options.sessionTimeout != null) + ? options.sessionTimeout + : DEFAULT_SESSION_TIMEOUT_MS; + + // Current state + this._currentChild = null; + this._parentMode = false; + this._sessionTimer = null; + this._lastActivity = 0; + + // Rate-limiting state: keyed by childId (or 'parent') + this._attempts = new Map(); + + // Wire up convenience callback + if (typeof options.onSelectRequired === 'function') { + this.on('child-select-required', options.onSelectRequired); + } + } + + // --------------------------------------------------------------- + // Public API + // --------------------------------------------------------------- + + /** + * Select a child by ID, optionally verifying their PIN. + * + * @param {number} childId - The child's ID. + * @param {string} [pin] - The raw PIN (required when verificationLevel is 'pin'). + * @returns {boolean} True if the child was successfully selected. + */ + selectChild(childId, pin) { + if (this._verificationLevel === 'parent-only') { + return false; + } + + const child = this._findChild(childId); + if (!child) { + return false; + } + + // Check lockout + if (this._isLockedOut(childId)) { + const remaining = this._lockoutRemaining(childId); + this.emit('child-locked-out', Math.ceil(remaining / 1000)); + return false; + } + + // PIN verification (skip for 'honour' level) + if (this._verificationLevel === 'pin') { + if (!pin) { + return false; + } + if (!child.pinHash || !child.pinSalt) { + // Child has no PIN set — treat as honour + } else { + const computed = hashPin(pin, child.pinSalt); + if (!safeCompare(computed, child.pinHash)) { + this._recordFailedAttempt(childId); + return false; + } + } + } + + // Success — clear attempts and activate session + this._clearAttempts(childId); + this._activateChild(child); + return true; + } + + /** + * Authenticate as a parent using the parent PIN. + * The parent PIN is stored on the first child entry as a convention, + * but is supplied via the `parentPinHash` / `parentPinSalt` fields + * on the children array's meta (or passed during construction). + * + * For simplicity, the parent PIN is verified against a special + * entry in the children array where `id === 0` or `name === '__parent__'`, + * OR the caller may store parent credentials on the shield directly. + * + * @param {string} pin - The raw parent PIN. + * @returns {boolean} True if parent mode was entered. + */ + selectParent(pin) { + if (!pin) { + return false; + } + + const parentEntry = this._findParentEntry(); + if (!parentEntry) { + return false; + } + + // Check lockout + if (this._isLockedOut('parent')) { + const remaining = this._lockoutRemaining('parent'); + this.emit('child-locked-out', Math.ceil(remaining / 1000)); + return false; + } + + if (!parentEntry.pinHash || !parentEntry.pinSalt) { + return false; + } + + const computed = hashPin(pin, parentEntry.pinSalt); + if (!safeCompare(computed, parentEntry.pinHash)) { + this._recordFailedAttempt('parent'); + return false; + } + + this._clearAttempts('parent'); + this._enterParentMode(); + return true; + } + + /** + * End the current session. Clears child selection or parent mode + * and emits 'child-select-required'. + */ + clearSelection() { + this._stopSessionTimer(); + this._currentChild = null; + this._parentMode = false; + this._lastActivity = 0; + this.emit('child-select-required', this.getChildren()); + } + + /** + * Returns the currently selected child object, or null. + * @returns {object|null} + */ + getCurrentChild() { + return this._currentChild; + } + + /** + * Returns true if the device is in parent (unrestricted) mode. + * @returns {boolean} + */ + isParentMode() { + return this._parentMode; + } + + /** + * Record user interaction to keep the session alive. + * Call this on meaningful user activity (key press, mouse move, etc.). + */ + recordActivity() { + this._lastActivity = Date.now(); + if (this._currentChild || this._parentMode) { + this._resetSessionTimer(); + } + } + + /** + * Replace the children list (e.g., after a getUpdates call). + * Preserves the current selection if the child still exists. + * + * @param {Array} children - Updated child objects. + */ + updateChildren(children) { + this._children = (children || []).slice(); + + // If a child is selected, make sure they still exist + if (this._currentChild) { + const still = this._findChild(this._currentChild.id); + if (!still) { + // Child was removed — force re-selection + this.clearSelection(); + } else { + // Update the cached object with fresh data + this._currentChild = Object.assign({}, still); + } + } + } + + /** + * Returns a safe copy of the children list (excluding PINs). + * Suitable for display in a child selector UI. + * @returns {Array} + */ + getChildren() { + return this._children.map(function (c) { + return { + id: c.id, + name: c.name, + avatarUrl: c.avatarUrl, + color: c.color, + hasAccount: c.hasAccount, + }; + }); + } + + /** + * Clean up timers. Call when the shield is no longer needed. + */ + destroy() { + this._stopSessionTimer(); + this.removeAllListeners(); + } + + // --------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------- + + /** + * Find a child by ID in the current list. + * @param {number} childId + * @returns {object|undefined} + */ + _findChild(childId) { + for (let i = 0; i < this._children.length; i++) { + if (this._children[i].id === childId) { + return this._children[i]; + } + } + return undefined; + } + + /** + * Find the parent entry in the children list. + * Convention: id === 0 or name === '__parent__'. + */ + _findParentEntry() { + for (let i = 0; i < this._children.length; i++) { + const c = this._children[i]; + if (c.id === 0 || c.name === '__parent__') { + return c; + } + } + return undefined; + } + + /** + * Activate a child session. + */ + _activateChild(child) { + this._parentMode = false; + this._currentChild = Object.assign({}, child); + this._lastActivity = Date.now(); + this._resetSessionTimer(); + this.emit('child-selected', child.id, child.name); + } + + /** + * Enter parent mode (unrestricted access). + */ + _enterParentMode() { + this._currentChild = null; + this._parentMode = true; + this._lastActivity = Date.now(); + this._resetSessionTimer(); + this.emit('parent-mode-entered'); + } + + // --------------------------------------------------------------- + // Session timer + // --------------------------------------------------------------- + + _resetSessionTimer() { + this._stopSessionTimer(); + if (this._sessionTimeout <= 0) { + return; // Timeout disabled + } + this._sessionTimer = setTimeout(() => { + this._onSessionTimeout(); + }, this._sessionTimeout); + // Prevent the timer from keeping the process alive + if (this._sessionTimer && typeof this._sessionTimer.unref === 'function') { + this._sessionTimer.unref(); + } + } + + _stopSessionTimer() { + if (this._sessionTimer) { + clearTimeout(this._sessionTimer); + this._sessionTimer = null; + } + } + + _onSessionTimeout() { + this._sessionTimer = null; + this._currentChild = null; + this._parentMode = false; + this._lastActivity = 0; + this.emit('session-timeout'); + this.emit('child-select-required', this.getChildren()); + } + + // --------------------------------------------------------------- + // Rate limiting + // --------------------------------------------------------------- + + /** + * Get or create the rate-limit record for a key. + */ + _getAttemptRecord(key) { + if (!this._attempts.has(key)) { + this._attempts.set(key, { failed: 0, lockoutUntil: 0 }); + } + return this._attempts.get(key); + } + + _isLockedOut(key) { + const record = this._getAttemptRecord(key); + if (record.lockoutUntil > 0 && Date.now() < record.lockoutUntil) { + return true; + } + // Lockout expired — reset + if (record.lockoutUntil > 0 && Date.now() >= record.lockoutUntil) { + record.failed = 0; + record.lockoutUntil = 0; + } + return false; + } + + _lockoutRemaining(key) { + const record = this._getAttemptRecord(key); + const remaining = record.lockoutUntil - Date.now(); + return remaining > 0 ? remaining : 0; + } + + _recordFailedAttempt(key) { + const record = this._getAttemptRecord(key); + record.failed += 1; + + if (record.failed >= MAX_PIN_ATTEMPTS) { + record.lockoutUntil = Date.now() + LOCKOUT_DURATION_MS; + this.emit('child-locked-out', Math.ceil(LOCKOUT_DURATION_MS / 1000)); + } else { + this.emit('child-pin-failed', record.failed, MAX_PIN_ATTEMPTS); + } + } + + _clearAttempts(key) { + this._attempts.delete(key); + } +} diff --git a/src/credentials/index.js b/src/credentials/index.js new file mode 100644 index 0000000..ef50656 --- /dev/null +++ b/src/credentials/index.js @@ -0,0 +1,42 @@ +/** + * Credential Backend Factory + * + * Provides a unified interface for credential storage. + * Currently supports 'plaintext' (JSON file). The 'libsecret' backend + * is available on Linux systems with libsecret installed. + */ + +import { PlaintextBackend } from './plaintext.js'; + +/** + * Create a credential storage backend. + * + * @param {string} type - Backend type: 'plaintext' or 'libsecret' + * @param {object} [options] - Backend-specific options + * @returns {PlaintextBackend|LibsecretBackend} + */ +export async function createBackend(type, options) { + if (type === 'plaintext') { + return new PlaintextBackend(options); + } + + if (type === 'libsecret') { + // Lazy-load to avoid hard dependency on libsecret native module. + // Users who want libsecret must install the optional peer dependency. + let LibsecretBackend; + try { + const mod = await import('./libsecret.js'); + LibsecretBackend = mod.LibsecretBackend; + } catch (_err) { + throw new Error( + 'libsecret backend requires the "libsecret" package. ' + + 'Install it with: npm install libsecret' + ); + } + return new LibsecretBackend(options); + } + + throw new Error('Unknown credential backend type: ' + type); +} + +export { PlaintextBackend } from './plaintext.js'; diff --git a/src/credentials/plaintext.js b/src/credentials/plaintext.js new file mode 100644 index 0000000..04dd0db --- /dev/null +++ b/src/credentials/plaintext.js @@ -0,0 +1,96 @@ +/** + * Plaintext Credential Backend + * + * Stores pairing credentials as a JSON file in ~/.allow2/credentials.json. + * File permissions are locked down to owner-only (0o600). + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const DEFAULT_PATH = path.join(os.homedir(), '.allow2', 'credentials.json'); + +export class PlaintextBackend { + + /** + * @param {object} [options] + * @param {string} [options.path] - Override the credential file path + */ + constructor(options = {}) { + this._path = options.path || DEFAULT_PATH; + } + + /** + * Persist credentials to disk. + * + * @param {object} data - { userId, pairId, pairToken, deviceToken, children } + */ + async store(data) { + const dir = path.dirname(this._path); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(this._path, JSON.stringify(data, null, 2), { mode: 0o600 }); + } + + /** + * Load credentials from disk. + * + * @returns {Promise} The stored data or null if missing/corrupt + */ + async load() { + try { + const raw = await fs.readFile(this._path, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err.code === 'ENOENT') { + return null; + } + throw err; + } + } + + /** + * Delete the credential file. + */ + async clear() { + try { + await fs.unlink(this._path); + } catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + } + } + + /** + * Load last-used timestamps for all children. + * + * @returns {Promise} Map of childId (string) to ISO timestamp string, or {} if missing/corrupt + */ + async loadLastUsed() { + var lastUsedPath = path.join(path.dirname(this._path), 'last-used.json'); + try { + var raw = await fs.readFile(lastUsedPath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err.code === 'ENOENT') { + return {}; + } + return {}; + } + } + + /** + * Update the last-used timestamp for a specific child. + * + * @param {number|string} childId - The child ID to update + */ + async updateLastUsed(childId) { + var lastUsedPath = path.join(path.dirname(this._path), 'last-used.json'); + var data = await this.loadLastUsed(); + data[String(childId)] = new Date().toISOString(); + var dir = path.dirname(lastUsedPath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(lastUsedPath, JSON.stringify(data, null, 2), { mode: 0o600 }); + } +} diff --git a/src/daemon.js b/src/daemon.js new file mode 100644 index 0000000..623ab98 --- /dev/null +++ b/src/daemon.js @@ -0,0 +1,813 @@ +/** + * DeviceDaemon — Main entry point for the Allow2 Device SDK. + * + * Manages the full device lifecycle: + * 1. Unpaired → sits idle, waits for openApp() to start pairing + * 2. Pairing → pairing wizard active + * 3. Paired → paired but no child selected yet + * 4. Enforcing → child selected, check loop running + * 5. Parent → parent mode, no enforcement + * + * The daemon never throws on missing credentials — it emits events so the + * platform layer (allow2linux, etc.) can show the appropriate UI. + * + * Usage: + * const daemon = new DeviceDaemon({ + * deviceName: 'Living Room PC', + * activities: [{ id: 1 }, { id: 8 }], + * credentialBackend: myBackend, + * childResolver: myResolver, + * }); + * daemon.on('child-select-required', (children) => showSelector(children)); + * daemon.on('warning', (w) => showWarning(w)); + * daemon.on('soft-lock', () => lockScreen()); + * daemon.on('unpaired', () => showUnpairedUI()); + * await daemon.start(); + * // When user opens the Allow2 app: + * await daemon.openApp(); + */ + +import { EventEmitter } from 'node:events'; +import { Allow2Api } from './api.js'; +import { Checker } from './checker.js'; +import { PairingWizard } from './pairing.js'; + +export class DeviceDaemon extends EventEmitter { + + /** + * @param {object} options + * @param {string} [options.deviceName] - Human-readable device name + * @param {Array<{ id: number }>} options.activities - Activities to monitor + * @param {number} [options.checkInterval=60] - Seconds between API checks + * @param {object} options.credentialBackend - { load(): Promise, store(creds): Promise } + * @param {object} options.childResolver - { resolve(children): { childId, childName } | null } + * @param {number} [options.gracePeriod=300] - Offline grace period in seconds + * @param {number} [options.hardLockTimeout=300] - Seconds after soft-lock before hard-lock + * @param {Array} [options.warnings] - Custom warning thresholds + * @param {string} [options.apiUrl] - Override API URL (or set ALLOW2_API_URL env var) + * @param {number} [options.vid] - Override version ID (or set ALLOW2_VID env var) + * @param {string} [options.token] - Override version token (or set ALLOW2_TOKEN env var) + * @param {number} [options.pairingPort=3000] - Port for pairing wizard web UI + */ + constructor(options) { + super(); + + if (!options.activities || options.activities.length === 0) { + throw new Error('activities array is required and must not be empty'); + } + if (!options.credentialBackend) { + throw new Error('credentialBackend is required'); + } + if (!options.childResolver) { + throw new Error('childResolver is required'); + } + + this._deviceName = options.deviceName || 'Allow2 Device'; + this._activities = options.activities; + this._checkInterval = options.checkInterval || 60; + this._credentialBackend = options.credentialBackend; + this._childResolver = options.childResolver; + this._gracePeriod = options.gracePeriod || 300; + this._hardLockTimeout = options.hardLockTimeout || 300; + this._warningThresholds = options.warnings || null; + this._pairingPort = options.pairingPort || 3000; + + this._api = new Allow2Api({ + apiUrl: options.apiUrl, + vid: options.vid, + token: options.token, + }); + + this._checker = null; + this._credentials = null; + this._childId = null; + this._running = false; + this._pairingWizard = null; + this._heartbeatTimer = null; + + /** @type {'unpaired'|'pairing'|'paired'|'enforcing'|'parent'} */ + this._state = 'unpaired'; + } + + /** The Allow2Api instance (for advanced usage like createRequest). */ + get api() { + return this._api; + } + + /** Current credentials (read-only). */ + get credentials() { + return this._credentials; + } + + /** Currently selected child ID. */ + get childId() { + return this._childId; + } + + /** Whether the daemon is running. */ + get running() { + return this._running; + } + + /** Whether the device is paired. */ + get paired() { + return !!(this._credentials && this._credentials.pairId && this._credentials.pairToken); + } + + /** Current daemon state: 'unpaired', 'pairing', 'paired', 'enforcing', or 'parent'. */ + get state() { + return this._state; + } + + /** Whether the daemon is in parent mode (no enforcement). */ + get isParentMode() { + return this._state === 'parent'; + } + + // ---------------------------------------------------------------- + // Lifecycle + // ---------------------------------------------------------------- + + /** + * Start the daemon. + * + * Checks for stored credentials: + * - If unpaired → sits idle, logs message, waits for openApp() + * - If paired → proceeds to child identification and enforcement + */ + async start() { + if (this._running) return; + this._running = true; + + // 1. Try to load stored pairing credentials + try { + this._credentials = await this._credentialBackend.load(); + } catch (err) { + console.error('Failed to load credentials:', err.message); + this._credentials = null; + } + + // 2. If not paired, sit idle and wait for openApp() + if (!this._credentials || !this._credentials.pairId || !this._credentials.pairToken) { + this._state = 'unpaired'; + console.log('Device not paired. Waiting for user to open Allow2 app.'); + return; + } + + // 3. Already paired — proceed to child identification + this._state = 'paired'; + await this._beginEnforcement(); + } + + /** + * Stop the daemon: stop check loop, stop pairing wizard, clean up. + */ + stop() { + this._running = false; + this._stopHeartbeat(); + if (this._checker) { + this._checker.stop(); + this._checker = null; + } + if (this._pairingWizard) { + this._pairingWizard.stop(); + this._pairingWizard = null; + } + this._childId = null; + + // Reset state based on whether we have credentials + if (this._credentials && this._credentials.pairId) { + this._state = 'paired'; + } else { + this._state = 'unpaired'; + } + } + + /** + * Called when the user opens the Allow2 app / UI. + * + * If unpaired, starts the pairing flow. + * If already paired, emits status info for the UI to display. + */ + /** + * Called when the user closes the Allow2 app / UI. + * Stops the pairing wizard if running (clears polling timers). + */ + closeApp() { + if (this._pairingWizard) { + this._pairingWizard.stop(); + this._pairingWizard = null; + } + // Stay in current state — unpaired devices remain unpaired, + // paired devices keep enforcing + if (!this._credentials || !this._credentials.pairId) { + this._state = 'unpaired'; + } + } + + async openApp() { + if (this._state === 'unpaired' || (!this._credentials || !this._credentials.pairId)) { + // Start pairing flow + await this._startPairing(); + } else { + // Already paired — emit status info + this.emit('status-requested', { + state: this._state, + children: (this._credentials && this._credentials.children) || [], + currentChildId: this._childId, + // remaining time will be filled by checker if available + remaining: this._checker ? this._checker.getRemaining() : null, + }); + } + } + + /** + * Enter parent mode: stops enforcement, no restrictions applied. + */ + enterParentMode() { + if (this._checker) { + this._checker.stop(); + this._checker = null; + } + this._state = 'parent'; + this.emit('parent-mode', {}); + } + + /** + * Called by the platform layer after pairing completes externally + * (e.g., if the overlay handles pairing instead of the Express wizard). + * + * @param {object} credentials - { userId, pairId, pairToken, children } + */ + async onPairingComplete(credentials) { + await this._onPaired(credentials); + } + + // ---------------------------------------------------------------- + // Child Management + // ---------------------------------------------------------------- + + /** + * Switch to a different child (e.g., child selector UI). + * Stops current check loop, sets new child, restarts. + * + * @param {number} childId + * @param {string} [name] + */ + async selectChild(childId, name) { + if (this._checker) { + this._checker.stop(); + } + + this._childId = childId; + this._state = 'enforcing'; + this.emit('child-selected', { childId: childId, name: name || null }); + + this._updateLastUsed(childId); + + if (this._running && this._credentials) { + this._startChecker(); + } + } + + /** + * Handle a failed child PIN attempt. + * + * @param {number} childId + * @param {number} attemptsRemaining + */ + childPinFailed(childId, attemptsRemaining) { + this.emit('child-pin-failed', { + childId: childId, + attemptsRemaining: attemptsRemaining, + }); + + if (attemptsRemaining <= 0) { + this.emit('child-locked-out', { childId: childId }); + } + } + + /** + * Signal that the child session has timed out (e.g., idle timeout). + * Stops the checker and requests child re-identification. + */ + sessionTimeout() { + if (this._checker) { + this._checker.stop(); + this._checker = null; + } + this._childId = null; + this._state = 'paired'; + this.emit('session-timeout', {}); + + // Re-resolve child + if (this._running && this._credentials) { + this._resolveChild(); + } + } + + // ---------------------------------------------------------------- + // Requests (convenience wrappers) + // ---------------------------------------------------------------- + + /** + * Create a "Request More Time" request on behalf of the current child. + * + * @param {object} params + * @param {number} params.duration - Requested minutes + * @param {number} params.activity - Activity ID + * @param {string} [params.message] - Message to parent + * @returns {Promise} - { requestId, statusSecret } + */ + async requestMoreTime(params) { + if (!this._childId) { + throw new Error('No child selected'); + } + return this._api.createRequest({ + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + childId: this._childId, + duration: params.duration, + activity: params.activity, + message: params.message, + }); + } + + /** + * Poll the status of a pending request. + * + * @param {string} requestId + * @param {string} statusSecret + * @returns {Promise} + */ + async pollRequestStatus(requestId, statusSecret) { + const result = await this._api.getRequestStatus(requestId, statusSecret); + + if (result && result.status === 'approved') { + this.emit('request-approved', { + requestId: requestId, + activityId: result.activityId, + duration: result.duration, + }); + if (this._checker && result.activityId) { + this._checker.onTimeExtended(result.activityId); + } + } else if (result && result.status === 'denied') { + this.emit('request-denied', { + requestId: requestId, + reason: result.reason, + }); + } + + return result; + } + + // ---------------------------------------------------------------- + // Usage-Auth Events (plane-2) + // ---------------------------------------------------------------- + + /** + * Report a plane-2 usage-auth: the consuming enforcer calls this WHEN someone identifies + * to start a usage session on this device (account/child PIN accepted, an offline 6-digit / + * QR self-auth verified locally, or a local auto-identify). The server records a + * login-history row and notifies the account holder / other parents. NOTIFICATION + audit + * only — the device has already authorized locally (offline-first). + * + * Best-effort, mirroring logUsage: a single POST with NO offline queue/replay — the server + * does NOT dedup, so a replayed event would double-notify the parent. The SDK exposes the + * capability; the consumer decides exactly when to call it (e.g. on PIN success). + * + * @param {object} [params] + * @param {string} [params.method] - 'pin' | 'offline_code' | 'qr' (anything else => generic 'token') + * @param {number} [params.childId] - The person who authed; defaults to the currently selected child + * @returns {Promise<{ status: string }>} + */ + async reportAuthEvent(params) { + if (!this._credentials || !this._credentials.pairId || !this._credentials.pairToken) { + throw new Error('Device not paired'); + } + + var opts = params || {}; + var childId = opts.childId != null ? opts.childId : this._childId; + + var result = await this._api.reportAuthEvent({ + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + childId: childId != null ? childId : undefined, + method: opts.method, + }); + + this.emit('auth-event-reported', { + childId: childId != null ? childId : null, + method: opts.method || null, + }); + + return result; + } + + // ---------------------------------------------------------------- + // Feedback + // ---------------------------------------------------------------- + + /** + * Whether the current session user can submit feedback. + * Returns true if the user has an Allow2 account (parent mode, or + * child with a linked user account). + */ + get canSubmitFeedback() { + // Parent mode: always true (parent has an account by definition) + if (this._state === 'parent') return true; + + // Must be paired with credentials + if (!this._credentials || !this._credentials.userId) return false; + + // If a child is selected, check if they have a linked account + if (this._childId && this._credentials.children) { + var children = this._credentials.children; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if ((child.id || child.childId) === this._childId) { + // Child with linked user account can submit + return !!(child.LinkedUserId || child.linkedUserId); + } + } + return false; // child not found + } + + // Paired with userId = parent context + return !!this._credentials.userId; + } + + /** + * Submit feedback to the Allow2 server. + * + * @param {object} params + * @param {string} params.category - One of: bypass, missing_feature, not_working, question, other + * @param {string} params.message - Feedback message text + * @param {object} [params.deviceContext] - Optional override for device context fields + * @returns {Promise<{ discussionId: string }>} + */ + async submitFeedback(params) { + if (!this.canSubmitFeedback) { + throw new Error('Cannot submit feedback: no account associated'); + } + if (!params || !params.category || !params.message) { + throw new Error('category and message are required'); + } + + var validCategories = ['bypass', 'missing_feature', 'not_working', 'question', 'other']; + if (validCategories.indexOf(params.category) === -1) { + throw new Error('Invalid category. Must be one of: ' + validCategories.join(', ')); + } + + var context = { + deviceName: this._deviceName, + platform: 'unknown', + sdkVersion: '2.0.0', + productName: 'allow2', + }; + if (params.deviceContext) { + if (params.deviceContext.deviceName) context.deviceName = params.deviceContext.deviceName; + if (params.deviceContext.platform) context.platform = params.deviceContext.platform; + if (params.deviceContext.sdkVersion) context.sdkVersion = params.deviceContext.sdkVersion; + if (params.deviceContext.productName) context.productName = params.deviceContext.productName; + } + + var result = await this._api.submitFeedback({ + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + childId: this._childId, + vid: this._api.vid, + category: params.category, + message: params.message, + deviceContext: context, + }); + + this.emit('feedback-submitted', { + discussionId: result.discussionId, + category: params.category, + }); + + return result; + } + + /** + * Load all feedback discussions for this device. + * + * @returns {Promise<{ discussions: Array }>} + */ + async loadDeviceFeedback() { + if (!this._credentials || !this._credentials.pairId) { + throw new Error('Device not paired'); + } + + var result = await this._api.loadFeedback({ + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + }); + + this.emit('feedback-loaded', { + discussions: (result && result.discussions) || [], + }); + + return result; + } + + /** + * Reply to an existing feedback discussion. + * + * @param {string} discussionId - The discussion to reply to + * @param {string} message - The reply message + * @returns {Promise<{ messageId: string }>} + */ + async replyToFeedback(discussionId, message) { + if (!discussionId || !message) { + throw new Error('discussionId and message are required'); + } + + var result = await this._api.feedbackReply({ + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + discussionId: discussionId, + message: message, + }); + + this.emit('feedback-reply-sent', { + discussionId: discussionId, + messageId: result.messageId, + }); + + return result; + } + + /** + * Convert feedback params to a human-readable label. + * + * @param {object} params + * @param {object} params.feedback + * @param {string} params.feedback.category + * @returns {string} + */ + static feedbackParamsToText(params) { + if (!params || !params.feedback) return ''; + var labels = { + bypass: 'Bypass / Circumvention report', + missing_feature: 'Missing Feature report', + not_working: 'Not Working report', + question: 'Question', + other: 'General feedback', + }; + return labels[params.feedback.category] || 'Feedback'; + } + + // ---------------------------------------------------------------- + // Internal — Pairing + // ---------------------------------------------------------------- + + async _startPairing() { + // Guard: don't create a second pairing session if one is already active + if (this._state === 'pairing' && this._pairingWizard) { + return; + } + + this._state = 'pairing'; + + this._pairingWizard = new PairingWizard({ + api: this._api, + credentialBackend: this._credentialBackend, + port: this._pairingPort, + deviceName: this._deviceName, + }); + + var self = this; + + this._pairingWizard.on('paired', function (credentials) { + self._pairingWizard = null; + self._onPaired(credentials); + }); + + this._pairingWizard.on('error', function (err) { + self.emit('pairing-error', err); + }); + + this._pairingWizard.on('connection-status', function (status) { + self.emit('pairing-connection-status', status); + }); + + try { + var info = await this._pairingWizard.start(); + + // Emit event so platform layer can show the PIN and QR code + // qrUrl is the deep link: https://app.allow2.com/pair?pin=XXXXXX + this.emit('pairing-required', { + wizard: this._pairingWizard, + pin: info.pin, + port: info.port, + url: info.url, + qrUrl: info.qrUrl, + connected: info.connected, + }); + } catch (err) { + this.emit('pairing-error', err); + } + } + + async _onPaired(credentials) { + this._credentials = credentials; + this._state = 'paired'; + + this.emit('paired', { + userId: credentials.userId, + children: credentials.children, + }); + + if (this._running) { + await this._beginEnforcement(); + } + } + + // ---------------------------------------------------------------- + // Internal — Heartbeat (paired but no checker running) + // ---------------------------------------------------------------- + + /** + * Start a lightweight heartbeat poll that validates credentials + * and refreshes children data. Runs when paired but no child is + * selected (no checker running). Detects 401 → unpair. + */ + _startHeartbeat() { + this._stopHeartbeat(); + + var self = this; + this._heartbeatTimer = setInterval(function () { + // Stop if checker took over or we're no longer paired + if (self._checker || !self._credentials || !self._credentials.pairId) { + self._stopHeartbeat(); + return; + } + + self._api.getUpdates({ + userId: self._credentials.userId, + pairId: self._credentials.pairId, + pairToken: self._credentials.pairToken, + }).then(function (result) { + // Refresh children if data returned + if (result && result.children) { + self._credentials.children = result.children; + self.emit('children-updated', { children: result.children }); + } + }).catch(function (err) { + if (err && err.status === 401) { + self._stopHeartbeat(); + self._state = 'unpaired'; + self._credentials = null; + self._childId = null; + // Clear stored credentials + if (self._credentialBackend && typeof self._credentialBackend.clear === 'function') { + self._credentialBackend.clear().catch(function () { /* best effort */ }); + } + self.emit('unpaired', { error: err }); + } + // Other errors (network) — just retry next interval + }); + }, 60000); // every 60 seconds + } + + _stopHeartbeat() { + if (this._heartbeatTimer) { + clearInterval(this._heartbeatTimer); + this._heartbeatTimer = null; + } + } + + // ---------------------------------------------------------------- + // Internal — Enforcement + // ---------------------------------------------------------------- + + async _beginEnforcement() { + // Resolve which child is using the device + await this._resolveChild(); + + // If child was resolved, start the check loop + if (this._childId) { + this._state = 'enforcing'; + this._stopHeartbeat(); + this._startChecker(); + } else { + // No child selected yet — start heartbeat to validate credentials + // and detect 401 (device released) while waiting on the selector + this._startHeartbeat(); + } + } + + async _resolveChild() { + const children = (this._credentials && this._credentials.children) || []; + + // Annotate children with lastUsedAt from credential backend + var annotatedChildren = []; + for (var i = 0; i < children.length; i++) { + var child = Object.assign({}, children[i]); + child.lastUsedAt = null; + annotatedChildren.push(child); + } + + if (this._credentialBackend && typeof this._credentialBackend.loadLastUsed === 'function') { + try { + var lastUsedMap = await this._credentialBackend.loadLastUsed(); + if (lastUsedMap) { + for (var j = 0; j < annotatedChildren.length; j++) { + var childId = annotatedChildren[j].id || annotatedChildren[j].childId; + if (childId && lastUsedMap[childId]) { + annotatedChildren[j].lastUsedAt = lastUsedMap[childId]; + } + } + } + } catch (err) { + // Non-critical — proceed without lastUsedAt data + console.error('Failed to load lastUsed data:', err.message); + } + } + + // Sort by lastUsedAt descending (most recent first), nulls last + // ISO 8601 strings sort correctly with localeCompare + annotatedChildren.sort(function (a, b) { + if (a.lastUsedAt && b.lastUsedAt) { + return b.lastUsedAt.localeCompare(a.lastUsedAt); + } + if (a.lastUsedAt && !b.lastUsedAt) return -1; + if (!a.lastUsedAt && b.lastUsedAt) return 1; + return 0; + }); + + // Try automatic resolution (OS username mapping, etc.) + var match = null; + if (typeof this._childResolver === 'function') { + match = this._childResolver(annotatedChildren); + } else if (this._childResolver && typeof this._childResolver.resolve === 'function') { + match = await this._childResolver.resolve(annotatedChildren); + } + + if (match && match.childId) { + this._childId = match.childId; + this._state = 'enforcing'; + this._updateLastUsed(match.childId); + this.emit('child-selected', { childId: match.childId, name: match.childName || null }); + } else { + // No automatic match — need interactive selection + this.emit('child-select-required', { children: annotatedChildren }); + } + } + + _startChecker() { + if (this._checker) { + this._checker.stop(); + } + + var self = this; + + this._checker = new Checker({ + api: this._api, + emit: this.emit.bind(this), + credentials: this._credentials, + childId: this._childId, + activities: this._activities, + checkInterval: this._checkInterval, + hardLockTimeout: this._hardLockTimeout, + gracePeriod: this._gracePeriod, + warningThresholds: this._warningThresholds, + }); + + // Listen for unpaired events from the checker (HTTP 401) + this.on('unpaired', function onUnpaired() { + self._state = 'unpaired'; + self._credentials = null; + if (self._checker) { + self._checker.stop(); + self._checker = null; + } + self._childId = null; + // Remove this one-shot listener + self.removeListener('unpaired', onUnpaired); + }); + + this._checker.start(); + } + + /** + * Persist the last-used timestamp for a child to the credential backend. + * + * @param {number} childId + */ + _updateLastUsed(childId) { + if (this._credentialBackend && typeof this._credentialBackend.updateLastUsed === 'function') { + this._credentialBackend.updateLastUsed(childId).catch(function (err) { + console.error('Failed to update lastUsed for child ' + childId + ':', err.message); + }); + } + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a28e8f6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,28 @@ +/** + * Allow2 Device SDK v2 + * + * Parental controls for apps and devices. + * https://developer.allow2.com + */ + +// Core +import { DeviceDaemon } from './daemon.js'; +export { DeviceDaemon }; +export { ChildShield } from './child-shield.js'; +export { PairingWizard } from './pairing.js'; +export { Allow2Api } from './api.js'; + +// Utilities +export { UpdatePoller } from './updates.js'; +export { RequestManager } from './request.js'; +export { OfflineHandler } from './offline.js'; + +// Convenience re-exports (also available as static methods on their classes) +var feedbackParamsToText = DeviceDaemon.feedbackParamsToText; +export { feedbackParamsToText }; + +// Credential backends +export { createBackend, PlaintextBackend } from './credentials/index.js'; + +// Child resolvers +export { resolveChild as resolveLinuxUser } from './child-resolver/linux-user.js'; diff --git a/src/offline.js b/src/offline.js new file mode 100644 index 0000000..a7c9407 --- /dev/null +++ b/src/offline.js @@ -0,0 +1,132 @@ +/** + * Offline Handler + * + * Caches the last successful check result and enforces a grace period + * when the device loses connectivity. After the grace period expires, + * defaults to DENY (block all activities). + * + * Cache is held in memory and persisted to disk so it survives daemon restarts. + */ + +import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const DEFAULT_GRACE_PERIOD = 300; // seconds +const DEFAULT_CACHE_PATH = path.join(os.homedir(), '.allow2', 'cache.json'); + +export class OfflineHandler extends EventEmitter { + + /** + * @param {object} [options] + * @param {number} [options.gracePeriod] - Seconds before deny-by-default kicks in (default 300) + * @param {string} [options.cachePath] - Path to the disk cache file + */ + constructor(options = {}) { + super(); + this._gracePeriod = options.gracePeriod != null ? options.gracePeriod : DEFAULT_GRACE_PERIOD; + this._cachePath = options.cachePath || DEFAULT_CACHE_PATH; + this._cached = null; // { result, timestamp } + this._loaded = false; + } + + /** + * Store a successful check result in memory and persist to disk. + * + * @param {object} checkResult - The raw API check response + */ + async cacheResult(checkResult) { + this._cached = { + result: checkResult, + timestamp: Date.now(), + }; + + await this._writeDisk(this._cached); + } + + /** + * Return the cached check result, loading from disk on first call if needed. + * Returns null if no cache exists. + * + * @returns {Promise} The cached check result or null + */ + async getCachedResult() { + if (!this._loaded) { + await this._loadDisk(); + } + if (!this._cached) { + return null; + } + return this._cached.result; + } + + /** + * Seconds elapsed since the last successful check. + * Returns Infinity if no cached result exists. + * + * @returns {Promise} + */ + async getGraceElapsed() { + if (!this._loaded) { + await this._loadDisk(); + } + if (!this._cached) { + return Infinity; + } + return Math.floor((Date.now() - this._cached.timestamp) / 1000); + } + + /** + * True if we are still within the grace period. + * + * @returns {Promise} + */ + async isInGracePeriod() { + const elapsed = await this.getGraceElapsed(); + if (elapsed < this._gracePeriod) { + this.emit('offline-grace', elapsed); + return true; + } + return false; + } + + /** + * True if the grace period has expired and we should deny by default. + * + * @returns {Promise} + */ + async shouldDeny() { + const elapsed = await this.getGraceElapsed(); + if (elapsed >= this._gracePeriod) { + this.emit('offline-deny'); + return true; + } + return false; + } + + // ── Internal ────────────────────────────────────────────── + + async _writeDisk(data) { + try { + const dir = path.dirname(this._cachePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(this._cachePath, JSON.stringify(data), { mode: 0o600 }); + } catch (_err) { + // Disk write failure is non-fatal — memory cache still works + } + } + + async _loadDisk() { + this._loaded = true; + try { + const raw = await fs.readFile(this._cachePath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && parsed.result && typeof parsed.timestamp === 'number') { + this._cached = parsed; + } + } catch (_err) { + // No cache file or corrupt — start fresh + } + } +} diff --git a/src/pairing.js b/src/pairing.js new file mode 100644 index 0000000..b4769f8 --- /dev/null +++ b/src/pairing.js @@ -0,0 +1,697 @@ +/** + * Pairing Wizard + * + * Manages one-time device pairing with the Allow2 platform. + * Parents NEVER enter credentials on the child's device. + * + * Flow: + * 1. Wizard calls API to register a pairing session (initPINPairing) + * 2. API returns a server-assigned PIN and session ID + * 3. Device displays the PIN (and QR code deep link) to the user + * 4. Parent opens Allow2 app on their phone, enters the PIN (or scans QR code) + * 5. Wizard polls checkPairingStatus until parent confirms + * 6. On confirmation, receives credentials (userId, pairId, pairToken, children) + * 7. Stores credentials via the credential backend + * + * Optionally starts a local Express server on localhost for a web UI + * showing the PIN — this is a convenience, not required for pairing. + */ + +import { EventEmitter } from 'node:events'; +import crypto from 'node:crypto'; +import express from 'express'; + +export class PairingWizard extends EventEmitter { + + /** + * @param {object} options + * @param {import('./api.js').Allow2Api} options.api + * @param {object} options.credentialBackend - { store(creds), load(), clear() } + * @param {number} [options.port=3000] + * @param {string} [options.deviceName] - Human-readable device name + * @param {string} [options.uuid] - Persistent device UUID (generated if not provided) + */ + constructor(options) { + super(); + this._api = options.api; + this._credentialBackend = options.credentialBackend; + this._port = options.port || 3000; + this._deviceName = options.deviceName || 'Linux PC'; + this._uuid = options.uuid || null; + + this._pin = null; + this._sessionId = null; + this._paired = false; + this._pairingResult = null; + this._server = null; + this._app = null; + this._pollTimer = null; + this._qrUrl = null; + this._connected = false; // tracks server connectivity + this._consecutiveErrors = 0; + } + + /** + * Start the pairing flow: + * 1. Register with the Allow2 API to get a server-assigned PIN + * 2. Optionally start a local Express server for the web UI + * 3. Begin polling for parent confirmation + * + * @returns {Promise<{ pin: string, port: number, url: string, qrUrl: string }>} + */ + async start() { + // Guard: prevent double-start + if (this._pollTimer || this._initRetryTimer) { + console.warn('[pairing] start() called but already running — ignoring'); + return { pin: this._pin, port: this._port, url: 'http://localhost:' + this._port, qrUrl: this._qrUrl, connected: this._connected }; + } + + this._paired = false; + this._pairingResult = null; + + // Generate or reuse device UUID + if (!this._uuid) { + this._uuid = await this._loadOrCreateUuid(); + } + + // Register pairing session with the Allow2 API + var apiResult; + try { + console.log('[pairing] Calling initPINPairing (uuid=' + this._uuid + ', device=' + this._deviceName + ')'); + apiResult = await this._api.initPINPairing({ + uuid: this._uuid, + deviceName: this._deviceName, + }); + console.log('[pairing] API response: ' + JSON.stringify(apiResult)); + } catch (err) { + // If the API call fails, fall back to local-only PIN mode + // (e.g., API unreachable, VID not configured yet) + console.warn('[pairing] API initPINPairing failed: ' + (err.message || err) + + ' — falling back to local PIN mode'); + apiResult = null; + } + + if (apiResult && apiResult.pin) { + // Use server-assigned PIN and session ID + this._pin = String(apiResult.pin); + this._sessionId = apiResult.sessionId || apiResult.pairingSessionId || null; + this._connected = true; + this._consecutiveErrors = 0; + console.log('[pairing] Using server PIN: ' + this._pin + ' (session=' + this._sessionId + ')'); + } else { + // API unreachable — no valid PIN yet + this._pin = '------'; + this._sessionId = null; + this._connected = false; + console.warn('[pairing] API unreachable — will retry'); + // Start retry loop to get a valid session + this._startInitRetry(); + } + + // Build QR deep link URL + this._qrUrl = 'https://app.allow2.com/pair?pin=' + this._pin; + + // Start local Express server for web UI (optional, non-fatal if port busy) + var port = this._port; + try { + await this._startExpress(); + } catch (err) { + console.warn('[pairing] Express server failed on port ' + port + ': ' + + (err.message || err) + ' — pairing still works via PIN/QR'); + this._server = null; + this._app = null; + } + + // Start polling for pairing completion (if we have a session ID) + if (this._sessionId) { + this._startPolling(); + } + + var info = { + pin: this._pin, + port: port, + url: 'http://localhost:' + port, + qrUrl: this._qrUrl, + connected: this._connected, + }; + this.emit('started', { pin: this._pin, port: port, qrUrl: this._qrUrl, connected: this._connected }); + return info; + } + + /** + * Shut down: stop polling, stop Express server. + * @returns {Promise} + */ + async stop() { + if (this._pollTimer) { + clearInterval(this._pollTimer); + this._pollTimer = null; + } + if (this._initRetryTimer) { + clearInterval(this._initRetryTimer); + this._initRetryTimer = null; + } + + if (!this._server) return; + + return new Promise((resolve) => { + this._server.close(() => { + this._server = null; + this._app = null; + resolve(); + }); + }); + } + + /** + * Return the current 6-digit PIN. + * @returns {string|null} + */ + getPin() { + return this._pin; + } + + /** + * Return the QR deep link URL. + * @returns {string|null} + */ + getQrUrl() { + return this._qrUrl; + } + + /** + * Called when pairing is confirmed (either via API polling or local callback). + * Stores credentials via the credential backend and emits 'paired'. + * + * @param {object} pairingData - Data received from the Allow2 API + * @param {number} pairingData.userId - Controller's user ID + * @param {number} pairingData.pairId - Pairing ID + * @param {string} pairingData.pairToken - Pairing token for subsequent API calls + * @param {Array} [pairingData.children] - List of children on this account + */ + async completePairing(pairingData) { + try { + if (!pairingData.userId || !pairingData.pairId || !pairingData.pairToken) { + throw new Error('Pairing callback missing required fields (userId, pairId, pairToken)'); + } + + var credentials = { + uuid: this._uuid, + userId: pairingData.userId, + pairId: pairingData.pairId, + pairToken: pairingData.pairToken, + children: pairingData.children || [], + }; + + // Persist credentials + await this._credentialBackend.store(credentials); + + this._paired = true; + this._pairingResult = credentials; + + this.emit('paired', credentials); + + // Auto-terminate the wizard after successful pairing + await this.stop(); + + return credentials; + } catch (err) { + this.emit('error', err); + throw err; + } + } + + // ── Internal ────────────────────────────────────────────── + + /** + * Load existing device UUID from credential backend, or create one. + */ + async _loadOrCreateUuid() { + try { + var creds = await this._credentialBackend.load(); + if (creds && creds.uuid) { + return creds.uuid; + } + } catch (_err) { /* no creds yet */ } + + // Generate and persist a new UUID + var uuid = crypto.randomUUID(); + try { + // Store just the UUID for now (credentials will be overwritten on pairing) + await this._credentialBackend.store({ uuid: uuid }); + } catch (_err) { /* best effort */ } + return uuid; + } + + /** + * Retry initPINPairing when the initial attempt failed (no connectivity). + * Retries every 5s until a valid session is obtained. + */ + _startInitRetry() { + if (this._initRetryTimer) return; + + var self = this; + this._initRetryTimer = setInterval(async function () { + if (self._paired || self._sessionId) { + clearInterval(self._initRetryTimer); + self._initRetryTimer = null; + return; + } + + try { + var result = await self._api.initPINPairing({ + uuid: self._uuid, + deviceName: self._deviceName, + }); + + if (result && result.pin) { + clearInterval(self._initRetryTimer); + self._initRetryTimer = null; + + self._pin = String(result.pin); + self._sessionId = result.sessionId || result.pairingSessionId || null; + self._qrUrl = 'https://app.allow2.com/pair?pin=' + self._pin; + self._connected = true; + self._consecutiveErrors = 0; + + console.log('[pairing] Reconnected! PIN: ' + self._pin + ' (session=' + self._sessionId + ')'); + self.emit('connection-status', { connected: true, pin: self._pin, qrUrl: self._qrUrl }); + + // Now start polling for parent confirmation + self._startPolling(); + } + } catch (err) { + console.warn('[pairing] Init retry failed:', err.message); + self.emit('connection-status', { connected: false }); + } + }, 5000); + } + + /** + * Start polling the Allow2 API for pairing confirmation. + */ + _startPolling() { + if (this._pollTimer) return; + + var self = this; + var pollCount = 0; + var maxPolls = 360; // 30 minutes at 5s intervals + + this._pollTimer = setInterval(function () { + if (self._paired) { + clearInterval(self._pollTimer); + self._pollTimer = null; + return; + } + + pollCount++; + if (pollCount > maxPolls) { + clearInterval(self._pollTimer); + self._pollTimer = null; + self.emit('error', new Error('Pairing timed out after 30 minutes')); + return; + } + + self._api.checkPairingStatus(self._sessionId).then(function (result) { + if (result && result.paired && result.userId && result.pairId && result.pairToken) { + self.completePairing(result); + return; + } + // Successful poll — mark as connected + if (!self._connected) { + self._connected = true; + self._consecutiveErrors = 0; + console.log('[pairing] Connection restored'); + self.emit('connection-status', { connected: true }); + } + }).catch(function (err) { + self._consecutiveErrors++; + // After 2 consecutive failures (~10s), mark as disconnected + if (self._consecutiveErrors >= 2 && self._connected) { + self._connected = false; + console.warn('[pairing] Connection lost:', err.message); + self.emit('connection-status', { connected: false }); + } + if (pollCount % 12 === 0) { // log every minute + console.warn('[pairing] Poll error:', err.message); + } + }); + }, 5000); + } + + /** + * Start the local Express server for the web UI. + */ + _startExpress() { + var self = this; + this._app = express(); + this._app.use(express.json()); + this._setupRoutes(); + + return new Promise(function (resolve, reject) { + try { + self._server = self._app.listen(self._port, function () { + resolve(); + }); + + self._server.on('error', function (err) { + reject(err); + }); + } catch (err) { + reject(err); + } + }); + } + + _setupRoutes() { + var self = this; + + // GET / — Serve the pairing page + this._app.get('/', function (_req, res) { + res.type('html').send(_buildPairingPage(self._pin, self._port, self._qrUrl)); + }); + + // GET /status — Polling endpoint for the web page + this._app.get('/status', function (_req, res) { + res.json({ + paired: self._paired, + result: self._paired ? { userId: self._pairingResult.userId } : null, + }); + }); + + // POST /pair-callback — Receive pairing data from Allow2 server (legacy callback) + this._app.post('/pair-callback', async function (req, res) { + if (self._paired) { + res.status(409).json({ error: 'Already paired' }); + return; + } + + var body = req.body; + if (!body || !body.userId || !body.pairId || !body.pairToken) { + res.status(400).json({ error: 'Missing required fields (userId, pairId, pairToken)' }); + return; + } + + try { + var credentials = await self.completePairing(body); + res.json({ success: true, userId: credentials.userId }); + } catch (err) { + res.status(500).json({ error: err.message || 'Pairing failed' }); + } + }); + + // GET /success — Success confirmation page + this._app.get('/success', function (_req, res) { + res.type('html').send(_buildSuccessPage()); + }); + } +} + +// ── Helpers ──────────────────────────────────────────────────── + +/** + * Generate a cryptographically random 6-digit PIN. + * @returns {string} + */ +function _generatePin() { + var num = crypto.randomInt(0, 1000000); + return String(num).padStart(6, '0'); +} + +/** + * Build the self-contained HTML pairing page. + * @param {string} pin + * @param {number} port + * @param {string} qrUrl + * @returns {string} + */ +function _buildPairingPage(pin, port, qrUrl) { + // Split PIN into individual digits for display + var digits = pin.split('').map(function(d) { + return '' + d + ''; + }).join(''); + + return '\n' + +'\n' + +'\n' + +'\n' + +'\n' + +'Allow2 - Device Pairing\n' + +'\n' + +'\n' + +'\n' + +'
\n' + +' \n' + +'
Device Pairing
\n' + +'\n' + +'

Enter this PIN in the Allow2 app on your phone to pair this device.

\n' + +'\n' + +'
' + digits + '
\n' + +'\n' + +'
    \n' + +'
  1. Open the Allow2 app on your phone
  2. \n' + +'
  3. Go to Devices and tap Add Device
  4. \n' + +'
  5. Enter the 6-digit PIN shown above
  6. \n' + +'
  7. This page will update automatically when paired
  8. \n' + +'
\n' + +'\n' + +'
\n' + +'
\n' + +' Waiting for parent to confirm...\n' + +'
\n' + +'
\n' + +'
\n' + +'\n' + +'\n' + +'\n' + +''; +} + +/** + * Build the success page shown after pairing completes. + * @returns {string} + */ +function _buildSuccessPage() { + return '\n' + +'\n' + +'\n' + +'\n' + +'\n' + +'Allow2 - Paired Successfully\n' + +'\n' + +'\n' + +'\n' + +'
\n' + +' \n' + +'
\n' + +'
Device Paired Successfully
\n' + +'

This device is now connected to your Allow2 account.
You can close this window.

\n' + +'
\n' + +'\n' + +''; +} diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000..8b1e0c4 --- /dev/null +++ b/src/request.js @@ -0,0 +1,123 @@ +/** + * Request More Time + * + * Lets a child request additional time for an activity. + * Creates the request via the Allow2 API, then polls for + * parent approval/denial. Emits events as the status changes. + */ + +import { EventEmitter } from 'node:events'; + +const DEFAULT_POLL_INTERVAL = 5000; // 5 seconds +const DEFAULT_TIMEOUT = 300000; // 5 minutes + +export class RequestManager extends EventEmitter { + + /** + * @param {object} options + * @param {import('./api.js').Allow2Api} options.api - Allow2Api instance + * @param {number} [options.pollInterval] - Polling interval in ms (default 5000) + * @param {number} [options.timeout] - Max wait time in ms (default 300000) + */ + constructor(options) { + super(); + this._api = options.api; + this._pollInterval = options.pollInterval || DEFAULT_POLL_INTERVAL; + this._timeout = options.timeout || DEFAULT_TIMEOUT; + this._pollTimer = null; + this._timeoutTimer = null; + } + + /** + * Submit a "request more time" to the Allow2 API and begin polling. + * + * @param {object} params + * @param {number} params.userId + * @param {number} params.pairId + * @param {string} params.pairToken + * @param {number} params.childId + * @param {number} params.duration - Minutes requested + * @param {number} params.activity - Activity ID + * @param {string} [params.message] - Optional message to parent + * @returns {Promise<{ requestId: string, statusSecret: string }>} + */ + async createRequest(params) { + try { + const response = await this._api.createRequest(params); + const requestId = response.requestId; + const statusSecret = response.statusSecret; + + this.emit('request-created', { requestId: requestId }); + this.startPolling(requestId, statusSecret); + + return { requestId: requestId, statusSecret: statusSecret }; + } catch (err) { + this.emit('request-error', err); + throw err; + } + } + + /** + * Begin polling the request status endpoint. + * + * @param {string} requestId + * @param {string} statusSecret + */ + startPolling(requestId, statusSecret) { + this.stopPolling(); + + // Timeout — give up after configured duration + this._timeoutTimer = setTimeout(() => { + this.stopPolling(); + this.emit('request-timeout'); + }, this._timeout); + + this._poll(requestId, statusSecret); + } + + /** + * Cancel any active polling. + */ + stopPolling() { + if (this._pollTimer) { + clearTimeout(this._pollTimer); + this._pollTimer = null; + } + if (this._timeoutTimer) { + clearTimeout(this._timeoutTimer); + this._timeoutTimer = null; + } + } + + // ── Internal ────────────────────────────────────────────── + + _poll(requestId, statusSecret) { + this._pollTimer = setTimeout(async () => { + try { + const status = await this._api.getRequestStatus(requestId, statusSecret); + + if (status.status === 'approved') { + this.stopPolling(); + this.emit('request-approved', { + requestId: requestId, + extension: status.extension, + }); + return; + } + + if (status.status === 'denied') { + this.stopPolling(); + this.emit('request-denied', { requestId: requestId }); + return; + } + + // Still pending — schedule next poll + this._poll(requestId, statusSecret); + } catch (err) { + this.emit('request-error', err); + // Keep polling despite transient errors + this._poll(requestId, statusSecret); + } + }, this._pollInterval); + } +} diff --git a/src/updates.js b/src/updates.js new file mode 100644 index 0000000..88c98e5 --- /dev/null +++ b/src/updates.js @@ -0,0 +1,178 @@ +/** + * Update Poller + * + * Polls GET /api/getUpdates for changes since the last check. + * Emits granular events for extensions, day type changes, quota updates, + * bans, and children list refreshes. + */ + +import { EventEmitter } from 'node:events'; + +export class UpdatePoller extends EventEmitter { + + /** + * @param {object} options + * @param {import('./api.js').Allow2Api} options.api + * @param {number} [options.pollInterval=30000] - Milliseconds between polls + */ + constructor(options) { + super(); + this._api = options.api; + this._pollInterval = options.pollInterval || 30000; + + this._credentials = null; + this._lastTimestamp = null; + this._timer = null; + this._running = false; + } + + /** + * Begin polling with the given credentials. + * + * @param {object} credentials + * @param {number|string} credentials.userId + * @param {number|string} credentials.pairId + * @param {string} credentials.pairToken + * @param {string} credentials.deviceToken + */ + start(credentials) { + if (this._running) return; + + this._credentials = credentials; + this._running = true; + this._poll(); + } + + /** + * Stop polling. + */ + stop() { + this._running = false; + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + } + + // ── Internal ────────────────────────────────────────────── + + async _poll() { + if (!this._running) return; + + try { + await this._fetchUpdates(); + } catch (err) { + this._handleError(err); + } + + if (this._running) { + this._timer = setTimeout(() => this._poll(), this._pollInterval); + } + } + + async _fetchUpdates() { + const params = { + userId: this._credentials.userId, + pairId: this._credentials.pairId, + pairToken: this._credentials.pairToken, + deviceToken: this._credentials.deviceToken, + }; + + if (this._lastTimestamp) { + params.timestampMillis = this._lastTimestamp; + } + + const result = await this._api.getUpdates(params); + + // Advance the timestamp so the next poll only gets deltas + if (result && result.timestampMillis) { + this._lastTimestamp = result.timestampMillis; + } + + this._processUpdates(result); + } + + /** + * Parse the getUpdates response and emit appropriate events. + * + * Expected response shape: + * { + * timestampMillis: number, + * extensions: [{ childId, activity, additionalMinutes }], + * dayTypeChanges: [{ childId, dayType }], + * quotaUpdates: [{ childId, activity, newQuota }], + * bans: [{ childId, activity, banned }], + * children: [{ id, name, pin, ... }] + * } + */ + _processUpdates(result) { + if (!result) return; + + // Extensions — parent approved extra time + const extensions = result.extensions; + if (extensions && extensions.length > 0) { + for (let i = 0; i < extensions.length; i++) { + this.emit('extension', { + childId: extensions[i].childId, + activity: extensions[i].activity, + additionalMinutes: extensions[i].additionalMinutes, + }); + } + } + + // Day type changes — e.g. school day switched to holiday + const dayTypeChanges = result.dayTypeChanges; + if (dayTypeChanges && dayTypeChanges.length > 0) { + for (let j = 0; j < dayTypeChanges.length; j++) { + this.emit('day-type-changed', { + childId: dayTypeChanges[j].childId, + dayType: dayTypeChanges[j].dayType, + }); + } + } + + // Quota updates — daily limit changed + const quotaUpdates = result.quotaUpdates; + if (quotaUpdates && quotaUpdates.length > 0) { + for (let k = 0; k < quotaUpdates.length; k++) { + this.emit('quota-updated', { + childId: quotaUpdates[k].childId, + activity: quotaUpdates[k].activity, + newQuota: quotaUpdates[k].newQuota, + }); + } + } + + // Bans — activity banned/unbanned + const bans = result.bans; + if (bans && bans.length > 0) { + for (let m = 0; m < bans.length; m++) { + this.emit('ban', { + childId: bans[m].childId, + activity: bans[m].activity, + banned: bans[m].banned, + }); + } + } + + // Children list refresh — names, PINs, added/removed children + const children = result.children; + if (children && children.length > 0) { + this.emit('children-updated', children); + } + } + + /** + * Handle errors from the polling loop. + * HTTP 401 indicates the device has been unpaired. + */ + _handleError(err) { + if (err && err.status === 401) { + this.emit('unpaired', { error: err }); + this.stop(); + return; + } + + this.emit('error', err); + } +} diff --git a/src/warnings.js b/src/warnings.js new file mode 100644 index 0000000..2775b87 --- /dev/null +++ b/src/warnings.js @@ -0,0 +1,86 @@ +/** + * Warning Scheduler + * + * Tracks remaining time per activity and emits 'warning' events + * when configurable thresholds are crossed. Prevents duplicate + * warnings for the same level+activity combination. + */ + +const DEFAULT_THRESHOLDS = [ + { remaining: 15 * 60, level: 'info' }, + { remaining: 5 * 60, level: 'urgent' }, + { remaining: 60, level: 'final' }, + { remaining: 30, level: 'countdown' }, +]; + +export class WarningScheduler { + + /** + * @param {object} options + * @param {Function} options.emit - The EventEmitter emit function to call + * @param {Array} [options.thresholds] - Warning thresholds sorted descending by remaining + */ + constructor(options) { + this._emit = options.emit; + this._thresholds = (options.thresholds || DEFAULT_THRESHOLDS) + .slice() + .sort((a, b) => b.remaining - a.remaining); + + // Map> — tracks which warnings have fired + this._fired = new Map(); + } + + /** + * Called by the Checker after each check response. + * Evaluates every activity's remaining time against thresholds. + * + * @param {Object} activities + * Keys are activity IDs (as strings), values have at least `remaining` in seconds. + */ + update(activities) { + const ids = Object.keys(activities); + for (let i = 0; i < ids.length; i++) { + const activityId = ids[i]; + const remaining = activities[activityId].remaining; + + if (remaining == null || remaining < 0) { + continue; + } + + let firedSet = this._fired.get(activityId); + if (!firedSet) { + firedSet = new Set(); + this._fired.set(activityId, firedSet); + } + + for (let t = 0; t < this._thresholds.length; t++) { + const threshold = this._thresholds[t]; + if (remaining <= threshold.remaining && !firedSet.has(threshold.level)) { + firedSet.add(threshold.level); + this._emit('warning', { + level: threshold.level, + activityId: activityId, + remaining: remaining, + }); + } + } + } + } + + /** + * Reset warnings for an activity (e.g., parent approved more time). + * Next check cycle will re-evaluate thresholds from scratch. + * + * @param {string} activityId + */ + resetActivity(activityId) { + this._fired.delete(activityId); + } + + /** + * Reset all warning state (e.g., new child session). + */ + resetAll() { + this._fired.clear(); + } +} diff --git a/tests/api.authEvent.test.js b/tests/api.authEvent.test.js new file mode 100644 index 0000000..c97e9b7 --- /dev/null +++ b/tests/api.authEvent.test.js @@ -0,0 +1,116 @@ +/** + * Tests for the plane-2 usage-auth report path: + * - Allow2Api.reportAuthEvent -> POST /api/authEvent with the pairToken seam + method/childId + * - DeviceDaemon.reportAuthEvent -> pulls creds, defaults childId to the selected child, emits event + * + * The HTTP layer is mocked by stubbing global.fetch, so no live endpoint is touched. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Allow2Api } from '../src/api.js'; +import { DeviceDaemon } from '../src/daemon.js'; + +/** Install a fake fetch that records the call and returns a JSON 200. Returns { calls, restore }. */ +function stubFetch(responseBody = { status: 'success' }, status = 200) { + const original = global.fetch; + const calls = []; + global.fetch = async function (url, options) { + calls.push({ url, options }); + return { + ok: status >= 200 && status < 300, + status, + async json() { return responseBody; }, + }; + }; + return { calls, restore() { global.fetch = original; } }; +} + +test('Allow2Api.reportAuthEvent POSTs the exact /api/authEvent contract', async () => { + const { calls, restore } = stubFetch({ status: 'success' }); + try { + const api = new Allow2Api({ apiUrl: 'https://example.test', vid: 42, token: 'devtok' }); + const res = await api.reportAuthEvent({ + userId: 'owner-uuid', + pairId: 7, + pairToken: 'pair-secret', + childId: 'child-uuid', + method: 'pin', + }); + + assert.equal(res.status, 'success'); + assert.equal(calls.length, 1); + + const { url, options } = calls[0]; + assert.equal(url, 'https://example.test/api/authEvent'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/json'); + + const body = JSON.parse(options.body); + assert.deepEqual(body, { + userId: 'owner-uuid', + pairId: 7, + pairToken: 'pair-secret', + deviceToken: 'devtok', // pulled from the client's version token (like logUsage) + childId: 'child-uuid', + method: 'pin', + }); + } finally { + restore(); + } +}); + +test('Allow2Api.reportAuthEvent surfaces a 401 as an error (best-effort, no swallow)', async () => { + const { restore } = stubFetch({ status: 'error', message: 'Invalid request.' }, 401); + try { + const api = new Allow2Api({ apiUrl: 'https://example.test', vid: 42, token: 'devtok' }); + await assert.rejects( + () => api.reportAuthEvent({ userId: 'o', pairId: 1, pairToken: 'p', method: 'pin' }), + (err) => err.status === 401, + ); + } finally { + restore(); + } +}); + +test('DeviceDaemon.reportAuthEvent uses stored creds, defaults childId, and emits', async () => { + const daemon = new DeviceDaemon({ + activities: [{ id: 1 }], + credentialBackend: { async load() { return null; }, async store() {}, async clear() {} }, + childResolver: { resolve() { return null; } }, + }); + + // Simulate a paired + child-selected device without touching the network. + daemon._credentials = { userId: 'owner-uuid', pairId: 7, pairToken: 'pair-secret', children: [] }; + daemon._childId = 'selected-child'; + + let apiParams = null; + daemon._api.reportAuthEvent = async (params) => { apiParams = params; return { status: 'success' }; }; + + let emitted = null; + daemon.on('auth-event-reported', (e) => { emitted = e; }); + + const res = await daemon.reportAuthEvent({ method: 'offline_code' }); + + assert.equal(res.status, 'success'); + assert.deepEqual(apiParams, { + userId: 'owner-uuid', + pairId: 7, + pairToken: 'pair-secret', + childId: 'selected-child', // defaulted from the selected child + method: 'offline_code', + }); + assert.deepEqual(emitted, { childId: 'selected-child', method: 'offline_code' }); +}); + +test('DeviceDaemon.reportAuthEvent throws when the device is not paired', async () => { + const daemon = new DeviceDaemon({ + activities: [{ id: 1 }], + credentialBackend: { async load() { return null; }, async store() {}, async clear() {} }, + childResolver: { resolve() { return null; } }, + }); + daemon._credentials = null; + + await assert.rejects(() => daemon.reportAuthEvent({ method: 'pin' }), /not paired/i); +});