{"_id":"@aiquants/auth-directory-core","_rev":"2-b03d4988168f80c5cc6f8bec00e62d9b","name":"@aiquants/auth-directory-core","dist-tags":{"latest":"0.2.1"},"versions":{"0.2.0":{"name":"@aiquants/auth-directory-core","version":"0.2.0","keywords":["auth","directory","group","synchronization","rbac","typescript"],"author":{"url":"https://x.com/fehdek","name":"fehde-k"},"license":"MIT","_id":"@aiquants/auth-directory-core@0.2.0","maintainers":[{"name":"fehde-k","email":"owner@aiquants.co.jp"},{"name":"fehde","email":"genbu0498@gmail.com"}],"dist":{"shasum":"a13dfa7c9edd478d50e72b5898247aea072b7ed3","tarball":"https://registry.npmjs.org/@aiquants/auth-directory-core/-/auth-directory-core-0.2.0.tgz","fileCount":9,"integrity":"sha512-oM3trr2NLN/qfSQU9eSgozfQFECksqBCAv4ZHZ4W5j4rCuxQsQrEvD4JcMsQtOYk3aJ/l5EjWWOgiGIXJowM0g==","signatures":[{"sig":"MEUCIAe0xFXe5W1CtSverErrKs2de/ChJDtvkVVLlvq0L20xAiEAjqJfCI5pyZSQhAu6v0PZmQw1V8kIWWJGESMSt2E6DkM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":138028},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","engines":{"node":">=18.0.0","pnpm":">=8.0.0"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"}},"scripts":{"dev":"tsup --watch","lint":"biome lint src/","test":"vitest run","build":"tsup","check":"biome check src/","clean":"rimraf dist","check:fix":"biome check --write src/","typecheck":"tsc --noEmit","build:watch":"tsup --watch","publish:major":"pnpm run typecheck && pnpm run --if-present test && pnpm version major --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks","publish:minor":"pnpm run typecheck && pnpm run --if-present test && pnpm version minor --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks","publish:patch":"pnpm run typecheck && pnpm run --if-present test && pnpm version patch --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks","test:coverage":"vitest run --coverage"},"_npmUser":{"name":"fehde","email":"genbu0498@gmail.com"},"description":"Provider-agnostic core for external directory group synchronization: the read-only DirectoryProvider port, a pure reconciler with a removal circuit breaker, and a pure tenant-membership rule. No network, no database, no vendor SDK.","directories":{},"sideEffects":false,"_nodeVersion":"26.0.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","rimraf":"^6.1.2","vitest":"^4.1.8","typescript":"^5.9.3","@aiquants/auth-core":"0.3.0","@aiquants/authz-core":"0.5.0"},"peerDependencies":{"@aiquants/auth-core":"^0.3.0","@aiquants/authz-core":"^0.5.0"},"_npmOperationalInternal":{"tmp":"tmp/auth-directory-core_0.2.0_1788959839059_0.025844576420622323","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"_id":"@aiquants/auth-directory-core@0.2.1","dist":{"shasum":"5a2911faa22cae4cb387e12de7f085fc429ebe1d","tarball":"https://registry.npmjs.org/@aiquants/auth-directory-core/-/auth-directory-core-0.2.1.tgz","fileCount":7,"integrity":"sha512-gIPjKa6bMZ6tDQqYjH5nhJAZh13pH1XAILmIBZ5BPoaRVcHbzbOnmUOwNnrNnIoeTPsB5xqTZNHnbVmS31Witw==","signatures":[{"sig":"MEYCIQCjhLSkxl7WpGn5MgwCN8T9/LSPDq4XuMF4VtlB0JI1igIhAKFb5A/qLP8n55JlzrqvnbVXfDIWvaz2VUYHYb+P250c","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIG426gZQzma380MLvusWPsb2hHtQhE33B44e30vro/A8AiAPXXQTAbZ2xg73TzJTOjzaA+qHDw0CAGVttP0NI7koVg=="}],"unpackedSize":28550},"main":"dist/index.js","name":"@aiquants/auth-directory-core","types":"dist/index.d.ts","author":{"url":"https://x.com/fehdek","name":"fehde-k"},"module":"dist/index.mjs","engines":{"node":">=18.0.0","pnpm":">=8.0.0"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"}},"license":"MIT","scripts":{"dev":"tsup --watch","lint":"biome lint src/","test":"vitest run","build":"tsup && node ../../.config/scripts/strip-dts-comments.mjs dist","check":"biome check src/","clean":"rimraf dist","check:fix":"biome check --write src/","typecheck":"tsc --noEmit","build:watch":"tsup --watch","publish:major":"pnpm run typecheck && pnpm run --if-present test && pnpm version major --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks","publish:minor":"pnpm run typecheck && pnpm run --if-present test && pnpm version minor --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks","publish:patch":"pnpm run typecheck && pnpm run --if-present test && pnpm version patch --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks","test:coverage":"vitest run --coverage"},"version":"0.2.1","_npmUser":{"name":"fehde","email":"genbu0498@gmail.com"},"keywords":["auth","directory","group","synchronization","rbac","typescript"],"description":"Provider-agnostic core for external directory group synchronization: the read-only DirectoryProvider port, a pure reconciler with a removal circuit breaker, and a pure tenant-membership rule. No network, no database, no vendor SDK.","directories":{},"maintainers":[{"name":"fehde-k","email":"owner@aiquants.co.jp"},{"name":"fehde","email":"genbu0498@gmail.com"}],"sideEffects":false,"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","rimraf":"^6.1.2","vitest":"^4.1.8","typescript":"^5.9.3","@aiquants/auth-core":"0.6.0","@aiquants/authz-core":"0.6.0"},"peerDependencies":{"@aiquants/auth-core":"^0.6.0","@aiquants/authz-core":"^0.6.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/auth-directory-core_0.2.1_1789684206557_0.41935987568255073"}}},"time":{"created":"2026-09-09T13:17:18.713Z","modified":"2026-09-17T22:30:06.789Z","0.2.0":"2026-09-09T13:17:19.201Z","0.2.1":"2026-09-17T22:30:06.640Z"},"author":{"url":"https://x.com/fehdek","name":"fehde-k"},"license":"MIT","keywords":["auth","directory","group","synchronization","rbac","typescript"],"description":"Provider-agnostic core for external directory group synchronization: the read-only DirectoryProvider port, a pure reconciler with a removal circuit breaker, and a pure tenant-membership rule. No network, no database, no vendor SDK.","maintainers":[{"name":"fehde-k","email":"owner@aiquants.co.jp"},{"name":"fehde","email":"genbu0498@gmail.com"}],"readme":"# @aiquants/auth-directory-core\n\nProvider-agnostic core for synchronizing group membership from an external identity directory\n(Google Workspace, LDAP, …) into a local RBAC schema.\n\nIt contains **no network calls, no database access, and no vendor SDK** — only the read-only port\nthe provider adapters implement, and the two pure functions that make the dangerous decisions:\nwhat to delete, and who may act as a tenant.\n\n## Why the pure core exists\n\nA directory sync has exactly one irreversible operation: **removal**. A partial read, an emptied\nupstream group, or a misconfigured filter all look identical to \"everyone left\" — and acting on\nthat revokes access for the whole company. Isolating the set arithmetic here means every one of\nthose cases is reproducible in a unit test, with no directory and no database.\n\n## Exports\n\n### `DirectoryProvider` (port)\n\n```ts\ntype DirectoryProvider = {\n    getGroup(externalId: string): Promise<DirectoryGroup | null>\n    listGroupMembers(externalId: string, mode: \"direct\" | \"transitive\"): Promise<DirectoryMember[]>\n}\n```\n\nRead-only by contract. There is deliberately no write method, so no code path exists in which a\nsync can modify the upstream directory.\n\n> ⚠️ `listGroupMembers` must return a **complete** list or throw. The caller treats what it returns\n> as the whole upstream truth and deletes everything not in it, so a part-way listing becomes a\n> mass revocation. If paging cannot be completed, throw — never return the partial array.\n\n`externalId` is the stable upstream id, never the group address: an address can be renamed\nupstream, and anchoring on it makes the link silently break the moment someone renames the group.\n\n### `reconcile(local, remote, policy)`\n\nTurns \"what upstream says\" into \"what to write locally\":\n\n| Output | Meaning |\n| --- | --- |\n| `ledgerUpserts` / `ledgerDeletes` | The member ledger, which records every upstream member — including people who have never signed in and therefore have no local user row |\n| `projectionAdds` / `projectionRemoves` | The real membership table, which only ever holds resolvable users |\n| `unresolvedEmails` | Upstream members with no local user. Not an error — it is the visible answer to \"why doesn't this person have the permission yet?\" |\n| `aborted` | Non-`null` when the plan must not be applied |\n\nRemoval is guarded by a circuit breaker (`BlastRadiusPolicy`) with **two independent axes**, each\nwith its own required limits:\n\n| Axis | Counts | Denominator |\n| --- | --- | --- |\n| `membership` | people losing real membership, i.e. losing granted permissions | memberships held before the run |\n| `ledger` | ledger rows removed, i.e. people losing the ability to sign in at all | ledger rows before the run |\n\nThe axes are separate because the ledger deliberately holds **every** member observed upstream,\nincluding people who have never signed in and hold no membership. Mixing them into one denominator\ndilutes the ratio in proportion to how many such people a group has: for a group with 1,000 ledger\nrows and 40 memberships, a run that removes **all 40 memberships** measures as a 4% blast and sails\nthrough — the breaker falls silent in exactly the failure it exists to catch. Separate limits also\nmean that relaxing one axis for a routine event never quietly relaxes the other.\n\n`minimumPopulation` exists on each axis because below it the ratio is too twitchy — a five-person\ngroup trips on an ordinary transfer, and a warning nobody can act on is a warning nobody reads.\n`maxRemovalRatio` is measured against the **local pre-sync population**, never the upstream count:\nwhen upstream returns zero the upstream-based ratio diverges, exactly when the guard matters most.\n\nAn abort clears **every** write, and carries the withheld identities — `withheldLedgerDeletes` and\n`withheldProjectionRemoves`. Asking an operator to approve a removal while showing only a count\nleaves them no way to decide except to raise the ceiling.\n\n### `evaluateTenantMembership(input)`\n\nDecides whether a caller may act as the tenant it named, and returns the **reason** alongside the\nverdict — a denial caused by \"tenant not served\" and one caused by \"not in any group\" send an\ninvestigation in opposite directions.\n\n```text\n1. named tenant is not one this deployment serves → deny  (tenant-not-served)\n2. anonymous caller                               → allow (anonymous)\n3. the tenant's policy is \"no-proof-required\"     → allow (proof-not-required)\n4. caller is in one of the proof groups           → allow (group-member)\n                              otherwise           → deny  (not-in-any-membership-group)\n```\n\nRule 2 is load-bearing: `@anonymous` grants belong to the tenant, so rejecting anonymous callers\nhere makes every public resource unreadable.\n\nRule 3 is an **explicit policy**, never inferred from an empty list. `TenantMembershipPolicy` is a\ndiscriminated union, so \"we require no proof\", \"somebody forgot to declare the groups\" and \"the\nquery returned nothing because of a transient fault\" cannot collapse into the same outcome — making\nan isolation axis optional is what turns *unspecified* into *everything*.\n\nA `require-proof` policy must also name at least one **break-glass** group, and every break-glass\ngroup must itself be a proof group. If every proof path is upstream-owned, one accident in the\ndirectory locks out every caller including the people who could repair it. `assertTenantMembershipPolicy`\nrefuses such a policy; call it at wiring time so the deployment fails to start rather than at the\nfirst request. Whether a break-glass group is genuinely locally managed is a fact about storage, so\nthat half of the check belongs to the adapter.\n\nEvidence comes from two disjoint sources — local membership (for locally-managed groups) and the\nmember ledger (for externally-sourced ones). **Use the ledger, not the projected membership**: on\na first sign-in the user row exists before the next sync projects it, and reading the projection\nwould lock a legitimate user out of everything for one sync interval.\n\nBlank tenant ids raise `AuthzTenantError` rather than denying, and malformed group ids raise\n`TypeError` rather than being dropped — a silently dropped id produces a false \"not a member\" with\nnothing in the logs to explain it.\n\n## Tenant identifiers\n\nValidation and comparison are delegated to `@aiquants/authz-core`\n(`assertTenantId` / `isSameTenant`). This package deliberately does not reimplement them: the\ndefinition of \"blank\" is an explicit Unicode set rather than any language's `trim`, and a second\nimplementation would drift from the authorization core it has to agree with.\n\n## Address comparison\n\nEvery address is compared through `normalizeAuthEmail` from `@aiquants/auth-core` — the same\nfunction the login allowlist uses. Two normalizers would let an address be present in the ledger\nand absent from the allowlist with nothing raising an error.\n\n## Install\n\n```bash\npnpm add @aiquants/auth-directory-core @aiquants/auth-core @aiquants/authz-core zod\n```\n\nBoth peers are pure logic packages with no I/O, and must resolve to the same instance the host uses.\n`@aiquants/authz-core` itself depends on `zod`, so a host that installs these three by hand needs it\ntoo — `pnpm add zod` — or an ESM import of the authz core fails with `ERR_MODULE_NOT_FOUND`.\n","readmeFilename":""}