Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8048f4b
test(access): define short-lived grant domain contract
seonghobae Aug 15, 2026
792fb7c
test(access): register grant contract in canonical coverage
seonghobae Aug 15, 2026
10d66e2
test(access): lock grant module into coverage producer
seonghobae Aug 15, 2026
b406688
test(access): cover fail-closed grant edge paths
seonghobae Aug 15, 2026
585e91f
test(access): exercise grant edge coverage
seonghobae Aug 15, 2026
1cc1efb
feat(access): add opaque short-lived grant domain
seonghobae Aug 15, 2026
3fd83d3
docs(access): record short-lived grant trust boundary
seonghobae Aug 15, 2026
0419e6a
docs(changelog): record access-grant domain foundation
seonghobae Aug 15, 2026
f038822
test(access): require grant identifiers independent of secret hash
seonghobae Aug 15, 2026
578808c
test(access): cover independent grant-id entropy contract
seonghobae Aug 15, 2026
cf15fac
fix(access): decouple audit grant ids from secret hashes
seonghobae Aug 15, 2026
79e529c
docs(access): separate correlation ids from token hashes
seonghobae Aug 15, 2026
cde324c
test(coverage): lock access-grant edge cases into c8
seonghobae Aug 15, 2026
a8f511b
test(access): reproduce audit and membership race failures
seonghobae Aug 15, 2026
fd24133
fix(access): close audit and membership race windows
seonghobae Aug 15, 2026
248120b
docs(access): define race-safe membership and audit durability
seonghobae Aug 15, 2026
1c7a12f
test(access): reject unusable membership versions before consume
seonghobae Aug 15, 2026
0443782
fix(access): require usable membership version before consume
seonghobae Aug 15, 2026
59c1008
test(access): align harness with membership-version consume contract
seonghobae Aug 15, 2026
19770e0
merge(develop): reconcile access-grant domain with adaptive attribution
seonghobae Aug 19, 2026
a1b1fee
fix(stack): inherit protected Hono runtime baseline
seonghobae Aug 20, 2026
f696428
fix(stack): inherit current protected develop in access-grant domain
seonghobae Aug 20, 2026
b253e5e
fix(stack): preserve protected Playwright baseline in access grant
seonghobae Aug 20, 2026
16b8a52
fix(stack): preserve protected Playwright lockfile in access grant
seonghobae Aug 20, 2026
5f39f59
test(access): reject forged atomic consume receipts
seonghobae Aug 20, 2026
c8261da
fix(access): validate atomic consume return authority
seonghobae Aug 20, 2026
fd94797
docs(access): record atomic consume return trust boundary
seonghobae Aug 20, 2026
c5162f4
test(access): reject uncommitted consume returns
seonghobae Aug 22, 2026
60b1bfc
fix(access): verify atomic consume transition
seonghobae Aug 22, 2026
4e09443
test(access): reject inherited purposes and mutable consume aliases
seonghobae Aug 22, 2026
f3ae1f1
fix(access): snapshot consume authority and harden purpose lookup
seonghobae Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added workflow ownership regression coverage so central review
workflows stay inherited from `ContextualWisdomLab/.github`, not copied
into this repository.
- Added a framework-neutral short-lived access-grant domain for the bounded
`stream` and `attachment_view` purposes, with injectable authorization,
membership-revocation, random-source, clock, audit, and atomic repository
ports. Route, database, calendar-subscription, and client migration remain
follow-up work under issue #413.

### Security

Expand Down Expand Up @@ -50,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added cross-device regression coverage proving that `logout-all` rejects stale
tokens on bearer, calendar, SSE, and attachment-view transports while the
replacement token continues through the same authentication boundary.
- Bound short-lived access grants to one project, purpose, audience and, for
attachment views, one attachment; capped their TTL at five minutes, persisted
only SHA-256 token hashes through the repository port, rechecked membership on
redemption, and required the repository to perform one-time consumption as an
atomic transition.

### Changed

Expand Down
195 changes: 195 additions & 0 deletions docs/doctoring/short-lived-access-grant-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Short-lived access-grant domain: evidence and design record

## Status and bounded scope

