@propustka/core 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,28 @@
1
+ {
2
+ "name": "@propustka/core",
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/core"
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
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Generate a UUIDv7 (RFC 9562): time-sortable, 128 bits.
3
+ *
4
+ * Layout:
5
+ * - bytes 0..5 : 48-bit big-endian Unix-millis timestamp
6
+ * - byte 6 : version nibble `7` in the high 4 bits, random in the low 4
7
+ * - byte 8 : RFC 4122 variant bits `10` in the high 2 bits, random in the low 6
8
+ * - the remaining 74 bits are random (`crypto.getRandomValues`)
9
+ *
10
+ * Because the timestamp occupies the most-significant bytes, two ids generated at
11
+ * different milliseconds sort lexicographically in time order. No dependency.
12
+ */
13
+ export function uuidv7(): string {
14
+ const bytes = new Uint8Array(16)
15
+
16
+ // 48-bit big-endian Unix-millis timestamp in the first 6 bytes.
17
+ const millis = Date.now()
18
+ // `millis` fits in 48 bits (well under 2^53), so the high bits are zero.
19
+ bytes[0] = Math.floor(millis / 0x10000000000) & 0xff
20
+ bytes[1] = Math.floor(millis / 0x100000000) & 0xff
21
+ bytes[2] = Math.floor(millis / 0x1000000) & 0xff
22
+ bytes[3] = Math.floor(millis / 0x10000) & 0xff
23
+ bytes[4] = Math.floor(millis / 0x100) & 0xff
24
+ bytes[5] = millis & 0xff
25
+
26
+ // Fill the remaining 10 bytes (74 usable random bits + the nibble/variant slots) with randomness.
27
+ const random = new Uint8Array(10)
28
+ crypto.getRandomValues(random)
29
+ bytes.set(random, 6)
30
+
31
+ // Version: high nibble of byte 6 = 0b0111 (7).
32
+ bytes[6] = (bytes[6]! & 0x0f) | 0x70
33
+ // Variant: high two bits of byte 8 = 0b10.
34
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80
35
+
36
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'))
37
+
38
+ return `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-${hex[4]}${hex[5]}-${hex[6]}${hex[7]}-${hex[8]}${hex[9]}-${hex[10]}${hex[11]}${hex[12]}${hex[13]}${
39
+ hex[14]
40
+ }${hex[15]}`
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // @propustka/core — the one shared library: pure logic & types that must not drift
2
+ // between @propustka/worker (implements it) and @propustka/client (consumes it).
3
+ export * from './ids'
4
+ export * from './permissions'
5
+ export * from './rpc'
6
+ export * from './types'
@@ -0,0 +1,75 @@
1
+ import type { PermissionEntry } from './types'
2
+
3
+ /**
4
+ * Wildcard action matching. The pattern is one of:
5
+ * - '*' — matches every action
6
+ * - 'prefix.*' — matches the `prefix` namespace and anything nested under it
7
+ * (e.g. 'project.*' matches 'project.read' and 'project.settings.update',
8
+ * but NOT 'projects.read' — the boundary is the dot)
9
+ * - exact — matches only that exact action string
10
+ *
11
+ * Examples:
12
+ * matchAction('*', 'x.y') === true
13
+ * matchAction('project.*', 'project.read') === true
14
+ * matchAction('project.*', 'project.settings.update') === true
15
+ * matchAction('project.read', 'project.read') === true
16
+ * matchAction('project.read', 'project.write') === false
17
+ * matchAction('project.*', 'projects.read') === false
18
+ */
19
+ export function matchAction(pattern: string, action: string): boolean {
20
+ if (pattern === '*') {
21
+ return true
22
+ }
23
+ if (pattern.endsWith('.*')) {
24
+ // Drop the trailing '*' (keep the dot) so 'project.*' yields 'project.' —
25
+ // the action must start with that prefix, which enforces the dot boundary.
26
+ const prefix = pattern.slice(0, -1)
27
+ return action.startsWith(prefix)
28
+ }
29
+ return pattern === action
30
+ }
31
+
32
+ /**
33
+ * Does `entries` grant `action` at the given scope? Mirrors `can()` semantics exactly:
34
+ * - projectScope === undefined → scope-less: ONLY entries with projectId === null satisfy.
35
+ * - projectScope === <id> → entries with projectId === null OR projectId === <id> satisfy.
36
+ *
37
+ * Within the satisfying entries, the entry's `action` is matched against the requested
38
+ * `action` using `matchAction` (so a wildcard entry like 'project.*' grants 'project.read').
39
+ */
40
+ export function permits(entries: PermissionEntry[], action: string, projectScope?: string): boolean {
41
+ for (const entry of entries) {
42
+ const scopeOk = entry.projectId === null || entry.projectId === projectScope
43
+ if (scopeOk && matchAction(entry.action, action)) {
44
+ return true
45
+ }
46
+ }
47
+ return false
48
+ }
49
+
50
+ /**
51
+ * The set of project ids `entries` grant `action` on — the resolution behind `scopedTo()`,
52
+ * shared so the real SDK context and any fake/test context agree exactly:
53
+ * - `null` → unrestricted: a matching GLOBAL entry (projectId === null) exists, so the
54
+ * principal holds the action everywhere (e.g. an admin / app-wide grant);
55
+ * - non-empty → exactly the project ids of the matching project-scoped entries;
56
+ * - `[]` → no matching entry: no project access at all.
57
+ * A matching global entry short-circuits to `null` (it dominates any scoped entries).
58
+ */
59
+ export function scopedProjects(entries: PermissionEntry[], action: string): string[] | null {
60
+ const ids: string[] = []
61
+ const seen = new Set<string>()
62
+ for (const entry of entries) {
63
+ if (!matchAction(entry.action, action)) {
64
+ continue
65
+ }
66
+ if (entry.projectId === null) {
67
+ return null
68
+ }
69
+ if (!seen.has(entry.projectId)) {
70
+ seen.add(entry.projectId)
71
+ ids.push(entry.projectId)
72
+ }
73
+ }
74
+ return ids
75
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { PermissionEntry, PrincipalType } from './types'
2
+
3
+ export interface AuthenticateInput {
4
+ /** Self-asserted caller app id; superseded by the aud-derived app id on valid tokens. */
5
+ app: string
6
+ /** Cf-Access-Jwt-Assertion header value. */
7
+ token: string | null
8
+ /** CF_Authorization cookie value, for get-identity (users only). */
9
+ cookie: string | null
10
+ /** The app's own origin (for get-identity). */
11
+ origin: string | null
12
+ requestId: string
13
+ }
14
+
15
+ export interface ResolvedPrincipal {
16
+ id: string
17
+ type: PrincipalType
18
+ label: string
19
+ permissions: PermissionEntry[]
20
+ requestId: string
21
+ }
22
+
23
+ export type AuthenticateResult =
24
+ /** get-identity failed → explicit grants only this request (`groupsUnavailable`). */
25
+ | { ok: true; principal: ResolvedPrincipal; groupsUnavailable?: true }
26
+ | { ok: false; reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' }
27
+
28
+ export interface AuditInput {
29
+ app: string
30
+ requestId: string
31
+ /** NULL for capability-driven events. */
32
+ principalId: string | null
33
+ /** Snapshot — survives principal deletion (token label for capabilities). */
34
+ principalLabel: string
35
+ /** Set for capability-driven events. */
36
+ capabilityTokenId?: string
37
+ action: string
38
+ resourceType: string
39
+ resourceId?: string
40
+ diff?: unknown
41
+ metadata?: unknown
42
+ }
43
+
44
+ export interface RedeemCapabilityInput {
45
+ app: string
46
+ token: string
47
+ requestId: string
48
+ }
49
+
50
+ export interface CapabilityGrant {
51
+ action: string
52
+ resource: string
53
+ }
54
+
55
+ export type RedeemCapabilityResult =
56
+ | { ok: true; capabilities: CapabilityGrant[]; tokenId: string; label: string | null }
57
+ | { ok: false; reason: 'unknown' | 'expired' | 'revoked' | 'exhausted' }
58
+
59
+ export interface IssueCapabilityGrant {
60
+ action: string
61
+ resource: string
62
+ /** Scope for the delegation check ONLY (not stored). Omitted → issuer must hold the action globally. */
63
+ projectId?: string | null
64
+ }
65
+
66
+ export interface IssueCapabilityInput {
67
+ app: string
68
+ /** The ISSUER's Access JWT — issuer is resolved server-side, never self-asserted. */
69
+ token: string | null
70
+ /** Issuer's CF_Authorization cookie (group-derived permissions count toward delegation too). */
71
+ cookie: string | null
72
+ origin: string | null
73
+ requestId: string
74
+ grants: IssueCapabilityGrant[]
75
+ label?: string
76
+ expiresAt?: number
77
+ maxUses?: number
78
+ }
79
+
80
+ export type IssueCapabilityResult =
81
+ /** Plaintext token returned ONCE. */
82
+ | { ok: true; token: string; id: string }
83
+ | { ok: false; reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' }
84
+
85
+ export interface RevokeCapabilityInput {
86
+ app: string
87
+ /** The CALLER's Access JWT — the authorizer is resolved server-side, never self-asserted. */
88
+ token: string | null
89
+ /** Caller's CF_Authorization cookie (group-derived permissions count toward the revoke check). */
90
+ cookie: string | null
91
+ origin: string | null
92
+ requestId: string
93
+ /** The capability token id (the `id` returned by issueCapability), NOT the plaintext token. */
94
+ tokenId: string
95
+ }
96
+
97
+ export type RevokeCapabilityResult =
98
+ /** `revoked` is false when the token was already revoked (idempotent). */
99
+ | { ok: true; revoked: boolean }
100
+ | { ok: false; reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' | 'not_found' }
101
+
102
+ /**
103
+ * The RPC contract. The Worker's `WorkerEntrypoint` `implements IamRpc`; the SDK types its
104
+ * binding as `Service<IamRpc>` (so the SDK never imports the Worker). Methods return plain
105
+ * serializable objects (RPC-friendly).
106
+ */
107
+ export interface IamRpc {
108
+ authenticate(input: AuthenticateInput): Promise<AuthenticateResult>
109
+ audit(event: AuditInput): Promise<void>
110
+ redeemCapability(input: RedeemCapabilityInput): Promise<RedeemCapabilityResult>
111
+ issueCapability(input: IssueCapabilityInput): Promise<IssueCapabilityResult>
112
+ /**
113
+ * Revoke a previously-issued capability token by id. The caller is resolved from the
114
+ * forwarded Access credentials and authorized: the original issuer may always revoke;
115
+ * otherwise the caller must hold every granted action globally (an admin / app-wide
116
+ * operator). Idempotent — revoking an already-revoked token returns `{ ok: true,
117
+ * revoked: false }`. An unknown id returns `{ ok: false, reason: 'not_found' }`.
118
+ */
119
+ revokeCapability(input: RevokeCapabilityInput): Promise<RevokeCapabilityResult>
120
+ }
package/src/types.ts ADDED
@@ -0,0 +1,43 @@
1
+ export type PrincipalType = 'user' | 'service'
2
+
3
+ /**
4
+ * Where a resolved permission entry came from. Used for debuggability in the admin UI
5
+ * ("why does this user have this permission?"); `can()`/`scopedTo()` ignore it.
6
+ * - 'grant' — explicit `grants` row
7
+ * - 'bootstrap' — IAM_BOOTSTRAP_ADMINS env match (resolution-time only)
8
+ * - `group:${groupRef}` — IdP group → role mapping (the `<org>/<team>` ref)
9
+ */
10
+ export type PermissionSource = 'grant' | 'bootstrap' | `group:${string}`
11
+
12
+ export interface PermissionEntry {
13
+ action: string
14
+ /** null = global / all projects */
15
+ projectId: string | null
16
+ source: PermissionSource
17
+ }
18
+
19
+ /**
20
+ * Domain event apps emit. Only the app knows what changed.
21
+ *
22
+ * IMPORTANT: `diff`/`metadata` may carry sensitive values (settings can hold secrets).
23
+ * The app MUST redact secret material before passing them — audit storage is verbatim
24
+ * and long-lived; the IAM Worker stores what it receives as-is.
25
+ */
26
+ export interface DomainEvent {
27
+ action: string
28
+ resourceType: string
29
+ resourceId?: string
30
+ diff?: unknown
31
+ metadata?: unknown
32
+ }
33
+
34
+ export interface RoleDef {
35
+ name: string
36
+ description?: string
37
+ permissions: string[]
38
+ }
39
+
40
+ export interface RoleSource {
41
+ getRole(key: string): RoleDef | undefined
42
+ listRoles(): Record<string, RoleDef>
43
+ }