diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..53eff8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check . + + - name: Type check with mypy + run: mypy . + + - name: Run tests + run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6d8643a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,62 @@ +name: Publish + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-pypi: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore deleted file mode 100644 index ff65a43..0000000 --- a/.gitignore +++ /dev/null @@ -1,97 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv -.env - -# virtualenv -.venv -venv/ -ENV/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 262d51c..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,46 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ceo@allow2.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index a3f598e..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,81 +0,0 @@ -# Contributing to allow2 - -:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: - -The following is a set of guidelines for contributing to Allow2 and its packages, which are hosted in the [Allow2 Organization](https://github.com/Allow2) on GitHub. -These are just guidelines, not rules, use your best judgment and feel free to propose changes to this document in a pull request. - - -## Submitting an Issue - -1. Provide a small self **sufficient** code example to **reproduce** the issue. -2. You should **always** use fenced code blocks when submitting code examples or any other formatted output: -
- ```js - put your javascript code here - ``` - - ``` - put any other formatted output here, - like for example the one returned from using request-debug - ``` -- -If the problem cannot be reliably reproduced, the issue will be marked as `Not enough info (see CONTRIBUTING.md)`. - -If the problem is not related to request the issue will be marked as `Help (please use Stackoverflow)`. - - -## Submitting a Pull Request - -1. In almost all of the cases your PR **needs tests**. Make sure you have any. -2. Run `npm test` locally. Fix any errors before pushing to GitHub. -3. After submitting the PR a build will be triggered on TravisCI. Wait for it to ends and make sure all jobs are passing. - -NOTE: We need to implement and complete unit testing to make 2 and 3 work, -feel free to implement that first! - ------------------------------------------ - - -## Becoming a Contributor - -Individuals making significant and valuable contributions are given -commit-access to the project to contribute as they see fit. This project is -more like an open wiki than a standard guarded open source project. - - -## Rules - -There are a few basic ground-rules for contributors: - -1. **No `--force` pushes** or modifying the Git history in any way. -1. **Non-master branches** ought to be used for ongoing work. -1. **Any** change should be added through Pull Request. -1. **External API changes and significant modifications** ought to be subject - to an **internal pull-request** to solicit feedback from other contributors. -1. Internal pull-requests to solicit feedback are *encouraged* for any other - non-trivial contribution but left to the discretion of the contributor. -1. For significant changes wait a full 24 hours before merging so that active - contributors who are distributed throughout the world have a chance to weigh - in. -1. Contributors should attempt to adhere to the prevailing code-style. -1. Run `npm test` locally before submitting your PR, to catch any easy to miss - style & testing issues. To diagnose test failures, there are two ways to - run a single test file: - - `node_modules/.bin/taper tests/test-file.js` - run using the default - [`taper`](https://github.com/nylen/taper) test reporter. - - `node tests/test-file.js` - view the raw - [tap](https://testanything.org/) output. - - -## Releases - -Declaring formal releases remains the prerogative of the project maintainer. - - -## Changes to this arrangement - -This is an experiment and feedback is welcome! This document may also be -subject to pull-requests or changes by contributors where you believe you have -something valuable to add or change. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bce3d4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +Copyright (c) 2024-2026 Allow2 Pty Ltd + +All rights reserved. + +SEE LICENSE IN LICENSE FILE at https://github.com/Allow2/allow2python diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 8ba0269..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,439 +0,0 @@ -API and SDK Licence and Service Agreement - -Allow2 Pty Ltd (Australia) Version 1.0 13 April 2014 - -Background - -ALLOW2 PTY LTD (ALLOW2) IS THE PROVIDER AND OWNER OF THE ALLOW2 SERVICE DESCRIBED IN THE SERVICE DESCRIPTION -SET OUT AT HTTP://WWW.ALLOW2.COM. ACCESS TO THE ALLOW2 SERVICE IS AVAILABLE TO REGISTERED DEVELOPERS FOR INTEGRATION -INTO THEIR SOFTWARE PRODUCTS VIA THE ALLOW2 APPLICATION PROGRAMMING INTERFACE (API) AND SOFTWARE DEVELOPMENT KIT (SDK) -RELEASED BY ALLOW2 PTY LTD TO REGISTERED DEVELOPERS. THIS API AND SDK LICENCE AND SERVICE AGREEMENT SETS OUT THE TERMS -AND CONDITIONS UPON WHICH ALLOW2 AGREES TO PROVIDE ACCESS TO THE ALLOW2 SERVICE, AND GRANTS A LICENCE TO USE THE ALLOW2 -API AND SDK IN REGISTERED DEVELOPERS’ PRODUCTS. YOU MAY ONLY USE THE ALLOW2 SERVICE AND/OR THE ALLOW2 API OR SDK IF YOU -ARE A REGISTERED DEVELOPER AND ACCEPT THESE TERMS OF USE. BY DOWNLOADING AND/OR USING AND/OR MODIFYING AND/OR -INTEGRATING THE ALLOW2 API INTO A SOFTWARE PRODUCT, YOU CONFIRM THAT YOU HAVE READ AND UNDERSTAND AND WHOLLY AND -UNCONDITIONALLY AGREE TO BE LEGALLY BOUND BY AND ACCEPT THE TERMS AND CONDITIONS OF THIS API AND SDK LICENCE AND -SERVICE AGREEMENT. WE MAY MODIFY AND/OR REPLACE THESE TERMS OF USE FROM TIME TO TIME WITHOUT NOTICE. WE WILL UPLOAD THE -LATEST VERSION TO THIS WEBPAGE. IT IS YOUR RESPONSIBILITY TO ENSURE THAT YOU HAVE READ AND UNDERSTAND THE LATEST VERSION -OF THIS API AND SDK LICENCE AND SERVICE AGREEMENT. IF YOU DO NOT ACCEPT THE TERMS AND CONDITIONS SET OUT IN THIS API AND -SDK LICENCE AND SERVICE AGREEMENT (AS AMENDED FROM TIME TO TIME), YOU MUST NOT REGISTER AN ACCOUNT AND CANNOT DOWNLOAD -AND/OR USE AND/OR MODIFY AND/OR INTEGRATE THE ALLOW2 API OR SDK INTO A SOFTWARE PRODUCT AND MUST IMMEDIATELY DISCONTINUE -ANY USE OF THE ALLOW2 API, SDK AND ALLOW2 SERVICE IN ALL OF YOUR SOFTWARE PRODUCTS. - -TERMS AND CONDITIONS: -1. Definitions and Interpretation - 1.1. Definitions - - In these Terms of Use: - - Account - has the meaning given in clause 3.1. - Allow2 - means Allow2 Pty Ltd [ACN 159048094], a company registered in Queensland, Australia. - Allow2 API - means the source code and object code described in the Service Description as the Allow2 API, and any - modification, translation or derivative of the Allow2 API including any Modified API Version. Note that - this License is intended to cover any use of the API or the SDK equally and therefore “API” and “SDK” - are used interchangeably and any reference to one also equally applies to the other. - Allow2 SDK - means the source code and object code described in the Service Description as the Allow2 SDK, and any - modification, translation or derivative of the Allow2 SDK including any Modified SDK Version. Note that - this License is intended to cover any use of the API or the SDK equally and therefore “API” and “SDK” - are used interchangeably and any reference to one also equally applies to the other. - Allow2 Service - means as set out in the Service Description. - API (or SDK) Licence - means as set out in clause 4.1. - API (or SDK) Modification - means as set out in clause 9.1(b). - Australian Consumer Law - means schedule 2 to the Competition and Consumer Act 2010 (Cth). - Business Day - means Monday – Friday excluding public holidays in Queensland. - Business Hours - means 9:00am – 5:00pm on Business Days. - Intellectual Property Rights - means all copyright, trademark rights, patent rights, and design rights, whether - registered or unregistered, and all other rights to intellectual property as defined under article 2 - of the convention establishing the World Intellectual Property Organization, and all rights to enforce - any of the foregoing rights. Modified API (or SDK) Version means as set out in clause 9.1. - Moral Rights - has the meaning given in the Copyright Act 1968 (Cth). - Non-Excludable Guarantee - means a non-excludable guarantee implied by the Australian Consumer Law. - Personal Property Securities Register - means the Personal Property Securities Register established under the - Personal Property Securities Act 2009 (Cth). - Premium Version - means as set out in clause 2.5. - Product - means any software that You own the Intellectual Property Rights in. - Product Data - means the data that You and/or Your Users transmit through or upload into the Allow2 Service via Your - Products. - Registered Developer - means as set out in clause 2.2. - Service Description - means as set out at http://www.allow2.com and associated pages and sites. - Terms of Use - means the terms and conditions set out on this webpage as amended by Us from time to time. Trademark - means any existing or future registered or unregistered trademarks of Allow2. - User - means a person who operates a Product. - “We”, “Our” and “Us” - means Allow2. - “Website” - means the allow2.com website and any content, images, text and other information appearing on any page of - the Website and any source code and object code in the Website, plus any database which forms part of - or which the Website interacts with. - “You” and “Your” - means you, the person who accesses this Website for any reason, whether or not You are a - Registered Developer. - - 1.2. Interpretation - - In these Terms of Use: - - (a) Headings and underlinings are for convenience only and do not affect the construction of these Terms of Use. - (b) A provision of these Terms of Use will not be interpreted against a party because the party prepared or was - responsible for the preparation of the provision, or because the party’s legal representative prepared the - provision. - (c) A reference to a statute or regulation includes amendments thereto. - (d) A reference to a clause, subclause or paragraph is a reference to a clause, subclause or paragraph of these - Terms of Use. - (e) A reference to a subclause or paragraph is a reference to the subclause or paragraph in the clause in which - the reference is made. - (f) The section entitled “Background” at the top of these Terms of Use forms part of the binding terms and - conditions of these Terms of Use. - (g) A reference to time is to time in Queensland. - (h) A reference to a person includes a reference to an individual, a partnership, a company, a joint venture, - government body, government department, and any other legal entity. - (i) The words “includes”, “including” and similar expressions are not words of limitation. - -2. Allow2 Service - 2.1. The Allow2 API, Allow2 SDK and the Allow2 Service are provided as a bundle and are not supplied or licensed - independently of one another. - 2.2. You may only download the Allow2 API and/or an Allow2 SDK and/or use the Allow2 Service if You are a - registered Account holder (“Registered Developer”). - 2.3. If you are a Registered Developer You may not use the Allow2 Service except: - (a) via the Allow2 API or an Allow2 SDK integrated into Your Products; and - (b) solely to provide information transmitted to Your Products via the Allow2 API in order to use that - information for the purposes expressly specified in the Service Description. - 2.4. A description of the Allow2 Service is included in the Service Description. Registered Developers may only - access the Allow2 Service for the purpose(s) specified in, and subject to the provisions of, the Service - Description and these Terms of Use. - 2.5. There are currently no fees payable by Developers to use the current version of the Allow2 Service in - accordance with these Terms of Use. We do not currently intend to introduce any fees for Developer use of the - Allow2 Service, unless we release: - (a) a version of the Allow2 Service which permits a greater number of calls to the Allow2 Service than specified in - clause 6.3; and/or - (b) a version that contains functionality or features over and above that which is provided by Our “standard” - version of the Allow2 Service, (each, a “Premium Version”) and We reserve the right to charge a fee for any - Premium Versions. - 2.6. There is no fee payable by Developers for the use of the Allow2 API in accordance with these Terms of Use. - 2.7. You and your Users must pay all costs and expenses associated with Your use of the Allow2 Service and the - Allow2 API, including internet access costs, web browser and computer and smartphone equipment costs, - telecommunications costs, data costs and roaming charges. - -3. Registration for the Allow2 Service - 3.1. Prior to downloading, modifying, using and/or integrating the Allow2 API in Your Products, You must register - for an Allow2 Service account through this Website (an “Account”). - 3.2. If You register on the Website, You: - (a) warrant that during the registration process You will provide truthful and accurate information only; - (b) warrant that You are registering an Account in Your own personal capacity and on behalf of and with the - authority and consent of the business entity, the name and details of which You enter during registration - as “the developer”; - (c) agree to be jointly and severally liable for any breach of these Terms of Use by You and/or that entity; - (d) agree and acknowledge that by registering on this Website that You are registering to use the Allow2 - Service in Your Products via the Allow2 API and/or SDK. - 3.3. You must ensure, without limiting clause 3.2, that You provide a valid email address at the time of registration - for an Account. - 3.4. We reserve the right to send an email to You with a hyperlink which requires You to verify that You are the - owner or operator of the email address entered by You during registration and to cancel/not approve Your - registration if Your rights to the email address are not so verified. - 3.5. If any of Your contact details or other information which You provide during the registration process change, - You must promptly update Your registration details for Your Account via the Website with Your up-to-date - details and information. - 3.6. You must not disclose Your account name or password for Your Account to any person. You agree and - acknowledge that You shall be solely responsible for the confidentiality of Your username and password and - any use (including unauthorised use) of Your Account. - 3.7. You must immediately notify Us if You become aware of any unauthorised use of Your Account. - 4. Licence to use and modify the Allow2 API and SDK - 4.1. Subject to Your compliance with these Terms of Use including clause 4.2, if You register for an Account We - hereby grant You a non-exclusive, non-transferable, royalty-free, worldwide licence to: - (a) use and modify the Allow2 API and/or SDK in object and source code form, and to translate the Allow2 - API and/or SDK to other computer programming languages and make any derivatives thereof; - (b) the right to incorporate into copies of Your Products distributed to Users that portion of the source code - and object code of the Allow2 API and/or SDK necessary to enable Your Products to interface with, - and/or use functionality provided by, the Allow2 Service; and - (c) the right to sublicense to Users the right to use such portion of the Allow2 API and/or SDK solely in the - course of their operation of Your Products subject to the provisions of clauses 13.3 and 13.4, - (collectively, the “API and SDK Licence”). - 4.2. You acknowledge and agree that You must not use the Allow2 API and/or SDK: - (a) in any Product that does not interface with and/or use functionality provided by the Allow2 Service; - (b) independently of the Allow2 Service; - (c) to build any service competitive with the Allow2 Service; and/or - (d) with any service competitive with the Allow2 Service or that provides the same or similar functionality as - the Allow2 Service. - 4.3. You must not authorise, encourage or license any person (including any User) to use the Allow2 API and/or - SDK in breach of the provisions of clause 4.2. - 4.4. The rights granted under paragraphs 4.1(a), 4.1(b), and 4.1(c) are the only rights granted under the API and - SDK Licence. No additional rights or licences are provided to You with respect to the Allow2 API and/or SDK - whether by implication or otherwise. - -5. Availability of Allow2 Service - 5.1. You agree and acknowledge that the Allow2 Service will not be able to provide data to any Product while the - Allow2 Service and/or the Product is disconnected from the Internet. - 5.2. While You are a Registered Developer of the Website, We agree to use Our best endeavours to procure - hosting of the Allow2 Service and the Product Data and to ensure that the Website and Allow2 Service are - available at least 90% of the time (calculated by Us monthly). - 5.3. The availability of the Allow2 Service to You will be subject, in addition to any other provisions set out in - these Terms of Use, to any bandwidth limitations, database size limitations, throughput limitations and other - technical and non-technical limitations or restrictions set out in the Service Description. - -6. API and SDK Licence Restrictions - 6.1. You may not make any use of the Allow2 API and/or SDK except as permitted by the API and SDK Licence - and may not do or authorise the commission of any act that would or might invalidate or be inconsistent with - Our Intellectual Property Rights. Without limiting the foregoing provisions of the API and SDK Licence, You - must not, under any circumstances, scrape, republish, mirror or otherwise rent, lend, lease, sell, redistribute, - sublicense, copy or duplicate the Allow2 API and/or SDK or any content You obtain from the Allow2 Service via - the Allow2 API and/or SDK, except as expressly permitted under the API and SDK Licence and the Service Description. - In addition, You must not, nor may You permit any person to: - (a) do any act that would or might invalidate or be inconsistent with Our Intellectual Property Rights or those of Our - licensors; - (b) use the Allow2 API, Allow2 SDK or the Allow2 Service in any way that infringes Our rights or the rights of any - third party; or - (c) take any steps to circumvent any technological protection measure or security measures that We implement with - respect to the Allow2 Service. - 6.2. You must not use the Allow2 API, the Allow2 SDK, the Allow2 Service or any part of them in any way which is in - breach of any statute, regulation, law or legal right of any person. - 6.3. The number of API and/or SDK calls that can be made through Your Account to the Allow2 Service is currently not - hard limited, however there is a nominal limit on calls to ensure service availability for all users. This is - currently defined as: - (a) to 50,000 calls/day/device/User for each of Your Products; - (b) to an aggregate maximum of 100,000,000 calls/day/Account (aggregated over all Products used under Your Account). - If usage extends beyond these limits, Allow2 Pty Ltd may contact you to negotiate an account specific license to - support your level of usage of the service. - -7. Acceptable Use Policy - 7.1. Youagreethat(andmustprocuretheagreementofYourUsersthat): - (a) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, to violate all or any legal rights - of any person or company or other entity in any jurisdiction is strictly prohibited; - (b) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, in relation to crimes such as theft - and fraud is strictly prohibited; - (c) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, in breach of laws relating to the - protection of copyright, trade secrets, patents or other intellectual property and laws relating to spam or - privacy and whether such violation is by way of the installation or distribution of "pirated” software or - otherwise, is strictly prohibited; - (d) introduction of malicious programs into Our network or servers (e.g., viruses, worms, Trojan horses, e- mail - bombs) is strictly prohibited; - (e) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, to make fraudulent offers of goods - or services is strictly prohibited; - (f) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, to carry out security breaches or - disruptions of network communication is strictly prohibited. Security breaches include, but are not limited to, - accessing data of which You are not an intended recipient or logging into a server or account that You are not - expressly authorized to access; corrupting any data; network sniffing; pinged floods; packet spoofing; denial - of service; and forged routing information for malicious purposes; - (g) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, to circumvent user authentication or - security of any of Our hosts, networks or accounts or those of Our customers or suppliers is strictly prohibited; - (h) using the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, to interfere with or deny service to - anyone is strictly prohibited; - (i) sending unsolicited email messages through or to Users of the Allow2 Service, the Allow2 API, any Allow2 SDK, or - the Products, in breach of the Spam Act 2003 is strictly prohibited; and - (j) use of the Allow2 Service, the Allow2 API, any Allow2 SDK, or the Products, in breach of any person’s privacy - (such as by way of identity theft or "phishing") is strictly prohibited. - -8. Intellectual Property Rights - 8.1. You agree and acknowledge that these Terms of Use do not transfer or assign any Intellectual Property Rights to You. - 8.2. You agree and acknowledge (and must procure Your Users’ agreement and acknowledgement) that We own all - Intellectual Property Rights in the Allow2 Service, the Allow2 API, any Allow2 SDK and the Website. - 8.3. You agree and acknowledge (and must procure Your Users’ agreement and acknowledgement) that: - (a) We own all Intellectual Property Rights in all Product Data; - (b) any Intellectual Property Rights in any Product Data become Our sole and exclusive property - immediately upon it being created, and You (and Your Users) agree to assign all Intellectual Property Rights - in all and any Product Data to Us effective immediately upon creation, pursuant to section 197 of the Copyright - Act 1968 (Cth) and in equity without the need for any further documentation or the execution of any instrument; - (c) You and Your Users waive all Moral Rights that You or they may have to any Product Data and consent to Us and - any third party We authorise to infringe all and any such Moral Rights in Our absolute discretion. - 8.4. You (and Your Users) must not take any step to invalidate or prejudice Our (or Our licensors’) Intellectual - Property Rights in the Allow2 Service, the Allow2 API, any Allow2 SDK, the Product Data, the Website or otherwise, - Without limiting the foregoing provisions, You (and Your Users) must not register any security interest or purchase - money security interest on the Personal Property Securities Register, or otherwise encumber or charge Your or their - rights under or in respect of the Allow2 Service, the Allow2 API, any Allow2 SDK, any Product Data or under the API - and SDK Licence. - -9. Rights to modified and ported versions of Allow2 API and/or SDK - 9.1. You agree that if You or any person on Your behalf modifies the Allow2 API and/or SDK in object and/or source code - form, and/or translates or “ports” the Allow2 API and/or SDK to other computer programming languages, and/or makes - any derivatives of the Allow2 API and/or SDK (“Modified API or SDK Version”): - (a) We will continue to own the Allow2 API and any Allow2 SDK; - (b) any Intellectual Property Rights in any modifications that are made to the Allow2 API and/or SDK in the course of - creating the Modified API or SDK Version (each an “API or SDK Modification”) becomes Our sole and exclusive - property immediately upon the API and/or SDK Modifications being created, and You hereby agree to assign all - Intellectual Property Rights in all and any such modifications to Us effective immediately upon creation, - pursuant to section 197 of the Copyright Act 1968 (Cth) and in equity without the need for any further - documentation or the execution of any instrument. - 9.2. You are not required to provide Us with the API and/or SDK Modifications or the Modified API or SDK Version, - but We encourage You to do so in order to promote the sharing of modifications to the Allow2 API and/or SDK - throughout Our community of Registered Developers. - 9.3. If You or any person on Your behalf creates any Modified API or SDK Version, You will be deemed to have irrevocably - and unequivocally: - (a) agreed and acknowledged that We own all Intellectual Property Rights in the Modified API or SDK Version; - (b) We may without limitation use, modify and/or otherwise exploit the Modified API or SDK Version, including to supply - the Modified API and/or SDK Version to any Registered Developers and any of their Users if You provide Us with a - copy of the Modified API or SDK Version; - (c) warranted and guaranteed to Us and any other person that obtains the Modified API and/or SDK Version from Us, - including any Registered Developers and any of their Users, that the use of the API or SDK Modifications by Us and - them will not result in the infringement of any Intellectual Property Rights or other rights of any person; - (d) consented to the infringement of any Moral Rights that You have in any Modified API or SDK Version by Us and any - other person that obtains the Modified API or SDK Version from Us, including any Registered Developers and any of - their Users; - (e) warranted and guaranteed that prior to Your provision of the copy of the Modified API and/or SDK Version to Us, - You procured an irrevocable assignment of all Intellectual Property Rights to Us and a waiver of Moral Rights, in - the Modified API and/or SDK Version from any person who created the Modified API and/or SDK Version on Your behalf, - and that the waiver contains a consent signed by the signatory consenting to the infringement of the signatory’s - Moral Rights by Us and any other person that obtains the Modified API and/or SDK Version from Us, including any - Registered Developers and any of their Users, with respect to the Modified API and/or SDK Version. - -10. Responsibility for Product Data - 10.1. You agree and acknowledge (and must procure that Your Users agree and acknowledge) that: - (a) the Allow2 Service and/or Product Data may be hosted by Us or Our suppliers on hardware or infrastructure located - in or outside Australia; and - (b) We may not own or operate the infrastructure upon which the Allow2 Service and/or the Product Data is hosted. - 10.2. You must ensure that You and Your Users do not input or transfer any Product Data via the Allow2 API, any Allow2 - SDK or into the Allow2 Service unless You and Your Users are fully entitled and authorised to upload, input and - transfer that Product Data via the Allow2 API and/or Allow2 SDK and into the Allow2 Service and disclose that - Product Data to Us. - 10.3. Each time You or any of Your Users use the Allow2 Service or any Product, You will be deemed to have warranted, - agreed and represented that: - (a) You and Your Users will only upload, input and transfer Product Data into and/or via the Allow2 Service or - disclose Product Data to Us, which You and Your Users are fully entitled and authorised to upload, input, - transfer and disclose; and - (b) the Product Data and Our collection, use, storage and/or disclosure thereof in the course of providing the - Allow2 Service, will not breach any applicable law or right of any person. - 10.4. As between You and Us, You are solely responsible for the accuracy, legality and quality of all Product Data and - for obtaining any permissions, licences, rights and authorisations necessary for Us to use, host, - transmit, store and disclose the Product Data in connection with the provision of the Allow2 Service. - 10.5. You indemnify Us in respect of any loss and damage We and/or any of Our suppliers incur in respect of any - claim that: - (a) any of the Product Data; and/or - (b) Our and/or Our suppliers’ and/or Your and/or Your Users’ use of Product Data, infringes the Intellectual - Property Rights or other rights of any person or breaches any law, regulation, code or standard. - -11. Allow2 Trademarks - 11.1. Except as specified in clause 11.2, nothing in the API Licence or these Terms of Use provides You with any - right to use any Trademarks. - 11.2. You may not use any Trademarks except as required to comply with the provisions of clause 13.3. - -12. Responsibility for Your Users - 12.1. We do not accept responsibility for the conduct of Your Users. - 12.2. As between You and Us, You are solely responsible for the conduct of Your Users and any act or omission of a - User in breach of these Terms of Use shall be deemed to be Your act or omission. - -13. Responsibility for Products etc. - 13.1. You agree that You are solely responsible for Your Products and that We do not in any way endorse or approve - Your Products, nor are We affiliated with You or Your Products by reason of Your use of the Allow2 API, any - Allow2 SDK, the Allow2 Service or otherwise. - 13.2. We reserve the right (but are not obligated) in Our sole discretion to suspend or terminate the operation of - the Allow2 Service in connection with any Products if We believe that any of the Allow2 Service is being used - by You and/or Your Users or others in breach of the provisions of these Terms of Use. - 13.3. Before making any Product available to any User, You must: - (a) include terms in a written agreement entered into between You and the User (such as in an “End User Agreement”) - in which the User agrees that: - (i) We own and retain all Intellectual Property Rights in the Allow2 API, any Allow2 SDK and the Allow2 Service; - (ii) the User is granted a non-exclusive, non-transferable licence to use the Allow2 API, any Allow2 SDK and the - Allow2 Service solely in operating the Product and will not use the Allow2 API and/or any Allow2 SDK and/or - the Allow2 Service for any other purpose without Our prior written consent; - (iii) any technical support provided by You with respect to the Products is provided by You alone and not on Our - behalf; - (iv) the Allow2 Service will not be able to provide data to any Product while the Allow2 Service and/or the Product - is disconnected from the Internet; - (v) the Allow2 Service may not be accurate, correct, up-to-date, uninterrupted or error free; - (vi) We do not have any liability to the User to the full extent possible by law; and - (vii) the User’s rights to use the Allow2 API, any Allow2 SDK and the Allow2 Service shall immediately cease upon - termination of the API and SDK Licence or Your Account for any reason; and - (b) include in the source code of the Products any comments included in the source code of the Allow2 API or/and - Allow2 SDK by Allow2, including any copyright notices and any other comments noting Allow2 as the owner of the - Intellectual Property Rights in the Allow2 API and/or SDK. - 13.4. You may only distribute Your Products under the terms of an agreement between You and Your Users which are not - incompatible or conflict with the provisions of clause 13.3. - 13.5. You agree and acknowledge that You are solely responsible for and You indemnify Us in respect of any loss and - damage We may incur in connection with any claims and/or complaints made by any person where caused directly - or indirectly by: - (a) Your and/or Your Users’ use of the Allow2 Service, the Allow2 API or any Allow2 SDK; - (b) Your Products; - (c) Your Users; - (d) any Product Data; and/or - (e) Your goods and/or services and/or your advertising and/or sales and/or marketing practices. - -14. Liability - 14.1. Except in respect of any Non-Excludable Guarantees, We do not represent that the information on this Website - or any information provided by the Allow2 Service or via the Allow2 API, or any Allow2 SDK, or that the Allow2 - Service or Allow2 API or Allow2 SDK are or will be accurate, correct, up-to-date, uninterrupted or error free. - 14.2. Except in respect of any Non-Excludable Guarantees, neither party is liable to the other party for any indirect, - special or consequential loss or damage incurred by the other party, including liability for loss of profits, - loss of business opportunity, loss of savings, or loss of data. - 14.3. Except in respect of any Non-Excludable Guarantees, to the maximum extent permitted by law (and if permitted by - law), We will not have any liability to You (or Your Users) for any loss or damage howsoever incurred in relation - to Your or their use of or inability to use the Allow2 Service, the Allow2 API, any Allow2 SDK, the Website, or - any Product. - 14.4. If Our supply of the Allow2 Service or the Allow2 API or Allow2 SDK comes with implied non-excludable guarantees - which are regulated by the Australian Consumer Law, the extent of the implied guarantees depends on whether You - are a ‘consumer’ of goods or services within the meaning of that term pursuant to the Australian Consumer Law as - amended. Where You are a ‘consumer’ for the purposes of the Australian Consumer Law, We are required to provide - and shall be deemed to have hereby provided the following mandatory statement to You: “Our goods come with - guarantees that cannot be excluded under the Australian Consumer Law. You are entitled to a replacement or refund - for a major failure and for compensation for any other reasonably foreseeable loss or damage. You are also - entitled to have the goods repaired or replaced if the goods fail to be of acceptable quality and the failure - does not amount to a major failure.” - 14.5. If the Allow2 Service or the Allow2 API or any Allow2 SDK are supplied by Us to You in Your capacity as a - 'consumer' of goods or services within the meaning of that term in the Australian Consumer Law as amended You - will have the benefit of certain non-excludable guarantees in respect of the goods or services and nothing in - these Terms of Use excludes or restricts or modifies any guarantee which pursuant to the Competition and Consumer - Act 2010 (Cth) is so conferred. However, if the goods or services are subject to a non-excludable guarantee, - implied by the Australian Consumer Law and the goods or services are not ordinarily acquired for personal, - domestic or household use or consumption, then pursuant to s 64A of the Australian Consumer Law, We limit Our - liability for breach of any such non-excludable guarantee implied by the Australian Consumer Law (other than a - guarantee implied by sections 51, 52 or 53 of the Australian Consumer Law) or expressly given by Us to You, in - respect of each of the goods and services, where it is fair and reasonable to do so, at Our option, to one or - more of the following: - (a) if the breach relates to goods: - (i) the replacement of the goods or the supply of equivalent goods; - (ii) the repair of such goods; - (iii) the payment of the cost of replacing the goods or of acquiring equivalent goods; or (iv) the payment of the - cost of having the goods repaired; and - (b) if the breach relates to services: - (i) the supplying of the services again; or - (ii) the payment of the cost of having the services supplied again. - 14.6. In order for You to claim against Us under a non-excludable guarantee implied by the Australian Consumer Law, or - under an express warranty given in respect of the goods or services provided by Us, You must provide written - notice to Us with documentary evidence substantiating the claim, for Our review, and, in respect of the Allow2 - API, the Allow2 SDK, the Allow2 Service and the Website, must continue to use them only in accordance with the - provisions of these Terms of Use. - 14.7. Upon receipt of a valid claim from You under any non-excludable guarantee implied by the Australian Consumer Law, - We will contact You to arrange a suitable remedy. Where We elect to repair goods the subject of a valid claim, - You agree that the goods may be replaced by refurbished goods of the same type rather than being repaired and - refurbished parts may be used to repair goods. You acknowledge that where the goods are repaired and are capable - of retaining user-generated data, it is possible that the repair of the goods may result in loss of data. - 14.8. Any warranty against defects provided by Us to You in Your capacity as a ‘consumer’ under the Australian Consumer - Law is in addition to Your other rights and remedies under a law in relation to the goods or services to which the - warranty relates. - 14.9. Except in respect of any Non-Excludable Guarantees, all conditions, warranties and guarantees implied in these - Terms of Use are excluded, to the extent possible by law. - -15. Termination - 15.1. If You are a Registered Developer, the API and SDK Licence and Your Account will terminate automatically if: - (a) You breach any material term of these Terms of Use; - (b) You breach the API and SDK Licence; or - (c) You and/or any person on Your behalf commences any proceedings against any person alleging that the Allow2 API - and/or any Allow2 SDK and/or Allow2 Service directly or indirectly infringes any patent. - 15.2. We may also terminate these Terms of Use, Your Account, the API and SDK Licence and/or Your use of the Allow2 - Service by notice to the email address associated with Your Account, if We deem solely in our opinion that You - are in breach of these Terms of Use. - 15.3. If you cease to be a Registered Developer, or if Your Account is terminated for any reason, the API and SDK - Licence will immediately terminate. - 15.4. If the API and SDK Licence and/or Your Account is terminated for any reason You must immediately remove the - Allow2 API and any Allow2 SDK from Your Products and cease using the Allow2 Service and We may cease providing - the Allow2 Service to You. - 15.5. We may take down the Allow2 Service or this Website or take any part of them down or offline at any time without - notice where reasonably necessary to protect Our legitimate interests or to carry out maintenance. - 15.6. Termination of these Terms of Use, Your Account and/or access to the Allow2 Service and/or the Website does not - affect any accrued rights of either party. - -16. General - 16.1. Amendment: These Terms of Use may be amended by Us at any time, provided that You shall have a right to cancel - Your Account if You do not approve the amendments. - 16.2. Assignment: You may not assign, transfer, licence or novate Your rights or obligations under these Terms of Use - without Our prior written consent. We may assign, transfer, license or novate Our rights or obligations under - these Terms of Use at any time. - 16.3. Severability: If any part of these Terms of Use is deemed invalid by a court of competent jurisdiction, the - remainder of these Terms of Use shall remain enforceable. - 16.4. Relationship: You and Us are independent contractors and these Terms of Use do not create any relationship of - partnership, joint venture, or employer and employee or otherwise. - 16.5. Australian Consumer Law: The exclusions and limitations of liability set out in these Terms of Use shall apply - to the fullest extent permissible at law, but We do not exclude or limit liability which may not be excluded or - limited by law. Without limiting the foregoing provisions, We do not exclude liability under the Australian - Consumer Law which is prohibited from being excluded. - 16.6. Entire Agreement: These Terms of Use constitute the entire agreement between You and Us and to the - extent possible by law, supersede all prior understandings, representations, arrangements and agreements - between You and Us regarding its subject matter. - 16.7. Jurisdiction: These Terms of Use will be interpreted in accordance with the laws in force in Queensland. You - and Us irrevocably submit to the exclusive jurisdiction of the courts situated in Queensland. - -Allow2 Pty Ltd (Australia) Version 1.0 13 April 2014 \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d498044..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.rst - diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8351df --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# Allow2 SDK for Python + +[](https://pypi.org/project/allow2/) +[](https://pypi.org/project/allow2/) +[](https://github.com/Allow2/allow2python/actions) + +Official Allow2 Parental Freedom SDK for Python. + +| | | +|---|---| +| **Package** | `allow2` | +| **Targets** | Python 3.10+ | +| **Dependencies** | `httpx` | +| **Optional** | `aiohttp` (pairing server), `keyring` (secure credential storage) | +| **Language** | Python (asyncio) | + +## Installation + +```bash +pip install allow2 +``` + +With optional secure credential storage: + +```bash +pip install allow2[keyring] +``` + +## Quick Start + +```python +import asyncio +from allow2 import DeviceDaemon, PlaintextBackend, Activity + +async def main(): + backend = PlaintextBackend() + + daemon = DeviceDaemon( + device_name='Living Room PC', + activities=[Activity(id=1), Activity(id=8)], # Internet + Screen Time + credential_backend=backend, + child_resolver=lambda children: None, # interactive selection + ) + + daemon.on('pairing-required', lambda info: print(f"Enter PIN: {info['pin']}")) + daemon.on('child-select-required', lambda data: print('Select child:', [c['name'] for c in data['children']])) + daemon.on('warning', lambda w: print(f"Warning: {w['level']}, {w['remaining']}s left")) + daemon.on('soft-lock', lambda _: print('Time is up!')) + + await daemon.start() + await daemon.open_app() # triggers pairing if unpaired + +asyncio.run(main()) +``` + +## Modules + +| Module | File | Purpose | +|--------|------|---------| +| **DeviceDaemon** | `daemon.py` | Main orchestrator managing the full device lifecycle | +| **Allow2Api** | `api.py` | httpx-based async REST client for all Allow2 endpoints | +| **PairingWizard** | `pairing.py` | aiohttp-based pairing wizard (QR code + PIN display) | +| **ChildShield** | `child_shield.py` | PIN hashing (SHA-256 + salt), rate limiting, session timeout | +| **Checker** | `checker.py` | Permission check loop with per-activity enforcement and stacking | +| **Warnings** | `warnings.py` | Configurable progressive warning scheduler | +| **OfflineHandler** | `offline.py` | Response cache, grace period, deny-by-default fallback | +| **RequestManager** | `request.py` | Request flow (more time, day type change, ban lift) with polling | +| **UpdatePoller** | `updates.py` | Poll for children, quota, ban, and day type changes | +| **Credentials** | `credentials/` | `PlaintextBackend` default + optional `KeyringBackend` | + +## Permission Checks + +```python +# The check loop runs automatically once a child is selected. +# Listen for results: +@daemon.on('check-result') +def on_check(result): + for activity_id, activity in result['activities'].items(): + print(f"{activity_id}: allowed={activity['allowed']}, remaining={activity['remaining']}s") + print(f"Today: {result['day_type_today']}, Tomorrow: {result['day_type_tomorrow']}") +``` + +## Request More Time + +```python +# Child requests 30 more minutes of gaming +result = await daemon.request_more_time( + activity=3, # Gaming + duration=30, # minutes + message="Can I please have more time? Almost done with this level.", +) + +# Poll until parent responds +status = await daemon.poll_request_status(result['request_id'], result['status_secret']) + +if status['status'] == 'approved': + print(f"Approved! {status['duration']} extra minutes.") +elif status['status'] == 'denied': + print("Request denied.") +``` + +## Feedback + +```python +# Submit feedback +result = await daemon.submit_feedback( + category='not_working', + message='The block screen appears even when time is remaining.', +) + +# Load feedback threads +feedback = await daemon.load_device_feedback() +for thread in feedback['discussions']: + print(f"[{thread['category']}] {thread['status']} - {thread['message_count']} messages") + +# Reply to a thread +await daemon.reply_to_feedback(result['discussion_id'], 'This happens every Tuesday.') +``` + +## Warnings + +The SDK fires progressive warnings as time runs out: + +``` +15 min -> 5 min -> 1 min -> 30 sec -> 10 sec -> BLOCKED +``` + +```python +@daemon.on('warning') +def on_warning(data): + show_warning_banner(f"{data['remaining']} seconds remaining") + +@daemon.on('soft-lock') +def on_lock(_): + show_block_screen() +``` + +## Credential Storage + +The SDK uses a pluggable credential backend. The default `PlaintextBackend` writes to `~/.allow2/credentials.json` with `0600` permissions. + +For production, use the `KeyringBackend` (backed by the system keychain) or implement your own: + +```python +class MyCredentialBackend: + async def load(self) -> dict | None: + """Return {'user_id': ..., 'pair_id': ..., 'pair_token': ..., 'children': [...]} or None.""" + ... + + async def store(self, credentials: dict) -> None: + """Persist credentials.""" + ... + + async def clear(self) -> None: + """Remove stored credentials.""" + ... +``` + +## Target Platforms + +| Platform | Notes | +|----------|-------| +| **Linux** | Desktop apps, daemons, Raspberry Pi | +| **macOS** | Desktop apps, system services | +| **Windows** | Desktop apps, services | +| **Embedded** | Any device with Python 3.10+ and httpx | +| **Server** | Django, FastAPI, Flask service 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 `httpx` with async support. 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 + +See [LICENSE](LICENSE) for details. diff --git a/README.rst b/README.rst deleted file mode 100644 index 66e3dd5..0000000 --- a/README.rst +++ /dev/null @@ -1,28 +0,0 @@ -Allow2 ------- - -pip install allow2 - -refer to https://github.com/Allow2/Allow2.github.io/wiki for more details. - -Before the app/device can log any actions or check permissions/etc, you need to first "pair" the device or app: - - >>> import allow2 - >>> - >>> userId, pairId, children = allow2.pair(user, password, deviceToken, deviceName) - -The userId and pairId are used for all subsequent requests to the API and will work only while the device/app remains paired, so these values should be persisted. - -If the parent that owns that account deletes the pairing, then the userId / pairId credentials will no longer work. - -The "children" return value is an array of all current children definitions in that account when it is paired. You can use this to show the parent an interface to -nominate the one permanent child who will use this device/app. Alternately, you can present a selector and use the PIN on each account to allow the child to directly -select and unlock their account prior to using the device or app. - -Then, to record usage and get permissions and blocks/etc, use the following: - - >>> import allow2 - >>> - >>> ???? = allow2.log(userId, pairId, [activityId, ...], childId) - -That will.... (TBC) diff --git a/allow2/__init__.py b/allow2/__init__.py deleted file mode 100644 index 5b0928d..0000000 --- a/allow2/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from pair import pair -from log import log \ No newline at end of file diff --git a/allow2/common.py b/allow2/common.py deleted file mode 100644 index fb0fe3c..0000000 --- a/allow2/common.py +++ /dev/null @@ -1,5 +0,0 @@ -import human_curl as requests -import json - -a2appUrl = 'https://app.allow2.com:8443' -a2apiUrl = 'https://app.allow2.com:9443' diff --git a/allow2/log.py b/allow2/log.py deleted file mode 100644 index 9ae881f..0000000 --- a/allow2/log.py +++ /dev/null @@ -1,6 +0,0 @@ -import common - -#---------------------------------------------------------------------- -def log(): - """""" - return true \ No newline at end of file diff --git a/allow2/pair.py b/allow2/pair.py deleted file mode 100644 index 6d75e05..0000000 --- a/allow2/pair.py +++ /dev/null @@ -1,24 +0,0 @@ -import common - -#---------------------------------------------------------------------- -def pair(user, password, deviceToken, deviceName): - """""" - r = requests.post(a2appUrl + '/api/pairDevice', data={ - 'user' : user, - 'pass' : password, - 'deviceToken' : deviceToken, - 'name' : deviceName - }) - - if (r.status_code == 404): - raise Exception('Authentication Failed', 404) - - if (r.status_code != 200): - raise Exception('Unexpected Error', r.status_code) - - json_response = json.loads(r.content) - - if json_response.get('error') or (not json_response.get('status')) or (json_response['status'] != 'success'): - raise Exception(json_response['error'], 500) - - return json_response['userId'], json_response['pairId'], json_response['children'] \ No newline at end of file diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..fd83095 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,1466 @@ +# Allow2 Python SDK v2 -- Design Document + +**Status:** Draft +**Date:** 2026-03-11 +**Gold Standard:** Node.js SDK v2 (`/workspace/ai/allow2/sdk/node/src/`, 14 files, ~2,450 lines) +**Target:** Functional equivalence with Node.js SDK v2 + +--- + +## 1. Overview + +The Allow2 Python SDK v2 is a device-side SDK for integrating Allow2 Parental Freedom into Python applications: games (Pygame, Ren'Py), IoT devices (Raspberry Pi), desktop apps, automation scripts, and system services. + +It mirrors the Node.js SDK v2 architecture exactly: same state machine, same API endpoints, same event names, same enforcement semantics. A developer familiar with one SDK can immediately use the other. + +### Package Identity + +- **PyPI name:** `allow2` +- **Import:** `import allow2` or `from allow2 import DeviceDaemon` +- **Version:** `2.0.0a1` (PEP 440 alpha) +- **Python:** 3.10+ +- **License:** Same as Node SDK (SEE LICENSE IN LICENSE FILE) + +--- + +## 2. File Structure + +``` +sdk/python/ +├── pyproject.toml +├── LICENSE +└── src/ + └── allow2/ + ├── __init__.py # Public exports + ├── py.typed # PEP 561 marker + ├── models.py # Dataclasses for all types + ├── api.py # Allow2Api HTTP client + ├── daemon.py # DeviceDaemon orchestrator + ├── checker.py # Check loop + per-activity enforcement + ├── child_shield.py # PIN verification + lockout + ├── pairing.py # QR/PIN pairing wizard + ├── warnings.py # Warning scheduler + ├── offline.py # Offline cache + grace period + ├── request.py # Request More Time + ├── updates.py # getUpdates polling + ├── feedback.py # Feedback submit/load/reply (extracted from daemon) + ├── credentials/ + │ ├── __init__.py # CredentialBackend protocol + factory + │ ├── plaintext.py # JSON file backend (~/.allow2/credentials.json) + │ └── keyring_backend.py # keyring-based backend + └── child_resolver/ + ├── __init__.py # ChildResolver protocol + ├── linux_user.py # OS username -> child mapping + ├── selector.py # Interactive selector (returns None) + └── env_var.py # ALLOW2_CHILD_ID env var (for scripts/CI) +``` + +**File count:** 18 source files (vs Node's 14). The delta is: +- `models.py` (new; Node uses ad-hoc objects, Python uses typed dataclasses) +- `feedback.py` (extracted; in Node it lives partly in daemon.js and api.js) +- `keyring_backend.py` (new; Node has libsecret TODO, we ship keyring) +- `env_var.py` (new; useful for headless/script scenarios) + +--- + +## 3. Dependency Choices + +### Required Dependencies (installed with `pip install allow2`) + +| Package | Version | Rationale | +|---------|---------|-----------| +| `httpx` | `>=0.27` | Modern async-first HTTP client. Supports sync and async on the same API. Has built-in timeout, connection pooling, HTTP/2. Replaces Node's native `fetch`. | + +### Optional Dependencies (extras) + +| Extra | Packages | Rationale | +|-------|----------|-----------| +| `[keyring]` | `keyring>=25` | System keyring credential storage (GNOME Keyring, KDE Wallet, macOS Keychain, Windows Credential Locker). Replaces Node's libsecret TODO. | +| `[server]` | `aiohttp>=3.9` | Local pairing web server. Equivalent to Node's Express. Only needed if the integration wants to serve the pairing HTML page. Most integrations will handle pairing via their own UI. | +| `[all]` | All of the above | Convenience extra for full feature set. | + +### Why httpx over alternatives + +- **`urllib`/`urllib3`**: No native async. Would require `asyncio.to_thread()` wrappers, losing true async benefits (connection pooling, multiplexing). +- **`aiohttp`**: Async-only. Cannot be used in sync contexts without running an event loop. httpx supports both sync (`httpx.Client`) and async (`httpx.AsyncClient`) on the same code. +- **`requests`**: Sync-only, requires `aiohttp` or `httpx` for async anyway. httpx is the modern successor. + +### Zero-dependency fallback + +The SDK will NOT provide a zero-dependency fallback. Python 3.10+ always has `urllib`, but wrapping it to match httpx's interface would be significant work for marginal benefit. httpx is a pure-Python wheel with no native compilation -- it installs everywhere Python runs. + +--- + +## 4. Type System (models.py) + +All data structures are `dataclasses` with full type annotations. This provides IDE autocompletion, runtime validation, and JSON serialization without external schema libraries. + +```python +# --- models.py --- + +from __future__ import annotations +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class DaemonState(str, Enum): + """Mirrors Node SDK states exactly.""" + UNPAIRED = "unpaired" + PAIRING = "pairing" + PAIRED = "paired" + ENFORCING = "enforcing" + PARENT = "parent" + + +class WarningLevel(str, Enum): + INFO = "info" + URGENT = "urgent" + FINAL = "final" + COUNTDOWN = "countdown" + + +class FeedbackCategory(str, Enum): + BYPASS = "bypass" + MISSING_FEATURE = "missing_feature" + NOT_WORKING = "not_working" + QUESTION = "question" + OTHER = "other" + + +class VerificationLevel(str, Enum): + HONOUR = "honour" + PIN = "pin" + PARENT_ONLY = "parent-only" + + +@dataclass +class Activity: + """An activity to monitor (e.g., Gaming, Internet, Screen Time).""" + id: int + + def to_check_map(self) -> dict[str, int]: + return {str(self.id): 1} + + +@dataclass +class Child: + """A child entity from the controller's account.""" + id: int + name: str + pin_hash: str | None = None + pin_salt: str | None = None + avatar_url: str | None = None + color: str | None = None + has_account: bool = False + linked_user_id: int | None = None + os_username: str | None = None + last_used_at: str | None = None # ISO 8601 + + +@dataclass +class Credentials: + """Stored pairing credentials.""" + uuid: str + user_id: int + pair_id: int + pair_token: str + children: list[Child] = field(default_factory=list) + + +@dataclass +class ActivityState: + """Per-activity enforcement state from a check response.""" + allowed: bool + remaining: float # seconds, math.inf if unlimited + + +@dataclass +class CheckResult: + """Parsed result from /serviceapi/check.""" + activities: dict[str, ActivityState] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WarningThreshold: + """A single warning threshold.""" + remaining: int # seconds + level: WarningLevel + + +@dataclass +class PairingInfo: + """Returned by PairingWizard.start().""" + pin: str + port: int + url: str + qr_url: str + connected: bool + + +@dataclass +class RequestResult: + """Returned by create_request().""" + request_id: str + status_secret: str + + +@dataclass +class DeviceContext: + """Context sent with feedback submissions.""" + device_name: str = "Allow2 Device" + platform: str = "python" + sdk_version: str = "2.0.0" + product_name: str = "allow2" +``` + +### Serialization Convention + +All dataclasses will have a `to_dict()` method (via a mixin or utility function) for JSON serialization, and a `@classmethod from_dict(cls, data: dict)` for deserialization from API responses. Field names in Python use `snake_case`; serialization maps to `camelCase` for API compatibility. + +--- + +## 5. Event / Callback System + +### Design Decision: Callback Registry (not asyncio.Event, not third-party) + +Python has no built-in `EventEmitter`. The options: + +1. **asyncio.Event / asyncio.Condition**: One-shot or condition-based; wrong abstraction for multi-listener event streams. +2. **Third-party (pyee, blinker)**: Adds dependency for a simple pattern. +3. **Custom callback registry**: Minimal, typed, zero-dependency. + +We use option 3: a simple `EventEmitter` mixin that mirrors the Node.js pattern. + +```python +# Embedded in daemon.py (or a small _events.py internal module) + +from typing import Callable, Any +from collections import defaultdict + +class EventEmitter: + """ + Minimal EventEmitter matching the Node.js pattern. + Supports: on(), off(), once(), emit(). + Thread-safe via a simple lock for add/remove. + """ + + def __init__(self) -> None: + self._listeners: dict[str, list[Callable]] = defaultdict(list) + + def on(self, event: str, callback: Callable[..., Any]) -> None: + """Register a listener for an event. Same callback can be registered multiple times.""" + self._listeners[event].append(callback) + + def off(self, event: str, callback: Callable[..., Any]) -> None: + """Remove a specific listener.""" + try: + self._listeners[event].remove(callback) + except ValueError: + pass + + def once(self, event: str, callback: Callable[..., Any]) -> None: + """Register a listener that fires once then auto-removes.""" + def wrapper(*args: Any, **kwargs: Any) -> Any: + self.off(event, wrapper) + return callback(*args, **kwargs) + self.on(event, wrapper) + + def emit(self, event: str, *args: Any, **kwargs: Any) -> None: + """ + Fire all listeners for an event. + If a listener is a coroutine function, it is scheduled on the + running event loop (fire-and-forget). If no loop is running, + it is called synchronously (assuming it returns a coroutine + that the caller can await or ignore). + """ + for listener in self._listeners[event][:]: # copy to allow mutation + result = listener(*args, **kwargs) + # If the listener returned a coroutine, schedule it + if asyncio.iscoroutine(result): + try: + loop = asyncio.get_running_loop() + loop.create_task(result) + except RuntimeError: + pass # No running loop; coroutine is discarded + + def remove_all_listeners(self, event: str | None = None) -> None: + """Remove all listeners, or all for a specific event.""" + if event is None: + self._listeners.clear() + else: + self._listeners.pop(event, None) +``` + +### Event Names (exact match with Node SDK) + +All event names use the same kebab-case strings as the Node SDK for cross-SDK consistency: + +| Event | Payload | Emitted By | +|-------|---------|------------| +| `"paired"` | `{"user_id": int, "children": list[Child]}` | Daemon | +| `"unpaired"` | `{"error": Exception \| None}` | Daemon, Checker | +| `"pairing-required"` | `PairingInfo` | Daemon | +| `"pairing-error"` | `Exception` | Daemon | +| `"pairing-connection-status"` | `{"connected": bool}` | Daemon | +| `"child-select-required"` | `{"children": list[Child]}` | Daemon | +| `"child-selected"` | `{"child_id": int, "name": str \| None}` | Daemon | +| `"child-pin-failed"` | `{"child_id": int, "attempts_remaining": int}` | Daemon, ChildShield | +| `"child-locked-out"` | `{"child_id": int}` or `int` (seconds) | Daemon, ChildShield | +| `"parent-mode"` | `{}` | Daemon | +| `"parent-mode-entered"` | (none) | ChildShield | +| `"session-timeout"` | `{}` | Daemon, ChildShield | +| `"status-requested"` | `{"state": DaemonState, ...}` | Daemon | +| `"warning"` | `{"level": WarningLevel, "activity_id": str, "remaining": int}` | Checker (via WarningScheduler) | +| `"activity-blocked"` | `{"activity_id": int, "activity": str, "remaining": 0}` | Checker | +| `"soft-lock"` | `{"reason": str}` | Checker | +| `"hard-lock"` | `{"reason": str}` | Checker | +| `"unlock"` | `{"reason": str, "activity_id": int \| None}` | Checker | +| `"offline-grace"` | `{"since": float, "grace_remaining": float}` | Checker | +| `"offline-deny"` | `{"since": float, "offline_duration": float}` | Checker | +| `"children-updated"` | `{"children": list[Child]}` | Daemon | +| `"request-created"` | `{"request_id": str}` | RequestManager | +| `"request-approved"` | `{"request_id": str, ...}` | Daemon, RequestManager | +| `"request-denied"` | `{"request_id": str, ...}` | Daemon, RequestManager | +| `"request-timeout"` | (none) | RequestManager | +| `"request-error"` | `Exception` | RequestManager | +| `"feedback-submitted"` | `{"discussion_id": str, "category": str}` | Daemon | +| `"feedback-loaded"` | `{"discussions": list}` | Daemon | +| `"feedback-reply-sent"` | `{"discussion_id": str, "message_id": str}` | Daemon | +| `"extension"` | `{"child_id": int, "activity": int, ...}` | UpdatePoller | +| `"day-type-changed"` | `{"child_id": int, "day_type": str}` | UpdatePoller | +| `"quota-updated"` | `{"child_id": int, "activity": int, ...}` | UpdatePoller | +| `"ban"` | `{"child_id": int, "activity": int, "banned": bool}` | UpdatePoller | + +--- + +## 6. File-by-File Specification + +### 6.1 `api.py` -- Allow2Api + +Port of `api.js` (302 lines). HTTP client wrapping all Allow2 REST endpoints. + +```python +class Allow2Api: + """ + Low-level async HTTP client for the Allow2 REST API. + + VID/Token resolution order: explicit arg > env var > baked-in default. + Env vars: ALLOW2_API_URL, ALLOW2_VID, ALLOW2_TOKEN. + """ + + def __init__( + self, + *, + api_url: str | None = None, + vid: int | None = None, + token: str | None = None, + timeout: float = 15.0, + ) -> None: ... + + # -- Properties -- + @property + def base_url(self) -> str: ... + @property + def vid(self) -> int: ... + + # -- Internal -- + async def _fetch(self, path: str, *, method: str = "GET", + json_body: dict | None = None, + headers: dict | None = None) -> dict: ... + """ + Core HTTP method. Uses httpx.AsyncClient. + - Sets Content-Type: application/json + - Parses JSON response + - On non-2xx: raises Allow2ApiError with .status, .code, .body + - Timeout via httpx.Timeout + """ + + # -- Pairing -- + async def init_qr_pairing(self, *, uuid: str, device_name: str, + platform: str = "python") -> dict: ... + async def init_pin_pairing(self, *, uuid: str, device_name: str, + platform: str = "python") -> dict: ... + async def check_pairing_status(self, pairing_session_id: str) -> dict: ... + + # -- Check -- + async def check(self, *, user_id: int, pair_id: int, pair_token: str, + child_id: int, activities: dict[str, int], + tz: str, log: bool = True) -> dict: ... + + # -- Updates -- + async def get_updates(self, *, user_id: int, pair_id: int, + pair_token: str, + timestamp_millis: int | None = None) -> dict: ... + + # -- Requests -- + async def create_request(self, *, user_id: int, pair_id: int, + pair_token: str, child_id: int, + duration: int, activity: int, + message: str | None = None) -> dict: ... + async def get_request_status(self, request_id: str, + status_secret: str) -> dict: ... + + # -- Feedback -- + async def submit_feedback(self, *, user_id: int, pair_id: int, + pair_token: str, child_id: int | None, + vid: int | None, category: str, + message: str, + device_context: dict | None = None) -> dict: ... + async def load_feedback(self, *, user_id: int, pair_id: int, + pair_token: str) -> dict: ... + async def feedback_reply(self, *, user_id: int, pair_id: int, + pair_token: str, discussion_id: str, + message: str) -> dict: ... + + # -- Usage -- + async def log_usage(self, *, user_id: int, pair_id: int, + pair_token: str, child_id: int, + activities: dict) -> dict: ... +``` + +**Error class:** + +```python +class Allow2ApiError(Exception): + """Raised on non-2xx API responses.""" + def __init__(self, message: str, *, status: int, code: str | None = None, + body: dict | None = None) -> None: + super().__init__(message) + self.status = status + self.code = code + self.body = body +``` + +**httpx client lifecycle:** A single `httpx.AsyncClient` instance is created lazily on first request and reused for connection pooling. Exposes `async close()` for clean shutdown. Supports context manager protocol (`async with Allow2Api() as api:`). + +### 6.2 `daemon.py` -- DeviceDaemon + +Port of `daemon.js` (769 lines). Main orchestrator. + +```python +class DeviceDaemon(EventEmitter): + """ + Main entry point for the Allow2 Device SDK. + + Manages the full device lifecycle: + unpaired -> pairing -> paired -> enforcing + -> parent (unrestricted) + """ + + def __init__( + self, + *, + activities: list[Activity], + credential_backend: CredentialBackend, + child_resolver: ChildResolver, + device_name: str = "Allow2 Device", + check_interval: int = 60, # seconds + grace_period: int = 300, # seconds + hard_lock_timeout: int = 300, # seconds + warnings: list[WarningThreshold] | None = None, + pairing_port: int = 3000, + api_url: str | None = None, + vid: int | None = None, + token: str | None = None, + ) -> None: ... + + # -- Properties (read-only) -- + @property + def api(self) -> Allow2Api: ... + @property + def credentials(self) -> Credentials | None: ... + @property + def child_id(self) -> int | None: ... + @property + def running(self) -> bool: ... + @property + def paired(self) -> bool: ... + @property + def state(self) -> DaemonState: ... + @property + def is_parent_mode(self) -> bool: ... + @property + def can_submit_feedback(self) -> bool: ... + + # -- Lifecycle -- + async def start(self) -> None: ... + def stop(self) -> None: ... + async def open_app(self) -> None: ... + def close_app(self) -> None: ... + def enter_parent_mode(self) -> None: ... + async def on_pairing_complete(self, credentials: Credentials) -> None: ... + + # -- Child Management -- + async def select_child(self, child_id: int, name: str | None = None) -> None: ... + def child_pin_failed(self, child_id: int, attempts_remaining: int) -> None: ... + def session_timeout(self) -> None: ... + + # -- Requests -- + async def request_more_time(self, *, duration: int, activity: int, + message: str | None = None) -> dict: ... + async def poll_request_status(self, request_id: str, + status_secret: str) -> dict: ... + + # -- Feedback -- + async def submit_feedback(self, *, category: FeedbackCategory, + message: str, + device_context: DeviceContext | None = None) -> dict: ... + async def load_device_feedback(self) -> dict: ... + async def reply_to_feedback(self, discussion_id: str, message: str) -> dict: ... + + @staticmethod + def feedback_params_to_text(category: FeedbackCategory) -> str: ... + + # -- Internal -- + async def _start_pairing(self) -> None: ... + async def _on_paired(self, credentials: Credentials) -> None: ... + async def _begin_enforcement(self) -> None: ... + async def _resolve_child(self) -> None: ... + def _start_checker(self) -> None: ... + def _start_heartbeat(self) -> None: ... + def _stop_heartbeat(self) -> None: ... + def _update_last_used(self, child_id: int) -> None: ... +``` + +**Async task management:** The daemon uses `asyncio.create_task()` for the check loop, heartbeat, and pairing poll. All tasks are tracked in a `set[asyncio.Task]` and cancelled in `stop()`. Timer-based operations (heartbeat, hard-lock timeout) use `asyncio.call_later()` or `asyncio.sleep()` in a loop. + +### 6.3 `checker.py` -- Checker + +Port of `checker.js` (296 lines). + +```python +SCREEN_TIME_ACTIVITY: int = 8 + +class Checker: + """ + Periodic check loop with per-activity enforcement. + + Not an EventEmitter itself -- delegates to the daemon's emit(). + """ + + def __init__( + self, + *, + api: Allow2Api, + emit: Callable[..., None], + credentials: Credentials, + child_id: int, + activities: list[Activity], + check_interval: int = 60, # seconds + hard_lock_timeout: int = 300, # seconds + grace_period: int = 300, # seconds + warning_thresholds: list[WarningThreshold] | None = None, + ) -> None: ... + + # -- Public -- + def start(self) -> None: ... # Creates asyncio task for _run_check loop + def stop(self) -> None: ... # Cancels task, clears timers + def on_time_extended(self, activity_id: int) -> None: ... + def get_remaining(self) -> dict[str, ActivityState] | None: ... + def reset(self, child_id: int) -> None: ... + + # -- Internal -- + async def _run_check(self) -> None: ... # Loop: _do_check() then sleep(interval) + async def _do_check(self) -> None: ... # API call, clear offline, process result + def _process_result(self, result: dict) -> None: ... + def _trigger_soft_lock(self, reason: str) -> None: ... + def _handle_error(self, err: Exception) -> None: ... +``` + +**Key behavioral details ported exactly:** +- `_state: dict[str, ActivityState]` tracks per-activity allowed/remaining. +- Detects allowed-to-blocked transition per activity; emits `activity-blocked`. +- Screen Time (ID 8) exhaustion triggers `soft-lock`. +- All activities blocked also triggers `soft-lock`. +- `soft-lock` starts a hard-lock countdown (`asyncio.call_later`). +- Blocked-to-allowed cancels soft-lock, emits `unlock`. +- HTTP 401 emits `unpaired` and stops the loop. +- Network errors trigger offline grace / deny logic. +- Timezone via `time.tzname` or `zoneinfo` (Python 3.9+ stdlib). + +### 6.4 `child_shield.py` -- ChildShield + +Port of `child-shield.js` (411 lines). + +```python +MAX_PIN_ATTEMPTS: int = 5 +LOCKOUT_DURATION: float = 300.0 # seconds +DEFAULT_SESSION_TIMEOUT: float = 300.0 # seconds + +def hash_pin(pin: str, salt: str) -> str: + """SHA-256(pin + salt) as lowercase hex. Uses hashlib.""" + ... + +def safe_compare(a: str, b: str) -> bool: + """Constant-time comparison via hmac.compare_digest.""" + ... + +class ChildShield(EventEmitter): + """ + Child identification, PIN verification, and session management. + """ + + def __init__( + self, + *, + children: list[Child], + verification_level: VerificationLevel = VerificationLevel.PIN, + session_timeout: float = DEFAULT_SESSION_TIMEOUT, + on_select_required: Callable | None = None, + ) -> None: ... + + # -- Public -- + def select_child(self, child_id: int, pin: str | None = None) -> bool: ... + def select_parent(self, pin: str) -> bool: ... + def clear_selection(self) -> None: ... + def get_current_child(self) -> Child | None: ... + def is_parent_mode(self) -> bool: ... + def record_activity(self) -> None: ... + def update_children(self, children: list[Child]) -> None: ... + def get_children(self) -> list[dict]: ... # Safe copy without PINs + def destroy(self) -> None: ... + + # -- Internal -- + def _find_child(self, child_id: int) -> Child | None: ... + def _find_parent_entry(self) -> Child | None: ... + def _activate_child(self, child: Child) -> None: ... + def _enter_parent_mode(self) -> None: ... + def _reset_session_timer(self) -> None: ... + def _stop_session_timer(self) -> None: ... + def _on_session_timeout(self) -> None: ... + def _get_attempt_record(self, key: str | int) -> dict: ... + def _is_locked_out(self, key: str | int) -> bool: ... + def _lockout_remaining(self, key: str | int) -> float: ... + def _record_failed_attempt(self, key: str | int) -> None: ... + def _clear_attempts(self, key: str | int) -> None: ... +``` + +**Session timer:** Uses `threading.Timer` (not asyncio) because ChildShield may be used outside an async context (e.g., in a Pygame main loop). The timer calls `_on_session_timeout()` which emits events synchronously. + +### 6.5 `pairing.py` -- PairingWizard + +Port of `pairing.js` (698 lines, including HTML templates). + +```python +class PairingWizard(EventEmitter): + """ + Manages one-time device pairing. Parents NEVER enter credentials on device. + + Flow: + 1. Call API to register pairing session (initPINPairing) + 2. API returns server-assigned PIN + session ID + 3. Device displays PIN (and QR deep link) + 4. Parent enters PIN in Allow2 app on their phone + 5. Wizard polls checkPairingStatus until confirmed + 6. On confirmation: store credentials, emit 'paired' + """ + + def __init__( + self, + *, + api: Allow2Api, + credential_backend: CredentialBackend, + port: int = 3000, + device_name: str = "Python Device", + uuid: str | None = None, + ) -> None: ... + + # -- Public -- + async def start(self) -> PairingInfo: ... + async def stop(self) -> None: ... + def get_pin(self) -> str | None: ... + def get_qr_url(self) -> str | None: ... + async def complete_pairing(self, pairing_data: dict) -> Credentials: ... + + # -- Internal -- + async def _load_or_create_uuid(self) -> str: ... + def _start_init_retry(self) -> None: ... + def _start_polling(self) -> None: ... + async def _start_http_server(self) -> None: ... # Optional, uses aiohttp if available +``` + +**HTTP server design:** The local pairing web server is optional. If `aiohttp` is installed (via `[server]` extra), PairingWizard starts an aiohttp server serving the same HTML as the Node SDK. If `aiohttp` is not installed, `start()` still works -- it just skips the web server and returns the PairingInfo for the integration to display however it wants. + +**Polling:** Uses `asyncio.create_task` with a loop that sleeps 5s between polls. Max 360 polls (30 minutes). Retry logic on init failure matches Node SDK exactly (5s retry interval). + +### 6.6 `warnings.py` -- WarningScheduler + +Port of `warnings.js` (87 lines). Smallest module. + +```python +DEFAULT_THRESHOLDS: list[WarningThreshold] = [ + WarningThreshold(remaining=900, level=WarningLevel.INFO), # 15 min + WarningThreshold(remaining=300, level=WarningLevel.URGENT), # 5 min + WarningThreshold(remaining=60, level=WarningLevel.FINAL), # 1 min + WarningThreshold(remaining=30, level=WarningLevel.COUNTDOWN), # 30 sec +] + +class WarningScheduler: + """ + Tracks remaining time per activity and emits 'warning' events + when thresholds are crossed. Fire-once-per-level per activity. + """ + + def __init__( + self, + *, + emit: Callable[..., None], + thresholds: list[WarningThreshold] | None = None, + ) -> None: ... + + def update(self, activities: dict[str, dict]) -> None: ... + """Called by Checker after each check. Evaluates thresholds.""" + + def reset_activity(self, activity_id: str) -> None: ... + def reset_all(self) -> None: ... +``` + +Internal state: `_fired: dict[str, set[WarningLevel]]` -- maps activity ID to set of levels already fired. + +### 6.7 `offline.py` -- OfflineHandler + +Port of `offline.js` (133 lines). + +```python +DEFAULT_GRACE_PERIOD: int = 300 # seconds + +class OfflineHandler(EventEmitter): + """ + Caches last successful check result. Enforces grace period. + After grace: deny-by-default. + Cache persisted to ~/.allow2/cache.json. + """ + + def __init__( + self, + *, + grace_period: int = DEFAULT_GRACE_PERIOD, + cache_path: str | None = None, # Default: ~/.allow2/cache.json + ) -> None: ... + + async def cache_result(self, check_result: dict) -> None: ... + async def get_cached_result(self) -> dict | None: ... + async def get_grace_elapsed(self) -> float: ... # seconds, math.inf if no cache + async def is_in_grace_period(self) -> bool: ... + async def should_deny(self) -> bool: ... + + # -- Internal -- + async def _write_disk(self, data: dict) -> None: ... + async def _load_disk(self) -> None: ... +``` + +Uses `aiofiles` if available, otherwise `asyncio.to_thread(pathlib.Path.write_text, ...)` for non-blocking disk I/O. File permissions set via `os.chmod(path, 0o600)`. + +### 6.8 `request.py` -- RequestManager + +Port of `request.js` (124 lines). + +```python +class RequestManager(EventEmitter): + """Request More Time flow with polling.""" + + def __init__( + self, + *, + api: Allow2Api, + poll_interval: float = 5.0, # seconds + timeout: float = 300.0, # seconds + ) -> None: ... + + async def create_request( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int, + duration: int, + activity: int, + message: str | None = None, + ) -> RequestResult: ... + + def start_polling(self, request_id: str, status_secret: str) -> None: ... + def stop_polling(self) -> None: ... + + # -- Internal -- + async def _poll(self, request_id: str, status_secret: str) -> None: ... +``` + +### 6.9 `updates.py` -- UpdatePoller + +Port of `updates.js` (179 lines). + +```python +class UpdatePoller(EventEmitter): + """ + Polls GET /api/getUpdates for delta changes. + Emits: extension, day-type-changed, quota-updated, ban, children-updated. + """ + + def __init__( + self, + *, + api: Allow2Api, + poll_interval: float = 30.0, # seconds + ) -> None: ... + + def start(self, credentials: Credentials) -> None: ... + def stop(self) -> None: ... + + # -- Internal -- + async def _poll(self) -> None: ... + async def _fetch_updates(self) -> None: ... + def _process_updates(self, result: dict) -> None: ... + def _handle_error(self, err: Exception) -> None: ... +``` + +### 6.10 `feedback.py` -- Feedback helpers + +Extracted from Node's `daemon.js` and `api.js` for cleaner separation. The daemon delegates to these, but they can also be used standalone. + +```python +VALID_CATEGORIES: list[FeedbackCategory] = list(FeedbackCategory) + +CATEGORY_LABELS: dict[FeedbackCategory, str] = { + FeedbackCategory.BYPASS: "Bypass / Circumvention report", + FeedbackCategory.MISSING_FEATURE: "Missing Feature report", + FeedbackCategory.NOT_WORKING: "Not Working report", + FeedbackCategory.QUESTION: "Question", + FeedbackCategory.OTHER: "General feedback", +} + +def feedback_category_to_text(category: FeedbackCategory) -> str: ... +``` + +The actual API calls live in `Allow2Api`. The daemon's `submit_feedback()`, `load_device_feedback()`, and `reply_to_feedback()` methods construct the params and call through `self._api`. + +### 6.11 `credentials/__init__.py` -- CredentialBackend Protocol + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class CredentialBackend(Protocol): + """ + Interface for credential storage backends. + All methods are async to support both file I/O and keyring lookups. + """ + + async def store(self, data: dict) -> None: ... + async def load(self) -> dict | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... # childId -> ISO timestamp + async def update_last_used(self, child_id: int) -> None: ... + + +async def create_backend( + backend_type: str = "plaintext", + **kwargs: Any, +) -> CredentialBackend: + """ + Factory for credential backends. + Types: 'plaintext', 'keyring'. + """ + ... +``` + +### 6.12 `credentials/plaintext.py` -- PlaintextBackend + +Port of `credentials/plaintext.js` (97 lines). + +```python +class PlaintextBackend: + """ + Stores credentials as JSON in ~/.allow2/credentials.json. + File permissions: 0o600. Directory permissions: 0o700. + """ + + def __init__(self, *, path: str | None = None) -> None: ... + # Default: Path.home() / ".allow2" / "credentials.json" + + async def store(self, data: dict) -> None: ... + async def load(self) -> dict | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... + async def update_last_used(self, child_id: int) -> None: ... +``` + +Uses `pathlib.Path` for path manipulation, `json` for serialization, `os.chmod` for permissions. + +### 6.13 `credentials/keyring_backend.py` -- KeyringBackend + +New (Node has libsecret as TODO). + +```python +class KeyringBackend: + """ + Stores credentials in the system keyring via the `keyring` package. + Service name: 'allow2-sdk'. + Keys: 'credentials', 'last-used'. + Values: JSON-serialized strings. + + Requires: pip install allow2[keyring] + """ + + def __init__(self, *, service_name: str = "allow2-sdk") -> None: ... + + async def store(self, data: dict) -> None: ... + async def load(self) -> dict | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... + async def update_last_used(self, child_id: int) -> None: ... +``` + +All keyring calls are wrapped in `asyncio.to_thread()` since `keyring` is synchronous and may block on D-Bus (GNOME Keyring) or other system calls. + +### 6.14 `child_resolver/__init__.py` -- ChildResolver Protocol + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class ChildResolver(Protocol): + """ + Interface for resolving which child is using the device. + Can be a function or an object with resolve(). + """ + + async def resolve(self, children: list[Child]) -> dict | None: + """ + Return {"child_id": int, "child_name": str} or None. + Returning None means interactive selection is required. + """ + ... +``` + +The daemon also accepts a plain `Callable[[list[Child]], dict | None]` (sync or async) for simple use cases, matching the Node SDK's function-or-object pattern. + +### 6.15 `child_resolver/linux_user.py` + +Port of `child-resolver/linux-user.js` (81 lines). + +```python +def get_linux_username() -> str: + """Get current username from $USER or os.getlogin().""" + ... + +async def resolve(children: list[Child]) -> dict | None: + """ + Match current Linux username to a child's name or os_username field. + Case-insensitive. Skips parent entry (id=0 or name='__parent__'). + Returns {"child_id": int, "child_name": str} or None. + """ + ... + +# Also available as a class for Protocol compliance: +class LinuxUserResolver: + async def resolve(self, children: list[Child]) -> dict | None: + return await resolve(children) +``` + +### 6.16 `child_resolver/selector.py` + +Port of `child-resolver/selector.js` (50 lines). + +```python +async def resolve(children: list[Child]) -> None: + """Always returns None. Selection must happen via UI.""" + return None + +class SelectorResolver: + async def resolve(self, children: list[Child]) -> None: + return None + +def request_selection(child_shield: ChildShield) -> None: + """Emit child-select-required on the given ChildShield.""" + child_shield.emit("child-select-required", child_shield.get_children()) +``` + +### 6.17 `child_resolver/env_var.py` (new) + +Python-specific addition for headless/script scenarios. + +```python +class EnvVarResolver: + """ + Resolves child from ALLOW2_CHILD_ID environment variable. + Useful for automation scripts, CI, Raspberry Pi headless setups. + """ + + def __init__(self, *, env_var: str = "ALLOW2_CHILD_ID") -> None: ... + + async def resolve(self, children: list[Child]) -> dict | None: + """ + If ALLOW2_CHILD_ID is set, find matching child by ID. + Returns {"child_id": int, "child_name": str} or None. + """ + ... +``` + +### 6.18 `__init__.py` -- Public Exports + +```python +""" +Allow2 Device SDK v2 -- Parental Freedom for apps and devices. +https://developer.allow2.com +""" + +# Core +from allow2.daemon import DeviceDaemon +from allow2.child_shield import ChildShield +from allow2.pairing import PairingWizard +from allow2.api import Allow2Api, Allow2ApiError + +# Utilities +from allow2.updates import UpdatePoller +from allow2.request import RequestManager +from allow2.offline import OfflineHandler +from allow2.warnings import WarningScheduler +from allow2.feedback import feedback_category_to_text + +# Models +from allow2.models import ( + Activity, Child, Credentials, DaemonState, WarningLevel, + WarningThreshold, FeedbackCategory, VerificationLevel, + PairingInfo, RequestResult, DeviceContext, ActivityState, + CheckResult, +) + +# Credential backends +from allow2.credentials import CredentialBackend, create_backend +from allow2.credentials.plaintext import PlaintextBackend + +# Child resolvers +from allow2.child_resolver.linux_user import LinuxUserResolver +from allow2.child_resolver.selector import SelectorResolver +from allow2.child_resolver.env_var import EnvVarResolver + +__version__ = "2.0.0a1" +__all__ = [ + "DeviceDaemon", "ChildShield", "PairingWizard", "Allow2Api", + "Allow2ApiError", "UpdatePoller", "RequestManager", "OfflineHandler", + "WarningScheduler", "feedback_category_to_text", + "Activity", "Child", "Credentials", "DaemonState", "WarningLevel", + "WarningThreshold", "FeedbackCategory", "VerificationLevel", + "PairingInfo", "RequestResult", "DeviceContext", "ActivityState", + "CheckResult", + "CredentialBackend", "create_backend", "PlaintextBackend", + "LinuxUserResolver", "SelectorResolver", "EnvVarResolver", + "__version__", +] +``` + +--- + +## 7. Async vs Sync Design Decision + +**Primary API: async (asyncio).** All public methods that do I/O are `async def`. + +**Sync wrapper: NOT provided in v1.** Rationale: +- Adding sync wrappers doubles the API surface and test burden. +- Python 3.10+ developers working with daemons/services expect async. +- For simple scripts, `asyncio.run(daemon.start())` is one line. +- Pygame/Ren'Py integrations can run the event loop in a background thread. + +If demand arises, a `allow2.sync` module can be added later wrapping every async method with `asyncio.run()` or `loop.run_until_complete()`. + +--- + +## 8. Packaging (pyproject.toml) + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "allow2" +version = "2.0.0a1" +description = "Allow2 Device SDK -- Parental Freedom for apps and devices" +readme = "README.md" +license = {text = "SEE LICENSE IN LICENSE FILE"} +requires-python = ">=3.10" +authors = [ + {name = "Allow2 Pty Ltd"}, +] +keywords = [ + "parental-controls", + "parental-freedom", + "allow2", + "screen-time", + "device-management", + "child-safety", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Framework :: AsyncIO", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27", +] + +[project.optional-dependencies] +keyring = ["keyring>=25"] +server = ["aiohttp>=3.9"] +all = ["keyring>=25", "aiohttp>=3.9"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "respx>=0.22", # httpx mock + "coverage>=7", + "mypy>=1.11", + "ruff>=0.6", +] + +[project.urls] +Homepage = "https://developer.allow2.com" +Repository = "https://github.com/Allow2/allow2python" +Issues = "https://github.com/Allow2/allow2python/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/allow2"] + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +--- + +## 9. Testing Strategy + +### Test Structure + +``` +tests/ +├── conftest.py # Shared fixtures (mock API, mock credentials) +├── test_api.py # Allow2Api HTTP client tests +├── test_daemon.py # DeviceDaemon lifecycle + state machine tests +├── test_checker.py # Check loop + enforcement logic tests +├── test_child_shield.py # PIN verification + lockout tests +├── test_pairing.py # Pairing wizard tests +├── test_warnings.py # Warning scheduler tests +├── test_offline.py # Offline handler tests +├── test_request.py # Request More Time tests +├── test_updates.py # UpdatePoller tests +├── test_feedback.py # Feedback tests +├── test_credentials.py # PlaintextBackend + KeyringBackend tests +├── test_child_resolvers.py # All resolver tests +└── test_models.py # Dataclass serialization tests +``` + +### Testing Approach + +| Layer | Tool | Approach | +|-------|------|----------| +| HTTP client | `respx` | Mock httpx requests. Test all endpoints, error codes (401, 500), timeouts, JSON parsing failures. | +| State machine | `pytest-asyncio` | Test all state transitions: unpaired->pairing->paired->enforcing->parent. Test edge cases: double-start, stop-while-pairing, 401-during-enforcement. | +| PIN verification | Pure unit tests | Test hash_pin, safe_compare, lockout progression, session timeout. No I/O. | +| Warning scheduler | Pure unit tests | Test threshold firing, fire-once-per-level, reset behavior. | +| Offline handler | tmp_path fixture | Test cache write/read, grace period math, deny-by-default, corrupt file handling. | +| Credentials | tmp_path fixture | Test store/load/clear, file permissions (on Linux), last-used tracking. | +| Child resolvers | monkeypatch | Mock $USER, test case-insensitive matching, parent entry skipping. | +| Integration | Full stack with respx | DeviceDaemon with mocked API, test complete flows: pair -> select child -> check loop -> warning -> soft-lock -> hard-lock. | + +### Coverage Target + +80% line coverage minimum. 100% on critical paths: state machine transitions, PIN verification, offline deny logic, 401 handling. + +### CI Matrix + +```yaml +# GitHub Actions +strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + os: [ubuntu-latest, macos-latest] +``` + +Windows is not a target for the device SDK (the Node SDK targets Linux specifically), but the test suite should pass on Windows with the exception of file-permission tests. + +--- + +## 10. Usage Examples + +### 10.1 Basic Desktop App (Linux) + +```python +import asyncio +from allow2 import ( + DeviceDaemon, Activity, PlaintextBackend, LinuxUserResolver, +) + +async def main(): + backend = PlaintextBackend() + resolver = LinuxUserResolver() + + daemon = DeviceDaemon( + activities=[Activity(id=1), Activity(id=8)], # Internet + Screen Time + credential_backend=backend, + child_resolver=resolver, + device_name="Living Room PC", + ) + + # Wire up event handlers + daemon.on("child-select-required", lambda data: + print(f"Select a child: {[c['name'] for c in data['children']]}")) + daemon.on("warning", lambda w: + print(f"Warning [{w['level']}]: {w['remaining']}s remaining")) + daemon.on("soft-lock", lambda d: + print(f"LOCKED: {d['reason']}")) + daemon.on("unpaired", lambda d: + print("Device unpaired!")) + + await daemon.start() + + # Keep running until interrupted + try: + await asyncio.Event().wait() + except KeyboardInterrupt: + daemon.stop() + +asyncio.run(main()) +``` + +### 10.2 Raspberry Pi Kiosk + +```python +import asyncio +from allow2 import ( + DeviceDaemon, Activity, PlaintextBackend, SelectorResolver, +) + +async def main(): + daemon = DeviceDaemon( + activities=[Activity(id=3)], # Gaming + credential_backend=PlaintextBackend(), + child_resolver=SelectorResolver(), + device_name="Pi Arcade Cabinet", + check_interval=30, + ) + + daemon.on("pairing-required", lambda info: + show_on_display(f"PIN: {info.pin}")) + daemon.on("child-select-required", lambda data: + show_child_picker(data["children"])) + daemon.on("soft-lock", lambda _: + blank_screen()) + daemon.on("unlock", lambda _: + resume_game()) + + await daemon.start() + await daemon.open_app() # Trigger pairing if not paired + + await asyncio.Event().wait() + +asyncio.run(main()) +``` + +### 10.3 Automation Script (headless) + +```python +import asyncio +from allow2 import DeviceDaemon, Activity, PlaintextBackend, EnvVarResolver + +async def main(): + # Pre-paired device, child set via ALLOW2_CHILD_ID=123 + daemon = DeviceDaemon( + activities=[Activity(id=1)], + credential_backend=PlaintextBackend(), + child_resolver=EnvVarResolver(), + device_name="Homework Timer", + ) + + daemon.on("activity-blocked", lambda d: + print(f"Activity {d['activity_id']} blocked. Stopping.")) + + await daemon.start() + + # Do work while enforcement runs in background... + await do_homework_timer() + + daemon.stop() + +asyncio.run(main()) +``` + +### 10.4 Pygame Integration + +```python +import asyncio +import threading +from allow2 import DeviceDaemon, Activity, PlaintextBackend, SelectorResolver + +# Run Allow2 daemon in background thread +daemon = DeviceDaemon( + activities=[Activity(id=3), Activity(id=8)], + credential_backend=PlaintextBackend(), + child_resolver=SelectorResolver(), + device_name="My Pygame Game", +) + +locked = False + +def on_soft_lock(_): + global locked + locked = True + +def on_unlock(_): + global locked + locked = False + +daemon.on("soft-lock", on_soft_lock) +daemon.on("unlock", on_unlock) + +def run_daemon(): + loop = asyncio.new_event_loop() + loop.run_until_complete(daemon.start()) + loop.run_forever() + +daemon_thread = threading.Thread(target=run_daemon, daemon=True) +daemon_thread.start() + +# Pygame main loop +import pygame +pygame.init() +screen = pygame.display.set_mode((800, 600)) +running = True + +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + if locked: + screen.fill((0, 0, 0)) + # Show "Time's up!" overlay + else: + # Normal game rendering + screen.fill((30, 30, 60)) + + pygame.display.flip() + +daemon.stop() +pygame.quit() +``` + +--- + +## 11. Key Design Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Async framework | asyncio (stdlib) | No external dependency. Universal in Python 3.10+. | +| HTTP client | httpx | Async-native, sync fallback, modern, well-maintained. | +| Event system | Custom EventEmitter | Zero-dependency, matches Node SDK API exactly. | +| Type system | dataclasses + enums | Stdlib, IDE-friendly, no pydantic dependency. | +| Sync wrappers | Not in v1 | Avoids API surface bloat. `asyncio.run()` suffices. | +| Credential keyring | Optional via `[keyring]` extra | Not everyone has GNOME/KDE. Plaintext is the default. | +| Pairing HTTP server | Optional via `[server]` extra | Most integrations handle pairing UI themselves. | +| Build system | hatchling | Modern, fast, PEP 621 compliant. No setup.py needed. | +| Linter | ruff | Fast, replaces flake8+isort+pyupgrade. | +| Type checker | mypy (strict) | Full type safety. `py.typed` marker for downstream. | + +--- + +## 12. Implementation Order + +Recommended implementation sequence, with each step producing a testable unit: + +| Phase | Files | Depends On | Est. Lines | +|-------|-------|------------|------------| +| 1 | `models.py`, `__init__.py`, `pyproject.toml` | Nothing | ~200 | +| 2 | `api.py` | models | ~250 | +| 3 | `credentials/` (all) | models | ~200 | +| 4 | `child_shield.py` | models, EventEmitter | ~350 | +| 5 | `warnings.py` | models | ~60 | +| 6 | `offline.py` | models | ~100 | +| 7 | `checker.py` | api, models, warnings | ~250 | +| 8 | `child_resolver/` (all) | models | ~100 | +| 9 | `request.py` | api, models, EventEmitter | ~100 | +| 10 | `updates.py` | api, models, EventEmitter | ~140 | +| 11 | `feedback.py` | models | ~30 | +| 12 | `pairing.py` | api, credentials, EventEmitter | ~300 | +| 13 | `daemon.py` | everything | ~600 | +| 14 | Tests | everything | ~1500 | + +**Total estimated:** ~2,880 lines of source + ~1,500 lines of tests. + +--- + +## 13. API Endpoint Mapping + +Complete mapping of Node SDK API calls to Python SDK methods, ensuring nothing is missed: + +| Node Method | Python Method | Endpoint | HTTP | +|-------------|---------------|----------|------| +| `initQRPairing` | `init_qr_pairing` | `/api/pair/qr/init` | POST | +| `initPINPairing` | `init_pin_pairing` | `/api/pair/pin/init` | POST | +| `checkPairingStatus` | `check_pairing_status` | `/api/pair/status/{id}` | GET | +| `check` | `check` | `/serviceapi/check` | POST | +| `getUpdates` | `get_updates` | `/api/getUpdates` | GET | +| `createRequest` | `create_request` | `/api/request/createRequest` | POST | +| `getRequestStatus` | `get_request_status` | `/api/request/{id}/status` | GET | +| `submitFeedback` | `submit_feedback` | `/api/feedback/submit` | POST | +| `loadFeedback` | `load_feedback` | `/api/feedback/load` | POST | +| `feedbackReply` | `feedback_reply` | `/api/feedback/reply` | POST | +| `logUsage` | `log_usage` | `/api/logUsage` | POST | + +All endpoints use `deviceToken` from the API client's `self.token` (the application-level token, not per-device). The per-device identity is `pairId` + `pairToken`. + +--- + +## 14. Critical Behavioral Parity Checklist + +These behaviors MUST match the Node SDK exactly. Each should have a dedicated test: + +- [ ] State machine transitions: unpaired -> pairing -> paired -> enforcing -> parent +- [ ] HTTP 401 from any endpoint -> clear credentials, emit `unpaired`, stop loops +- [ ] Screen Time (activity 8) exhaustion -> `soft-lock` with reason `screen-time-exhausted` +- [ ] All activities blocked -> `soft-lock` with reason `all-activities-blocked` +- [ ] Soft-lock timeout -> `hard-lock` after `hard_lock_timeout` seconds +- [ ] Soft-lock cancelled on unlock -> clear hard-lock timer +- [ ] Warning fire-once-per-level: same (activity, level) never emits twice +- [ ] Warning reset on time extension or child change +- [ ] PIN hash: `SHA-256(pin + salt)` as lowercase hex +- [ ] PIN comparison: constant-time (`hmac.compare_digest`) +- [ ] PIN lockout: 5 failed attempts -> 300s lockout +- [ ] Lockout expiry: attempts reset after lockout period +- [ ] Session timeout: clears child, emits `session-timeout`, requests re-selection +- [ ] Offline grace period: use cached result for `grace_period` seconds +- [ ] Offline deny: after grace period, emit `offline-deny` +- [ ] Offline clear: successful API check clears offline state +- [ ] Pairing PIN: server-assigned (not locally generated) +- [ ] Pairing QR URL: `https://app.allow2.com/pair?pin=XXXXXX` +- [ ] Pairing poll: 5s interval, 30-minute max (360 polls) +- [ ] Pairing retry: if init fails, retry every 5s until connected +- [ ] Pairing connection-status: emit after 2 consecutive poll failures +- [ ] Request polling: 5s interval, 5-minute timeout +- [ ] feedbackDeviceAuth: uses userId + pairId + pairToken (NOT deviceToken) +- [ ] Feedback categories: bypass, missing_feature, not_working, question, other +- [ ] canSubmitFeedback: parent=true, child with linked account=true, child without=false +- [ ] Heartbeat: runs when paired but no child selected, 60s interval +- [ ] Heartbeat detects 401 -> unpair +- [ ] Children sorted by lastUsedAt descending (most recent first) +- [ ] Child resolver accepts function or object with resolve() +- [ ] Credential backend file permissions: 0o600 (file), 0o700 (directory) +- [ ] UUID generation: random UUID, persisted across restarts +- [ ] Check loop: log=true by default, includes timezone diff --git a/pair.py b/pair.py deleted file mode 100755 index cf0566f..0000000 --- a/pair.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/python - -import allow2, sys, getopt - -def usage(): - print 'pair.py -u
Enter this PIN in the Allow2 app on your phone to pair this device.
+This device is now connected to your Allow2 account. You can close this window.
+