This record describes active pull-request work for issue #413. It is **not
protected-`develop` shipped truth** until the corresponding pull request merges.
The bounded slice establishes only the framework-neutral short-lived grant
policy and repository contract used later by HTTP and persistence adapters.

This slice deliberately does **not** yet:

- replace the existing session JWT query-string transports;
- add the authenticated grant exchange route;
- create SQLite or PostgreSQL grant tables;
- integrate SSE, attachment-view, or calendar clients;
- implement long-lived calendar-subscription secrets, rotation, or UI; or
- claim issue #413 is complete.

Those operations need separately reviewable migrations, route adapters,
revocation hooks, browser acceptance tests, and recovery evidence.

## Threat and decision

A general session bearer token in a URI has more authority and a longer lifetime
than an SSE bootstrap or one attachment view requires. URI credentials may also
appear in browser history, reverse-proxy/access logs, observability systems,
copied URLs, screenshots, and incident artifacts. RFC 6750 therefore discourages
URI query transport because of its logging exposure, while RFC 9700 states that
OAuth clients must not pass access tokens in URI query parameters.

Where a browser mechanism still requires URL-carried authority, ScopeWeave will
move toward a narrowly scoped opaque credential rather than another
resource-general JWT. The current domain slice implements two short-lived
purposes:

- `stream` with audience `scopeweave:stream`; and
- `attachment_view` with audience `scopeweave:attachment-view` and one required
attachment identifier.

Both have a hard maximum lifetime of 300 seconds. Calendar subscription
credentials are excluded because a long-lived subscription needs an independent
secret lifecycle, rotation, usage metadata, and user-facing revocation policy.

## Ports and authority boundary

The domain depends on explicit ports instead of Hono, SQLite, Clearfolio, or a
browser implementation:

```mermaid
flowchart LR
Caller[Authenticated caller] --> Domain[Access-grant domain]
Domain --> Authz[ProjectAuthorizationPort]
Domain --> Membership[MembershipRevocationPort]
Domain --> Repository[AccessGrantRepository]
Domain --> Clock[AccessGrantClock]
Domain --> Random[AccessGrantRandomSource]
Domain --> Audit[AccessGrantAuditSink]
Repository --> Atomic[Atomic one-time consume plus membership version check]
Repository --> Outbox[Production transactional audit outbox]
```

Required repository methods are `insertGrant`, `findGrantByHash`, and
`consumeGrantAtomically`. `MembershipRevocationPort.assertActive()` returns an
opaque membership version captured during the active-state check; the domain
passes that version into `consumeGrantAtomically`, and a production repository
adapter must compare it with live membership state inside the same atomic
consume boundary. An adapter that cannot share that transaction boundary must
instead atomically revoke affected grants as part of membership removal. A
separate check followed by an unconditional consume is not compliant.

The eventual SQLite and PostgreSQL adapters must run the same repository
contract. The repository—not the HTTP framework—owns the atomic state transition
that makes concurrent one-time consumption yield at most one success and closes
the revoke-between-check-and-consume race. A successful repository mutation does
not make its returned object trusted: the domain rechecks grant, subject,
project, purpose, audience, and attachment identity against the pre-consume
record and caller binding before that object may become a principal or audit
identity. A mismatched atomic return fails closed even though the one-time grant
may already have been consumed.

## Security invariants

The implementation enforces these invariants before route integration:

