@propustka/client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@propustka/client",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/propustka.git",
8
+ "directory": "packages/client"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "@propustka/core": "0.0.1"
30
+ },
31
+ "devDependencies": {
32
+ "@cloudflare/workers-types": "^4.20251001.0"
33
+ }
34
+ }
package/src/client.ts ADDED
@@ -0,0 +1,202 @@
1
+ import type { DomainEvent, IamRpc, ResolvedPrincipal } from '@propustka/core'
2
+ import { permits, scopedProjects } from '@propustka/core'
3
+ import { readCredentials } from './request'
4
+ import type {
5
+ AuthContext,
6
+ AuthFailure,
7
+ Capability,
8
+ CapabilityFailure,
9
+ IssueCapabilityRequest,
10
+ IssuedCapability,
11
+ IssueFailure,
12
+ PrincipalIdentity,
13
+ RevokedCapability,
14
+ RevokeFailure,
15
+ } from './types'
16
+
17
+ // ── AuthContext (real) ─────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * The real, principal-backed `AuthContext`. `can`/`scopedTo` are pure functions over the
21
+ * permissions array the IAM Worker already resolved (no per-check round-trip); only `audit`
22
+ * calls the binding.
23
+ */
24
+ class RealAuthContext implements AuthContext {
25
+ readonly ok = true
26
+ readonly principal: PrincipalIdentity
27
+
28
+ constructor(
29
+ private readonly binding: IamRpc,
30
+ private readonly appId: string,
31
+ private readonly resolved: ResolvedPrincipal,
32
+ ) {
33
+ this.principal = { id: resolved.id, type: resolved.type, label: resolved.label }
34
+ }
35
+
36
+ can(action: string, scope?: { project?: string }): boolean {
37
+ // `permits` already encodes the scope-less rule: with no project, ONLY global
38
+ // (projectId === null) entries satisfy.
39
+ return permits(this.resolved.permissions, action, scope?.project)
40
+ }
41
+
42
+ scopedTo(action: string, _dimension = 'project'): string[] | null {
43
+ // `dimension` is forward-looking only — v1 has a single scope dimension (project).
44
+ return scopedProjects(this.resolved.permissions, action)
45
+ }
46
+
47
+ audit(event: DomainEvent): Promise<void> {
48
+ return this.binding.audit({
49
+ app: this.appId,
50
+ requestId: this.resolved.requestId,
51
+ principalId: this.resolved.id,
52
+ principalLabel: this.resolved.label,
53
+ action: event.action,
54
+ resourceType: event.resourceType,
55
+ resourceId: event.resourceId,
56
+ diff: event.diff,
57
+ metadata: event.metadata,
58
+ })
59
+ }
60
+ }
61
+
62
+ // ── Capability (real) ──────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * A redeemed, anonymous capability token. `can` is EXACT-match (action, resource) — no
66
+ * wildcards. `audit` attaches the token id + label and a null principal.
67
+ */
68
+ class RealCapability implements Capability {
69
+ readonly ok = true
70
+
71
+ constructor(
72
+ private readonly binding: IamRpc,
73
+ private readonly appId: string,
74
+ private readonly requestId: string,
75
+ private readonly tokenId: string,
76
+ private readonly label: string | null,
77
+ private readonly capabilities: ReadonlyArray<{ action: string; resource: string }>,
78
+ ) {}
79
+
80
+ can(action: string, resource: string): boolean {
81
+ for (const c of this.capabilities) {
82
+ if (c.action === action && c.resource === resource) {
83
+ return true
84
+ }
85
+ }
86
+ return false
87
+ }
88
+
89
+ audit(event: DomainEvent): Promise<void> {
90
+ return this.binding.audit({
91
+ app: this.appId,
92
+ requestId: this.requestId,
93
+ principalId: null,
94
+ // Snapshot the token label; fall back to `capability:<id>` when unlabeled.
95
+ principalLabel: this.label ?? `capability:${this.tokenId}`,
96
+ capabilityTokenId: this.tokenId,
97
+ action: event.action,
98
+ resourceType: event.resourceType,
99
+ resourceId: event.resourceId,
100
+ diff: event.diff,
101
+ metadata: event.metadata,
102
+ })
103
+ }
104
+ }
105
+
106
+ // ── IamClient ──────────────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Thin, app-facing wrapper over the IAM Worker service binding. Bakes the caller `app` id
110
+ * into the constructor so app code can never forget or mistype it. Depends ONLY on the
111
+ * `IamRpc` contract from `@propustka/core` — never on the Worker — which is what keeps the
112
+ * SDK worker-independent (the binding is the deployed Worker, reached at runtime).
113
+ */
114
+ export class IamClient {
115
+ constructor(
116
+ private readonly binding: IamRpc,
117
+ private readonly appId: string,
118
+ ) {}
119
+
120
+ /**
121
+ * Resolve the caller from the forwarded Access credentials. Returns a rich `AuthContext`
122
+ * on success, or a typed `AuthFailure` carrying the 401/403 status (missing/invalid → 401;
123
+ * unknown_principal/disabled → 403).
124
+ */
125
+ async authenticate(req: Request): Promise<AuthContext | AuthFailure> {
126
+ const { token, cookie, origin, requestId } = readCredentials(req)
127
+ const result = await this.binding.authenticate({ app: this.appId, token, cookie, origin, requestId })
128
+ if (result.ok) {
129
+ return new RealAuthContext(this.binding, this.appId, result.principal)
130
+ }
131
+ return { ok: false, reason: result.reason, status: authFailureStatus(result.reason) }
132
+ }
133
+
134
+ /**
135
+ * Redeem a capability token (a share link). No identity. A bad/expired/revoked/exhausted
136
+ * token reads as a 404 (the link is "invalid or expired"), never a leaky 401/403.
137
+ */
138
+ async redeemCapability(req: Request, token: string): Promise<Capability | CapabilityFailure> {
139
+ const { requestId } = readCredentials(req)
140
+ const result = await this.binding.redeemCapability({ app: this.appId, token, requestId })
141
+ if (result.ok) {
142
+ return new RealCapability(this.binding, this.appId, requestId, result.tokenId, result.label, result.capabilities)
143
+ }
144
+ return { ok: false, reason: result.reason, status: 404 }
145
+ }
146
+
147
+ /**
148
+ * Mint a capability (share link) in-flow. Forwards the REQUESTER's credentials as the
149
+ * issuer — the IAM Worker resolves the issuer server-side and enforces the delegation rule
150
+ * (you can only delegate what you can do). The app supplies only the grants/label/expiry.
151
+ */
152
+ async issueCapability(req: Request, input: IssueCapabilityRequest): Promise<IssuedCapability | IssueFailure> {
153
+ const { token, cookie, origin, requestId } = readCredentials(req)
154
+ const result = await this.binding.issueCapability({
155
+ app: this.appId,
156
+ token,
157
+ cookie,
158
+ origin,
159
+ requestId,
160
+ grants: input.grants,
161
+ label: input.label,
162
+ expiresAt: input.expiresAt,
163
+ maxUses: input.maxUses,
164
+ })
165
+ if (result.ok) {
166
+ return { ok: true, token: result.token, id: result.id }
167
+ }
168
+ return { ok: false, reason: result.reason, status: issueFailureStatus(result.reason) }
169
+ }
170
+
171
+ /**
172
+ * Revoke a capability (share link) by its id. Forwards the CALLER's credentials as the
173
+ * authorizer — the IAM Worker resolves the caller server-side and enforces the rule (the
174
+ * original issuer, or anyone who could re-issue the grants, may revoke). Idempotent: a
175
+ * second revoke returns `{ ok: true, revoked: false }`. An unknown id → 404.
176
+ */
177
+ async revokeCapability(req: Request, tokenId: string): Promise<RevokedCapability | RevokeFailure> {
178
+ const { token, cookie, origin, requestId } = readCredentials(req)
179
+ const result = await this.binding.revokeCapability({ app: this.appId, token, cookie, origin, requestId, tokenId })
180
+ if (result.ok) {
181
+ return { ok: true, revoked: result.revoked }
182
+ }
183
+ return { ok: false, reason: result.reason, status: revokeFailureStatus(result.reason) }
184
+ }
185
+ }
186
+
187
+ /** missing/invalid token → 401 (not authenticated); unknown/disabled principal → 403. */
188
+ function authFailureStatus(reason: AuthFailure['reason']): 401 | 403 {
189
+ return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
190
+ }
191
+
192
+ /** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
193
+ function issueFailureStatus(reason: IssueFailure['reason']): 401 | 403 {
194
+ return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
195
+ }
196
+
197
+ /** missing/invalid → 401; not_found → 404; unknown_principal/disabled/not_allowed → 403. */
198
+ function revokeFailureStatus(reason: RevokeFailure['reason']): 401 | 403 | 404 {
199
+ if (reason === 'missing_token' || reason === 'invalid_token') return 401
200
+ if (reason === 'not_found') return 404
201
+ return 403
202
+ }
package/src/fake.ts ADDED
@@ -0,0 +1,264 @@
1
+ import type { DomainEvent, PermissionEntry, PrincipalType } from '@propustka/core'
2
+ import { matchAction, permits, scopedProjects } from '@propustka/core'
3
+ import type {
4
+ AuthContext,
5
+ AuthFailure,
6
+ Capability,
7
+ CapabilityFailure,
8
+ IssueCapabilityRequest,
9
+ IssuedCapability,
10
+ IssueFailure,
11
+ PrincipalIdentity,
12
+ RevokedCapability,
13
+ RevokeFailure,
14
+ } from './types'
15
+
16
+ /**
17
+ * A fixed dev persona: an identity plus a real permissions array. When `FakeIamConfig.personas`
18
+ * is set, the fake resolves one of these per request (by a cookie / header key) and backs
19
+ * `can()` / `scopedTo()` with the SAME `permits` / `scopedProjects` core logic the real client
20
+ * uses — so role/scope behaviour (admin vs app-wide vs project-scoped) is exercisable in dev and
21
+ * browser tests without Cloudflare Access or a running IAM Worker.
22
+ */
23
+ export interface FakePersona {
24
+ id: string
25
+ label: string
26
+ type?: PrincipalType
27
+ /** The resolved permission entries — real `permits`/`scopedProjects` semantics (not allow-all). */
28
+ permissions: PermissionEntry[]
29
+ }
30
+
31
+ /**
32
+ * Config for the fake. Two modes:
33
+ *
34
+ * - SIMPLE (default): a single fixed identity, `can()` allows everything except the `deny`
35
+ * action patterns (same `*`/`prefix.*` matching as roles) so 403 paths are still testable.
36
+ * `principal` overrides the fixed identity (id/label/type).
37
+ *
38
+ * - PERSONA: set `personas` (keyed by an opaque selector, e.g. an email) to make the fake
39
+ * impersonate a specific principal per request, with real permission semantics. The active
40
+ * persona key is read from the `personaCookie` (default `propustka_dev_principal`) or the
41
+ * `personaHeader` (default `X-Dev-Principal`), falling back to `defaultPersona`. A cookie is
42
+ * what browser E2E uses (it rides every navigation + fetch); the header suits CLI/fetch. An
43
+ * unknown/absent key with no default resolves to `unknown_principal` (403) — the same shape a
44
+ * real unrecognised principal gets.
45
+ */
46
+ export interface FakeIamConfig {
47
+ deny?: string[]
48
+ principal?: {
49
+ id?: string
50
+ label?: string
51
+ type?: PrincipalType
52
+ }
53
+ personas?: Record<string, FakePersona>
54
+ /** Cookie carrying the active persona key (browser E2E). Default `propustka_dev_principal`. */
55
+ personaCookie?: string
56
+ /** Header carrying the active persona key (fetch/CLI). Default `X-Dev-Principal`. */
57
+ personaHeader?: string
58
+ /** Persona key used when neither cookie nor header is present. */
59
+ defaultPersona?: string
60
+ /**
61
+ * Dynamic per-request persona resolver — the most flexible mode (takes precedence over
62
+ * `personas`/`deny`). The app derives the persona however it likes (e.g. a dev cookie →
63
+ * a directory lookup), so personas need not be enumerated up front. Returning `null`
64
+ * resolves to `unknown_principal` (403), like a real unrecognised principal.
65
+ */
66
+ resolve?: (req: Request) => FakePersona | null | Promise<FakePersona | null>
67
+ }
68
+
69
+ interface FakeIdentity {
70
+ id: string
71
+ label: string
72
+ type: PrincipalType
73
+ }
74
+
75
+ const DEFAULT_PERSONA_COOKIE = 'propustka_dev_principal'
76
+ const DEFAULT_PERSONA_HEADER = 'X-Dev-Principal'
77
+
78
+ function resolveIdentity(config: FakeIamConfig | undefined): FakeIdentity {
79
+ return {
80
+ id: config?.principal?.id ?? 'fake-principal',
81
+ label: config?.principal?.label ?? 'fake@example.com',
82
+ type: config?.principal?.type ?? 'user',
83
+ }
84
+ }
85
+
86
+ /** True when `action` matches any pattern in the deny list. */
87
+ function isDenied(deny: string[], action: string): boolean {
88
+ for (const pattern of deny) {
89
+ if (matchAction(pattern, action)) {
90
+ return true
91
+ }
92
+ }
93
+ return false
94
+ }
95
+
96
+ /** Read a single cookie value out of a request's Cookie header. Returns null when absent. */
97
+ function readCookie(req: Request, name: string): string | null {
98
+ const header = req.headers.get('Cookie')
99
+ if (!header) {
100
+ return null
101
+ }
102
+ for (const part of header.split(';')) {
103
+ const eq = part.indexOf('=')
104
+ if (eq === -1) {
105
+ continue
106
+ }
107
+ if (part.slice(0, eq).trim() === name) {
108
+ return decodeURIComponent(part.slice(eq + 1).trim())
109
+ }
110
+ }
111
+ return null
112
+ }
113
+
114
+ // ── Fake surfaces ───────────────────────────────────────────────────────────────
115
+
116
+ /** Allow-all-except-deny context (simple mode). */
117
+ class FakeAuthContext implements AuthContext {
118
+ readonly ok = true
119
+ readonly principal: PrincipalIdentity
120
+
121
+ constructor(
122
+ private readonly deny: string[],
123
+ identity: FakeIdentity,
124
+ ) {
125
+ this.principal = { id: identity.id, type: identity.type, label: identity.label }
126
+ }
127
+
128
+ can(action: string, _scope?: { project?: string }): boolean {
129
+ // Allow everything except denied actions — regardless of scope.
130
+ return !isDenied(this.deny, action)
131
+ }
132
+
133
+ scopedTo(_action: string, _dimension = 'project'): string[] | null {
134
+ // Unrestricted: the fake identity may see everything.
135
+ return null
136
+ }
137
+
138
+ audit(_event: DomainEvent): Promise<void> {
139
+ return Promise.resolve()
140
+ }
141
+ }
142
+
143
+ /** Persona-backed context (persona mode) — real `permits` / `scopedProjects` semantics. */
144
+ class PersonaAuthContext implements AuthContext {
145
+ readonly ok = true
146
+ readonly principal: PrincipalIdentity
147
+
148
+ constructor(private readonly persona: FakePersona) {
149
+ this.principal = { id: persona.id, type: persona.type ?? 'user', label: persona.label }
150
+ }
151
+
152
+ can(action: string, scope?: { project?: string }): boolean {
153
+ return permits(this.persona.permissions, action, scope?.project)
154
+ }
155
+
156
+ scopedTo(action: string, _dimension = 'project'): string[] | null {
157
+ return scopedProjects(this.persona.permissions, action)
158
+ }
159
+
160
+ audit(_event: DomainEvent): Promise<void> {
161
+ return Promise.resolve()
162
+ }
163
+ }
164
+
165
+ class FakeCapability implements Capability {
166
+ readonly ok = true
167
+
168
+ constructor(private readonly deny: string[]) {}
169
+
170
+ can(action: string, _resource: string): boolean {
171
+ return !isDenied(this.deny, action)
172
+ }
173
+
174
+ audit(_event: DomainEvent): Promise<void> {
175
+ return Promise.resolve()
176
+ }
177
+ }
178
+
179
+ // ── FakeIamClient ─────────────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Drop-in for `IamClient` with an identical public interface, selectable by the app via an env
183
+ * flag for `wrangler dev`. No Access, no IAM Worker. In SIMPLE mode `can()` allows everything
184
+ * except the `deny` list; in PERSONA mode it impersonates the request's selected persona with
185
+ * real permission semantics (see `FakeIamConfig`).
186
+ */
187
+ export class FakeIamClient {
188
+ private readonly deny: string[]
189
+ private readonly identity: FakeIdentity
190
+ private readonly personas?: Record<string, FakePersona>
191
+ private readonly personaCookie: string
192
+ private readonly personaHeader: string
193
+ private readonly defaultPersona?: string
194
+ private readonly resolve?: (req: Request) => FakePersona | null | Promise<FakePersona | null>
195
+ // In-memory capability registry so issue → redeem → revoke stay consistent in dev/tests
196
+ // (no IAM Worker locally). Maps the plaintext token to its id, tracks every issued id,
197
+ // and the subset that has been revoked — so a redeemed-then-revoked token reads 'revoked'.
198
+ private readonly issuedTokens = new Map<string, string>()
199
+ private readonly issuedIds = new Set<string>()
200
+ private readonly revokedIds = new Set<string>()
201
+
202
+ constructor(config?: FakeIamConfig) {
203
+ this.deny = config?.deny ?? []
204
+ this.identity = resolveIdentity(config)
205
+ this.personas = config?.personas
206
+ this.personaCookie = config?.personaCookie ?? DEFAULT_PERSONA_COOKIE
207
+ this.personaHeader = config?.personaHeader ?? DEFAULT_PERSONA_HEADER
208
+ this.defaultPersona = config?.defaultPersona
209
+ this.resolve = config?.resolve
210
+ }
211
+
212
+ async authenticate(req: Request): Promise<AuthContext | AuthFailure> {
213
+ // Dynamic resolver wins — the app decides the persona per request.
214
+ if (this.resolve) {
215
+ const persona = await this.resolve(req)
216
+ if (!persona) {
217
+ return { ok: false, reason: 'unknown_principal', status: 403 }
218
+ }
219
+ return new PersonaAuthContext(persona)
220
+ }
221
+ if (this.personas) {
222
+ const key = readCookie(req, this.personaCookie) ?? req.headers.get(this.personaHeader) ?? this.defaultPersona
223
+ const persona = key ? this.personas[key] : undefined
224
+ if (!persona) {
225
+ // Selected an unknown persona (or none, with no default) → behave like a real
226
+ // authenticated-but-unrecognised principal.
227
+ return { ok: false, reason: 'unknown_principal', status: 403 }
228
+ }
229
+ return new PersonaAuthContext(persona)
230
+ }
231
+ return new FakeAuthContext(this.deny, this.identity)
232
+ }
233
+
234
+ redeemCapability(_req: Request, token: string): Promise<Capability | CapabilityFailure> {
235
+ // A token issued by THIS fake and since revoked reads 'revoked' (404), like the real
236
+ // Worker. Tokens we never issued (e.g. a hand-written one) still redeem allow-all — the
237
+ // fake is a dev/test convenience, not a validator.
238
+ const id = this.issuedTokens.get(token)
239
+ if (id && this.revokedIds.has(id)) {
240
+ return Promise.resolve({ ok: false, reason: 'revoked', status: 404 })
241
+ }
242
+ return Promise.resolve(new FakeCapability(this.deny))
243
+ }
244
+
245
+ issueCapability(_req: Request, _input: IssueCapabilityRequest): Promise<IssuedCapability | IssueFailure> {
246
+ const suffix = crypto.randomUUID()
247
+ const token = `fake-token-${suffix}`
248
+ const id = `fake-${this.identity.id}-${suffix}`
249
+ this.issuedTokens.set(token, id)
250
+ this.issuedIds.add(id)
251
+ return Promise.resolve({ ok: true, token, id })
252
+ }
253
+
254
+ revokeCapability(_req: Request, tokenId: string): Promise<RevokedCapability | RevokeFailure> {
255
+ if (!this.issuedIds.has(tokenId)) {
256
+ return Promise.resolve({ ok: false, reason: 'not_found', status: 404 })
257
+ }
258
+ if (this.revokedIds.has(tokenId)) {
259
+ return Promise.resolve({ ok: true, revoked: false })
260
+ }
261
+ this.revokedIds.add(tokenId)
262
+ return Promise.resolve({ ok: true, revoked: true })
263
+ }
264
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // @propustka/client — the app-facing IAM SDK (the only package published to npm).
2
+ //
3
+ // Depends ONLY on @propustka/core: the binding is typed as the `IamRpc` contract (so the SDK
4
+ // never imports the Worker), and `can()`/`scopedTo()` reuse core's `permits`/`matchAction`.
5
+
6
+ export { IamClient } from './client'
7
+ export { FakeIamClient } from './fake'
8
+ export type { FakeIamConfig, FakePersona } from './fake'
9
+ export { applyScope } from './scope'
10
+ export type {
11
+ AuthContext,
12
+ AuthFailure,
13
+ Capability,
14
+ CapabilityFailure,
15
+ IssueCapabilityRequest,
16
+ IssuedCapability,
17
+ IssueFailure,
18
+ PrincipalIdentity,
19
+ RevokedCapability,
20
+ RevokeFailure,
21
+ } from './types'
22
+
23
+ // Re-export from core so apps need only depend on the SDK: DomainEvent (one event shape) and
24
+ // IamRpc (the binding contract — apps type their `env.IAM` as IamRpc without importing core).
25
+ export type { DomainEvent, IamRpc } from '@propustka/core'
package/src/request.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Reading the forwarded Access credentials off the incoming Request. Mirrors the
2
+ // extraction the IAM Worker's admin router does, so both sides agree on exactly which
3
+ // header/cookie carries what. None of this is a security boundary — it's plumbing that
4
+ // hands the verified-at-the-edge token to the IAM Worker, which validates it.
5
+
6
+ /** Cloudflare Access injects the app token as this header on every request behind Access. */
7
+ const ACCESS_TOKEN_HEADER = 'Cf-Access-Jwt-Assertion'
8
+ /** The browser carries the Access session as this cookie; needed for get-identity (users). */
9
+ const ACCESS_COOKIE_NAME = 'CF_Authorization'
10
+ /** Cloudflare's per-request ray id; we use it as the correlation request id. */
11
+ const RAY_HEADER = 'cf-ray'
12
+
13
+ export interface ForwardedCredentials {
14
+ /** Cf-Access-Jwt-Assertion value, or null if absent (no Access in front). */
15
+ token: string | null
16
+ /** CF_Authorization cookie value parsed from the Cookie header, or null. */
17
+ cookie: string | null
18
+ /** The app's own origin (scheme + host), for the IAM Worker's get-identity call. */
19
+ origin: string
20
+ /** cf-ray, or a fresh UUID when absent (e.g. local dev). */
21
+ requestId: string
22
+ }
23
+
24
+ /**
25
+ * Pull the forwarded Access credentials and a correlation id off the request. The token
26
+ * and cookie may be null (e.g. a Bypass path with no Access in front); the IAM Worker
27
+ * decides what that means.
28
+ */
29
+ export function readCredentials(req: Request): ForwardedCredentials {
30
+ return {
31
+ token: req.headers.get(ACCESS_TOKEN_HEADER),
32
+ cookie: parseCookie(req.headers.get('Cookie'), ACCESS_COOKIE_NAME),
33
+ origin: new URL(req.url).origin,
34
+ requestId: req.headers.get(RAY_HEADER) ?? crypto.randomUUID(),
35
+ }
36
+ }
37
+
38
+ /** Read a single cookie value out of a raw Cookie header. Returns null when absent. */
39
+ function parseCookie(header: string | null, name: string): string | null {
40
+ if (!header) {
41
+ return null
42
+ }
43
+ for (const part of header.split(';')) {
44
+ const eq = part.indexOf('=')
45
+ if (eq === -1) {
46
+ continue
47
+ }
48
+ if (part.slice(0, eq).trim() === name) {
49
+ return part.slice(eq + 1).trim()
50
+ }
51
+ }
52
+ return null
53
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolve the three-state scope from `scopedTo()` into a single value the app controls.
3
+ * This is the SINGLE place the empty-`IN ()` SQL trap is handled: `none` short-circuits
4
+ * without ever emitting `WHERE id IN ()`.
5
+ *
6
+ * - `null` → `all()` — unrestricted (admin / global grant): no filter
7
+ * - `[]` → `none()` — no access: empty result, DO NOT query
8
+ * - non-empty → `some(ids)` — filter to these ids (`WHERE id IN (...)`)
9
+ *
10
+ * The app supplies what all/some/none mean for its own query; filtering must happen at the
11
+ * data layer, never by loading everything and filtering in memory.
12
+ */
13
+ export function applyScope<T>(
14
+ scope: string[] | null,
15
+ branches: { all: () => T; some: (ids: string[]) => T; none: () => T },
16
+ ): T {
17
+ if (scope === null) {
18
+ return branches.all()
19
+ }
20
+ if (scope.length === 0) {
21
+ return branches.none()
22
+ }
23
+ return branches.some(scope)
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { DomainEvent, IssueCapabilityGrant, PrincipalType } from '@propustka/core'
2
+
3
+ /**
4
+ * The resolved caller's identity — who Access says you are, as the IAM Worker recorded
5
+ * the principal. Exposed on `AuthContext` so apps can stamp domain rows (created_by,
6
+ * activity actor, assignee) and render "signed in as …" without a second lookup. It is
7
+ * NOT a permission surface — authorization is still `can()` / `scopedTo()`.
8
+ * - `id` — the IAM principal id (UUIDv7); stable, safe to store on domain rows.
9
+ * - `type` — 'user' (Access identity login) or 'service' (Access service token).
10
+ * - `label` — human-readable: the user's email, or the service token's name.
11
+ */
12
+ export interface PrincipalIdentity {
13
+ readonly id: string
14
+ readonly type: PrincipalType
15
+ readonly label: string
16
+ }
17
+
18
+ // ── Result surfaces ──────────────────────────────────────────────────────────
19
+ //
20
+ // Every method returns a discriminated union whose members all carry an `ok` field,
21
+ // so app code branches once on `result.ok`. The ok:true members are the rich
22
+ // `AuthContext` / `Capability` / `IssuedCapability` surfaces; the ok:false members are
23
+ // the typed failures below, each carrying the HTTP status the app should return.
24
+ //
25
+ // The ok:true surfaces are modelled as *interfaces* (not concrete classes) so that both
26
+ // the real (`permits`-backed) and fake (allow-all-except-deny) implementations satisfy a
27
+ // single shared shape without any casts — see the design note in the package README of the
28
+ // spec. The documented surface (`ok` / `can` / `scopedTo` / `audit`) is the interface.
29
+
30
+ /**
31
+ * The authenticated, ok:true result. `can`/`scopedTo` are local & pure (no binding call);
32
+ * only `audit` reaches the IAM Worker.
33
+ */
34
+ export interface AuthContext {
35
+ readonly ok: true
36
+ /**
37
+ * The resolved caller identity (id / type / label). For stamping domain rows and
38
+ * display — NOT a permission surface (authorization stays `can` / `scopedTo`).
39
+ */
40
+ readonly principal: PrincipalIdentity
41
+ /**
42
+ * Point check: may this principal do `action` (optionally on `scope.project`)?
43
+ * Scope-less → satisfied by GLOBAL permissions only; a project-scoped grant never
44
+ * widens into a scope-less allow.
45
+ */
46
+ can(action: string, scope?: { project?: string }): boolean
47
+ /**
48
+ * Scoping: which project ids may this principal perform `action` on?
49
+ * - `null` → unrestricted (holds the action globally — e.g. an admin)
50
+ * - `[]` → no project access at all
51
+ * - non-empty → exactly those project ids
52
+ * Consume via `applyScope` so the empty-`IN ()` trap is handled once. `dimension`
53
+ * defaults to `'project'` and is forward-looking only (v1 has a single scope dimension).
54
+ */
55
+ scopedTo(action: string, dimension?: string): string[] | null
56
+ /** Emit a domain audit event; app/principal/requestId are auto-attached. Fire-and-forget. */
57
+ audit(event: DomainEvent): Promise<void>
58
+ }
59
+
60
+ /**
61
+ * An anonymous redeemed capability token. Same `can` ergonomics as `AuthContext`, but
62
+ * EXACT (action, resource) matching — no wildcards, no project scope.
63
+ */
64
+ export interface Capability {
65
+ readonly ok: true
66
+ /** Exact match against the token's (action, resource) list. No wildcards. */
67
+ can(action: string, resource: string): boolean
68
+ /** Emit a domain audit event; capabilityTokenId + token label attached, principalId null. */
69
+ audit(event: DomainEvent): Promise<void>
70
+ }
71
+
72
+ /** Successful `issueCapability` — plaintext token returned ONCE. */
73
+ export interface IssuedCapability {
74
+ readonly ok: true
75
+ /** Plaintext token — show once, never persist. */
76
+ token: string
77
+ /** The capability token id (safe to store/reference). */
78
+ id: string
79
+ }
80
+
81
+ /** Successful `revokeCapability`. `revoked` is false when the token was already revoked. */
82
+ export interface RevokedCapability {
83
+ readonly ok: true
84
+ /** True when this call flipped the token to revoked; false if it was already revoked (idempotent). */
85
+ revoked: boolean
86
+ }
87
+
88
+ // ── Failures ─────────────────────────────────────────────────────────────────
89
+
90
+ /** `authenticate` failure. missing/invalid → 401; unknown_principal/disabled → 403. */
91
+ export interface AuthFailure {
92
+ readonly ok: false
93
+ reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled'
94
+ status: 401 | 403
95
+ }
96
+
97
+ /** `redeemCapability` failure — a bad/expired share link reads as 404. */
98
+ export interface CapabilityFailure {
99
+ readonly ok: false
100
+ reason: 'unknown' | 'expired' | 'revoked' | 'exhausted'
101
+ status: 404
102
+ }
103
+
104
+ /** `issueCapability` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
105
+ export interface IssueFailure {
106
+ readonly ok: false
107
+ reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
108
+ status: 401 | 403
109
+ }
110
+
111
+ /** `revokeCapability` failure. missing/invalid → 401; unknown/disabled/not_allowed → 403; not_found → 404. */
112
+ export interface RevokeFailure {
113
+ readonly ok: false
114
+ reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' | 'not_found'
115
+ status: 401 | 403 | 404
116
+ }
117
+
118
+ // ── Inputs ───────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * App-supplied portion of `issueCapability` — the grants/label/expiry/maxUses. The SDK
122
+ * fills `app` and the issuer's forwarded credentials (token/cookie/origin/requestId) from
123
+ * the request, so app code can never self-assert the issuer.
124
+ */
125
+ export interface IssueCapabilityRequest {
126
+ grants: IssueCapabilityGrant[]
127
+ label?: string
128
+ expiresAt?: number
129
+ maxUses?: number
130
+ }