@agimon-ai/po-identity
Portable identity home for Po
Package details
Install @agimon-ai/po-identity from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@agimon-ai/po-identity- Package
@agimon-ai/po-identity- Version
0.0.2-alpha.54- Published
- Nov 4, 2026
- Downloads
- 11.3K/mo · 7,729/wk
- Author
- agiflow-ai
- License
- BUSL-1.1
- Types
- package
- Size
- 273.2 KB
- Dependencies
- 9 dependencies · 0 peers
Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
Po identity
Selectable global identity home for Node and SQLite-backed Durable Objects. Better Auth and its
Expo, SSO and passkey integrations are pinned to 1.7.12. Pairing-only hosts do not compose this package.
No organization is created at signup. personalOrg(subject) provisions the personal organization,
owner membership and unique subject marker in one transaction.
Composition
Node's global plugin consumes only the server environment explicitly admitted by the CLI, once at
composition. It injects po/sqlite and po/fs. It does not use a session's po/config: that would let
one session configure an issuer shared by every session. This was the coordinator-approved phase-1
configuration decision. Configuration changes require restart. A future global config service can
provide hot reload. The Worker uses the same identityConfiguration(env) function.
Required environment keys, with no defaults:
PO_IDENTITY_ISSUER,PO_IDENTITY_JWT_KEY_WRAP(at least 32 characters).PO_UNSENDBOX_ENDPOINT,PO_UNSENDBOX_ORGANIZATION_ID,PO_UNSENDBOX_API_KEY,PO_UNSENDBOX_FROM.
Optional OAuth configuration: PO_APPLE_CLIENT_ID, PO_APPLE_CLIENT_SECRET, PO_APPLE_BUNDLE_ID,
PO_GOOGLE_CLIENT_ID, PO_GOOGLE_CLIENT_SECRET, PO_MICROSOFT_CLIENT_ID,
PO_MICROSOFT_CLIENT_SECRET, PO_GITHUB_CLIENT_ID, PO_GITHUB_CLIENT_SECRET.
An enabled provider requires its secret. Apple's client secret is a server-held Apple client-secret
JWT, never an app credential. Operators must renew it before expiry. Native code exchange uses the
bundle ID when configured, rather than silently exchanging a native code as a web Services ID.
PO_IDENTITY_WEB_RP_ID enables web account passkeys against the configured issuer origin. Native
account passkeys remain disabled. Expo trusts po://; native scheme/capability changes belong
to phase 2. SSO includes OIDC and SAML, with no unsolicited-login override.
Node stores identity.sqlite under the admitted Po home, respecting PO_HOME. The Worker binds
PO_IDENTITY to one PoIdentityObject per configured issuer. Secrets are operator bindings, not
checked-in values. Hosts must serve the issuer through trusted HTTPS in production.
Issuer endpoints
All paths are under /api/auth/. Node mounts them only when the global identity API is composed.
The Worker branches to the identity object before its legacy attach-token gate. Other routes keep
their existing authorization. Better Auth's session-only /token and built-in deletion endpoints
are disabled: they must not bypass device binding or the deletion outbox.
- Better Auth: email OTP, magic link, configured social providers, SSO and optional web passkeys.
GET jwks: public signing keys. Private keys and OAuth tokens are wrapped at rest.POST personal-org: authenticated lazy provisioning.POST devices/enroll: authenticated first-device TOFU with proof of the new private key. A durable enrollment marker forbids another TOFU after enrollment or revocation. Returns a 256-bit recovery code once; only a salted PBKDF2-SHA-256 hash (600,000 iterations) is stored. Optionalassurance: "hardware" | "software"is signed and stored as self-reported. Software enrollment is allowed; App Attest and Play Integrity are future attestation upgrades. Later devices need a current trusted-device C10approvalor an owned hub'shubEnrollment.POST identity-token: session plus fresh enrolled-device proof, current person and membership. C1 claims bind the device JWK thumbprint and network audience, with a 600-second lifetime.POST delegated-token: fresh calling-host proof plus enrolled-device-signed C5 consent. It needs no person session. The composition must supplycallingHostKey(networkId, hostId)from current roster authority. Without that port it fails closed, not to a caller-advertised key. C2 binds the host key, host ID and grant ID; expiry is the smaller of one hour and grant expiry.POST device-key: cookie-free lookup withIdentityDeviceKeyRequestfromapiContracts. Send{proof}signed withpo/host-request/v1over{issuer, hostId, keyId, issuedAt, expiresAt, nonce}by the hub key previously admitted throughhubs/claim. Lifetime is at most 60 seconds, with the same conservative 30-second skew deadline. Nonces (16 to 128 base64url characters) are durably single-use per host, including failed key lookups. Returns{keyId, issuer, subject, publicKey, revoked, revocationEpoch, trust, organizationId}from the registry, never from caller-advertised keys. Only the claiming person's keys or keys of current members of a hub's claimed organization where its claimant is still a member are visible. Unknown, out-of-scope, malformed, stale, replayed, unenrolled and revoked-host requests all return the same no-store 404. Migration 3 preserves legacy owner-only hub scope; fresh valid claims record org scopes, including multiple organizations per hub. The registry'sidentity_hub.revokedflag refuses lookup and further enrollment/claims.GET devices: same-person device list, public account keys, trust, self-reported assurance, current personrevocationEpoch, the durablehasEnrolledmarker and any pending recovery.POST devices/approval/request: signed-in new-keyapproval-requestproof creates an opaque 16-character, five-minute code.GET devices/approval?code=...is same-person lookup/polling;POST devices/approvaladmits the trusted device's signed C10 envelope. The new device then callsdevices/enrollwith a fresh new-key proof and that approval. Final admission is durable and single-use. Both devices must be signed in; no email address enters the approval.POST hubs/claim: current enrolled-device C10 claim plus the nested QR host attestation records the person's hub key. The hub must independently enforce its own local pin; the issuer never authorizes on its behalf. Advertised unregistered hub keys cannot approve enrollment.POST devices/recovery/code:{proof, recoveryCode}with actionrecover-codeenrolls at once and consumes the code.POST devices/recovery/start:{proof}with actionrecover-startpersists a 72-hour due time. It durably queues a start email with a cancel link and one notice per trusted device throughnotifyTrustedDevice. Missing or failed delivery ports leave jobs pending. The email token is hashed in the pending row and wrapped in the delivery outbox.POST devices/recovery/cancel:{cancelToken}from email, or{proof}signed by a trusted same-person device or the requesting new device. Actioncancel-recoverybindstargetDeviceIdto the recovery ID. The email GET only displays confirmation, avoiding mutation by link scanners. Completion durably queues a second email.POST devices/recovery/admin-approve: current enrolled org owner/admin proof, actionadmin-approve,orgthe limiting organization andtargetDeviceIdthe pending recovery ID. The signed-in pending device and admin must both belong to that organization. The resulting device can mint only for that org and cannot approve unrestricted devices or claim hubs.POST devices/revoke: enrolled-device proof and a same-person target. Revocation immediately stops new mints, including C2 grants signed by that device.GET revocations?after=<sequence>: public cursor feed, no addresses or credentials, no cache. Enrolled hosts can fetch it without a person cookie or host proof.POST delete-account: refuses a sole company owner with other members, deletes person-owned identity and device records, emits revocation, and durably queues personal-org/cloud-workspace purge and Apple revocation before removing credentials.
Proofs use identityProofFields(issuer, subject, action, proof) with the tag
po/identity-mint/v1 and P-256 SHA-256 P1363 base64url signatures. Actions include enroll, mint, delegate, revoke, approval-request, recover-code,
recover-start, cancel-recovery and admin-approve. The helper binds timestamps, nonce, org, device ID, optional enrollment
key, optional revocation target and the exact serialized grant. Proof times are milliseconds;
JWT dates are seconds. Proof lifetime is at most 60 seconds. Future issue time is at most 30
seconds; acceptance ends at expiry minus 30 seconds, inclusive. When supplied, assurance appends ["assurance", value] to the signed fields; omitted assurance
keeps phase-1 signed bytes unchanged. Nonces are admitted durably in
transactions before tokens are released. Signing is followed by a current-authority recheck.
A destination still needs phase-3 JWT validation, C3/C4 proofs, durable nonce admission and local
authorization. Issuance is not workspace permission.
Native Apple sign-in sends authorizationCode beside idToken: { token, nonce } to
sign-in/social. Missing code or nonce is refused. The issuer verifies Apple's JWKS, issuer,
audience, time and nonce, exchanges the one-use code, verifies the exchanged subject and stores
an encrypted issuer/subject/provider-bound refresh credential before returning a session.
Deletion retains that ciphertext through Apple outages and retries via the outbox. No refresh
token is returned to the app. Provider response bodies and addresses are never logged.
Persistence and limits
The SQLite adapter is the Better Auth spike adapter moved into runtime source, with literal predicates, one-row versus many-row mutations, transaction-local executors, counts and versioned migration admission. Initial schema creation is tied to the pinned plugin versions. Plugin/schema upgrades need explicit new migrations; this is not a general Better Auth migration engine or a completed upstream adapter conformance certification. The Node/workerd rollback and concurrency tests now import the real package adapter.
Node drains the identity outbox on start and periodically. Recovery uses the injected
scheduleRecovery port and a Node scheduler. The identity DO's single alarm multiplexes recovery
due times and outbox retries; both deadlines are restored from SQLite after reconstruction.
runDueRecovery uses the injected clock and atomically completes only due, uncancelled requests.
Every completed recovery increments the person's epoch atomically with device insertion and a
feed row carrying epoch. The recovered device stores that enrollment epoch. SQLite refuses
non-increasing epoch updates. Other enrolled devices stay trusted, but C5 revocationEpoch must
exactly equal the current epoch to mint C2. Missing grant epochs mean zero, deliberately voiding
all legacy grants after the first recovery. C1/C2 JWTs carry revocation_epoch.
Phase 3 still needs destination JWT/feed checks. A pre-recovery identity token can remain valid until its own expiry, at most ten minutes, at a destination that has not fetched the feed. Delegated tokens retain their existing at-most-one-hour expiry. Propagation is not bounded here.
Hub policy is exported as createHubDeviceTrust from core's remote-access store. Owner claims,
account pins and replay admission persist in hub-trust.json. Approval requires a device already
pinned here and verified current issuer device authority. Recovered keys need a fresh QR pin in
the current recovery epoch. Issuer enrollment alone is never owner-level authority. Phase 3 must
compose this gate into federation control routes; it is intentionally not wired into legacy
layer-2 native registration, whose host-scoped keys differ from the account device key.
Phase-4 provisioning API
ensurePersonalOrg(subject, 'cloud-workspace') atomically creates the personal org, owner membership,
unique (issuer, subject) marker, first workspace allocation and materialization outbox item.
Concurrent calls and retries return the same {orgId, workspaceId}. Slug collisions retry inside
that transaction (up to 16 attempts). Existing subject-only markers migrate without replacing orgs.
PersonalOrgTrigger reserves own-hub-link for phase 5; it is currently refused.
The authenticated POST personal-org accepts {trigger: "cloud-workspace"} and returns
{organizationId, orgId, workspaceId} for bootstrap before device enrollment and token minting.
Legacy {} and personalOrg(subject) remain org-only compatibility calls. Signup still creates no org.
The trusted internal API also exposes allocateWorkspace(subject, orgId) (a new allocation per call),
listOrgWorkspaces(subject, orgId) and isWorkspaceAllocatedToOrg(subject, orgId, workspaceId).
All require a current account and org membership. The last method returns false for unallocated ids.
The Worker must authenticate the subject before calling these methods; they are not bearer endpoints.
orgRole and federationAuthority expose live membership and device/epoch authority to its verifier.
The latter fails closed on issuer, membership, device revocation and restricted-org mismatches.
listProvisioningOutbox() returns typed {id, kind: "materialize-workspace", payload: {orgId, workspaceId}}
items only. ackProvisioningOutbox(id) is idempotent and cannot ack recovery, purge or Apple jobs.
Alternatively the host supplies materializeWorkspace(payload) and calls drainOutbox() (also used
by runDueRecovery). Missing consumers and failed deliveries stay pending. Materialization is an
at-least-once, separate cross-object step: its consumer must be idempotent. Hosts restore retry
alarms from persisted pending jobs on boot and schedule after allocation. Identity commits never
claim cross-object atomicity. Deletion cancels personal-org allocation/materialization records and
queues the existing purge job. Host-side purge and in-flight materialization need coordinated
idempotent handling; production alarm delivery and full cloud deletion remain Worker verification.
Types are exported from the package root and apiContracts. No dependency changes are required.
Registry migration 4 adds provisioning tables and repairs the monotonic epoch update trigger when
opening older v2 databases that did not have it. SQLite upgrade tests preserve v2 devices, hubs and
pending outbox data. The purge consumer is a host port. Until wired, purge jobs remain pending durably.
No purge SLA, roster cadence or emergency JWKS-cache bound is invented here.
Calling-host roster authority and destination authorization remain separate phases. Apple exchange/revocation tests use fake HTTP providers. Real providers, native
Apple return, deployment, production quotas and provider key rotation remain unverified.
Phase-1 local evidence
Final checks in the multihost worktree:
NX_DAEMON=false pnpm nx run-many -p @agimon-ai/po-identity @agimon-ai/po-cloudflare @agimon-ai/po-core @agimon-ai/po-web-security @agimon-ai/po -t lint,typecheck,build --parallel=1 --output-style=static
NX_DAEMON=false pnpm nx run @agimon-ai/po-cloudflare:test --maxWorkers=1 --output-style=static
NX_DAEMON=false pnpm nx run-many -p @agimon-ai/po-identity @agimon-ai/po-core @agimon-ai/po-web-security @agimon-ai/po -t test --parallel=1 --output-style=static
pnpm --filter @agimon-ai/po-cloudflare exec wrangler deploy --dry-run --outdir .wrangler/identity-bundle
All listed Nx targets passed. Full test counts: identity 11, Cloudflare 64, core 1,981, security 126,
CLI 962 passed with one existing skipped test. A parallel run hit the already documented isolate
memory-test duration bound; the isolated rerun and full single-worker Cloudflare suite passed.
No isolate code, assertions or time bounds were changed. Worktree-local vibe-lint preflight,
including po-edge-portable, and git diff --check passed.
Wrangler 4.150.0 reported total upload 13,553.00 KiB raw, 3,129.00 KiB gzip. The main emitted
worker.js was 13,375,137 raw bytes, 2,969,373 gzip bytes; total upload also includes WASM.
This is the composed Po Worker, including identity and SAML, not the smaller Better Auth and SAML spikes.
It exceeds a 3 MiB compressed free-worker ceiling. Paid-plan limits, deployment, CPU/startup
budgets and actual production-provider behavior were not verified. Dry-run artifacts remain ignored.
Phase-2 verification boundary
Focused protocol tests cover signed approval chains, forgery/expiry/replay refusal, owned-hub attestation admission, one-use recovery codes, software assurance, org-only admin recovery, monotonic epochs, stale/invalid grant epochs, email and device cancellation, exact 72-hour completion and restart. A shared fixture runs the real KDF and signature/envelope helpers on both Node SQLite and workerd DO SQLite, with only delivery, scheduling and time injected. The hub-store tests cover account-association round trips, durable claims and approval chains, and recovered-device owner denial until QR. A diagnostic spy checks no recovery address is logged.
Real email deliverability, the native push adapter, production DO alarm delivery across eviction, production issuer-to-hub authority synchronization and end-to-end native QR attestation remain unverified. No dependency or lockfile changes are needed by this protocol implementation.
Network roster issuer (phase 5)
GET /api/auth/roster-metadatapublishes{issuer, rosterPublicKey}. The key is base64url DER P-256 SPKI, not PEM. Claim-time discovery must fetch it from the already trusted issuer and pin it locally, never trust a key in a roster response. The dedicated private roster key is persisted encrypted withjwtKeyWrap. No route exposes it. Rotation currently requires every hub to re-pin; the upgrade path is signed key-transition records.POST /api/networks/own-hub-linkreturns{networkId}and lazily creates one personal org per person in the existing transaction. It does not allocate a cloud workspace.GET /api/networks/:networkId/rosterreturns an issuer-signed C6 envelope. Current member identity tokens (ES256, issuer and network audience, live membership, device and recovery epoch) or existing signed-in sessions are accepted. Host polling may instead sendx-po-roster-proof: a JSONpo/host-request/v1envelope signed by the current host key, with{networkId, hostId, destination: issuer, method: 'GET', path: exactPathname, nonce, issuedAt, expiresAt}. Envelope freshness and durable single-use nonces apply; removed or evicted hosts cannot poll.POST /api/networks/:networkId/hostsaccepts{entry}from the entry owner's current membership;POST /api/networks/:networkId/changesaccepts{change}from an owner or admin. A change names the next epoch. Before any epoch or eviction write, the issuer verifies host entries against the submitting owner's enrolled, non-revoked device, and eviction/voter changes against a current org owner's enrolled, non-revoked device. Durable records verify as of issuance, with at most 30 seconds of future skew. Term changes require a current voter host's own key and verify at the current time. These enrollment checks do not replaceapplyRosterwith independently pinned keys. Host entries, changes and all Better Auth membership writes advance the epoch transactionally. Membership removal permanently marks that person's host IDs unavailable.- Composition APIs on
createIdentityHome()arenetworkRoster(subject, networkId),registerRosterHost(subject, networkId, entry),registerRosterChange(subject, networkId, change),rosterIssuerPublicKey(),hostKey(networkId, hostId),rosterPins(subject, networkId), andregisterCloudRosterHost(subject, networkId, {hostId, publicKey, endpoints?}). The cloud helper signs with the issuer and uses reserved device key IDissuer-roster; identical calls preserve the original owner and epoch.rosterPinssupplies{hostId, issuer, subject, keyId, publicKey}records for Po Cloud only. Its trust ceiling is issuer-enrolled devices, not local hub pins.
Snapshots retain all evictions, the latest voter change per live host, and the current epoch's term changes. The issuer orders these records and may withhold changes. A new joiner skips an eviction it cannot verify only if the target host is absent from both its local state and the snapshot. It then lacks that tombstone, so a malicious issuer could later re-include an old device-signed host entry. Independent hub pins remain mandatory. Compaction requires a signed checkpoint. Roster authorization does not replace C9 quorum commitment, and this issuer does not claim Byzantine safety or availability in small networks.
Durable device host entries and owner voter/eviction decisions verify as of issuance, including on catch-up after their declared expiry. The fresh issuer snapshot supplies freshness. Term diagnostics still verify at the current time. Local pin revocation or rotation removes the old key's authority, so its old records stop verifying. A compromised still-pinned owner key can backdate records until it is unpinned; the issuer cannot correct a host's local trust decision.