1. Secrets contain 32 random bytes encoded with unpadded base64url. The random
source must return an actual 32-byte `Uint8Array`.
2. Only a SHA-256 token hash is passed to persistence; plaintext grant secrets
are never part of stored records or audit events.
3. Purpose and audience are fixed pairs rather than caller-extensible strings.
4. Stream grants cannot carry an attachment identifier; attachment-view grants
require exactly one bound attachment identifier.
5. Project authorization is checked before minting. An authorization failure is
intentionally represented by a generic not-authorized result suitable for a
tenant-nondisclosing route response.
6. Membership activity is checked before redemption and its captured membership
version is part of the atomic consume condition, so a revocation that wins the
race prevents consumption.
7. Redemption requires an exact secret shape plus purpose, audience, project,
attachment, membership-version, expiry, unused, and unrevoked conditions.
Missing, malformed, expired, used, revoked, stale-membership, wrong-resource,
or otherwise unusable grants collapse to the same unauthorized result.
8. Time values and expiry arithmetic must be non-negative safe integers. Exact
expiry is non-usable (`now >= expires_at`).
9. Successful redemption depends on the repository's atomic consume operation;
read-then-write consumption in a route adapter is not compliant.
10. Audit metadata may contain grant identifiers and bound resource metadata,
but never the plaintext secret or token hash.
11. Once `insertGrant` or `consumeGrantAtomically` durably commits, downstream
audit-delivery failure does not convert that completed operation into a
client-visible failure that could trigger unsafe retry. Production adapters
that require durable audit evidence must persist an audit outbox in the same
transaction and deliver it asynchronously.
12. The object returned by `consumeGrantAtomically` is untrusted adapter output.
Its grant, subject, project, purpose, audience, and attachment identities
must exactly match the pre-consume grant and requested binding before the
domain emits a principal or audit event. A forged or stale return object is
rejected with the same tenant-nondisclosing unauthorized result.

The generated `grant_id` is an operational correlation identifier, not a bearer
credential. It uses an independent 16 random bytes and is never derived from the
secret or its token hash, so audit correlation does not disclose token-hash
material. This provides 128 bits of independent entropy without coupling the
identifier format to UUID semantics.

## Persistence contract for follow-up adapters

No database object is added in this slice. Follow-up persistence work must use
3NF and descriptive two-or-more-word `snake_case` object names, including the
issue-defined `access_grants`, `grant_consumptions`, and `grant_revocations`
objects where those responsibilities remain distinct. The adapter must make
expiry/revocation/use predicates, live membership-version comparison, and the
first successful consumption one transactionally atomic transition. A stale
read followed by an unconditional update is not sufficient.

Production persistence must also preserve hash-only storage across restart and
use a transactionally durable audit-outbox record for grant state changes when
audit evidence is mandatory. The external `AccessGrantAuditSink` is a
post-commit delivery boundary; sink availability must not change a completed
grant result. Migration, rollback, and recovery evidence must keep schema
generation, grant state, membership versions, and outbox state consistent.

## TDD and acceptance evidence

The first contract commit intentionally imported the absent
`server/access_grant_domain.mjs`; Node returned `ERR_MODULE_NOT_FOUND`, providing
the RED evidence before implementation. The production module was added only
after the behavior and coverage registrations were committed.

Focused contract tests cover:

- dependency-port validation;
- 32-byte opaque-token generation and hash-only persistence;
- independently random non-secret grant identifiers;
- secret/hash exclusion from audit events;
- fixed purpose/audience/resource binding;
- maximum and exact TTL boundaries;
- inaccessible-project and revoked-membership behavior;
- revocation occurring after the membership check but before atomic consumption;
- forged atomic-consume return identities that attempt to substitute a different
subject or project after the durable one-time transition;
- audit-sink rejection after durable mint and consume transitions;
- malformed and unknown secrets;
- exact-expiry rejection;
- one-time replay rejection; and
- two concurrent redemption attempts producing exactly one success through the
repository's atomic consume contract.

The production source and both access-grant behavior test files are registered
explicitly in the repository `c8` producer, and the coverage-registration
contract prevents them from silently dropping out. An earlier focused Node V8
run produced 100% statement/line, branch, and function coverage before the latest
race/durability hardening; hosted exact-current-head coverage is authoritative
for the resulting implementation.

## Rollback and compatibility

This slice has no route, schema, migration, session-token, Clearfolio, or browser
behavior change. Rollback therefore removes the domain module, its contract and
edge tests, coverage registrations, this record, and the matching changelog
entry together. Existing protected behavior is unchanged until a later route
integration explicitly migrates a transport.

## References

Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework:
Bearer token usage* (RFC 6750). Internet Engineering Task Force.
https://doi.org/10.17487/RFC6750

Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current
practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task
Force. https://doi.org/10.17487/RFC9700

Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current
practices* (BCP 225; RFC 8725). Internet Engineering Task Force.
https://doi.org/10.17487/RFC8725
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js",
Expand All @@ -31,4 +31,4 @@
"c8": "12.0.0",
"fast-check": "4.9.0"
}
}
}
Loading
Loading