@ionite/redis-core 0.0.22-beta.20260728.1 → 0.0.22

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/README.md +0 -139
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ionite/redis-core",
3
- "version": "0.0.22-beta.20260728.1",
3
+ "version": "0.0.22",
4
4
  "description": "Redis adapter interface and store implementations for ionite. Use with @ionite/redis (node-redis), @ionite/ioredis, or a custom RedisAdapter.",
5
5
  "private": false,
6
6
  "author": "ionite",
@@ -24,7 +24,7 @@
24
24
  "./package.json": "./package.json"
25
25
  },
26
26
  "dependencies": {
27
- "@ionite/core": "0.0.22-beta.20260728.1"
27
+ "@ionite/core": "^0.0.22"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^26.1.0"
package/README.md DELETED
@@ -1,139 +0,0 @@
1
- # @ionite/redis-core
2
-
3
- Redis-backed store implementations for ionite: **UserStore**, **SessionStore**, **MagicLinkStore**, **WorkloadTokenStore**, **AccessCatalogStore** (group/role/permission/resource definitions), **AccessAssignmentStore** (granted roots), **AccessIndex** (derived closure for fast `has*` checks), and **TenantStore**. Drop-in replacements for the in-memory stores from `@ionite/server`, suitable for production when you need persistence and/or multi-instance sharing.
4
-
5
- This package provides the **stores**, the **RedisAdapter interface**, and the **driver-agnostic reactive helper** used by the package-specific `ionRedis(...)` wrappers. It does **not** include adapters for specific Redis clients.
6
-
7
- ## When to use this package
8
-
9
- - **Custom adapter:** You implement the `RedisAdapter` interface yourself (e.g. for Bun.redis, Upstash, or a custom wrapper). Install `@ionite/redis-core` and pass your adapter to the store constructors.
10
- - **Custom reactive adapter:** You want the same secret-driven reconnect behavior as `@ionite/redis` / `@ionite/ioredis`, but for your own Redis client. Use `createReactiveRedis(...)` from this package.
11
- - **Pre-built client:** Use one of the adapter packages instead so you get the adapter and stores from a single import. You do **not** need to install `@ionite/redis-core`:
12
- - **node-redis (v5 or v4)** → `@ionite/redis` (includes adapter and stores)
13
- - **ioredis** → `@ionite/ioredis`
14
-
15
- ## Installation (custom adapter only)
16
-
17
- ```bash
18
- bun add @ionite/redis-core
19
- ```
20
-
21
- ## Adapter interface
22
-
23
- Stores accept a **RedisAdapter**. Implement this interface for your Redis client:
24
-
25
- - **Required:** `get`, `set`, `setex`, `del`, `sadd`, `srem`, `smembers`, `mget`
26
- - **List ordering:** `list()` returns records in a **stable, deterministic order** (ids sorted lexicographically via `SMEMBERS` + `MGET`), so offset pagination is coherent across pages. No sorted-set support is required; UI-style sorting is the caller's concern.
27
- - **Optional (atomic consume):** `getdel(key)` — when present, the magic-link store consumes a link atomically (Redis `GETDEL`, **Redis 6.2+**) so concurrent consumes yield exactly one winner; otherwise it falls back to a best-effort get-then-del. The bundled `@ionite/redis` and `@ionite/ioredis` adapters implement it natively, and `ionRedis(...)` forwards it from the active client.
28
- - **Optional (intra-store atomicity):** `runScript(script, keys, args)` — runs a server-side Lua script (the Redis `EVAL` command; **not** the JavaScript `eval()` sink — nothing is dynamically executed in your process). When present, the stores run create-if-absent, unique-index claim-or-fail, multi-key writes, and session compare-and-set read-modify-write as one all-or-nothing unit (§5.9, §8.2). When absent, the stores fall back to best-effort sequenced writes that still perform all checks before any write (fail-fast + idempotent replay); only write isolation differs. A genuine create/uniqueness collision throws a typed `StoreConflictError` (from `@ionite/core`); an identical replay is treated as success. Script bodies are static SDK-authored text and all dynamic values are passed via `keys`/`args` (referenced as `KEYS[i]`/`ARGV[j]`), so there is no Lua-injection vector. `ionRedis(...)` forwards `runScript` from the active client, so the atomic paths apply over secret-backed reactive connections too.
29
- - **Session updates (§8.2):** `SessionStore.update(...)` reads the record, shallow-merges the patch in JS (exact `JSON.stringify` semantics — arrays and large values of untouched fields are left byte-for-byte, no `cjson` round-trip), then compare-and-sets, retrying on contention before a final best-effort write. Concurrent partial updates cannot clobber each other. The session key TTL is derived from `session.expiresAt`: `create` uses `SET ... EX`, and `update`'s compare-and-set carries `EX` (or `KEEPTTL` when the merged record has no `expiresAt`), so expired and refresh-rotation-grace records are physically reaped by Redis with no read-path bookkeeping. The optional adapter `ttl(key)` method exposes the remaining TTL for diagnostics/validation.
30
-
31
- ## Reactive helper
32
-
33
- `createReactiveRedis(...)` creates one stable `RedisAdapter` object that can swap its underlying connected client when a secret version changes. The package-specific `ionRedis(...)` wrappers use this helper internally.
34
-
35
- Use it when:
36
-
37
- - your Redis client is not node-redis or ioredis
38
- - the Redis connection details come from `ion.secrets`
39
- - you want one stable adapter that can be constructed before secrets arrive
40
-
41
- Your custom client factory returns `{ adapter, dispose? }`, and the helper handles source-bound async get-then-watch secret subscription, reconnect dedupe by secret version, source-handle swaps during a config change, operation-level waiting until the first client is ready, and `ready()` when you want an explicit startup barrier. Subscriptions default to fail-fast, so missing Redis secrets reject `ready()` and store operations instead of waiting silently forever; pass `subscription: { mode: 'wait', timeout?: number }` only when waiting for the secret to appear is intentional.
42
-
43
- ## Options
44
-
45
- All store constructors take **RedisStoreOptions**:
46
-
47
- - **`redis`** (required): A `RedisAdapter` instance.
48
- - **`keyPrefix`** (**required**): Key prefix for all keys. There is no implicit default. In multi-tenant deployments each tenant MUST use a distinct prefix (e.g. `es:t:<tenantId>:`) so the global index sets (`user:ids`, `tenant:ids`, …) auto-namespace and never collide across tenants. An empty or missing prefix throws.
49
- - **`logger`** (optional): `Logger` from `@ionite/core` (default `defaultLogger`).
50
-
51
- `RedisTenantStore` also accepts:
52
-
53
- - **`ionCache`** (optional): an app-owned `TenantIonCache` (`{ fetch(id), delete(id) }`, structurally compatible with `LRUCache<string, Ionite>`). `getIon(id)` delegates to `ionCache.fetch(id)`; calling `getIon(...)` without an `ionCache` throws. `upsert`/`delete` call `ionCache.delete(id)` so a changed/removed record invalidates (and, via the cache's `dispose`, closes) the stale runtime. The SDK never caches tenant runtimes itself — the tenant *record* is a plain, non-sensitive Redis lookup; only the runtime (which reads sensitive RemoteConfig from the vault) is cached, and that cache is yours.
54
- - **Tenant config persistence:** Redis tenant stores persist tenant metadata, including an optional narrow `TenantConfigLocator` (`{ source?, path, ttl? }`) stored at `tenant.configLocator`. In your cache's `fetchMethod`, call `tenantConfigSource(tenant.configLocator, es)` (passing the root `es`) to materialize the runtime `ConfigSource` when a locator is present, and wire `dispose: (es) => ion.close()` so evicted runtimes are released. See the "Tenant runtime cache" section of the root README for the canonical `lru-cache` pattern.
55
- - **Cross-node staleness:** each node's `ionCache` is independent, so a record change on one node is visible on another only when that node's cache entry expires (TTL) or it mutates the tenant itself. Size the cache `ttl` to your acceptable record-staleness window; config *content* changes still propagate live via the ConfigSource subscription.
56
- - **Directory note:** the SDK no longer ships tenant-directory methods (`findTenants`/`findUserTenants`) or `/mine`/`/discovery` endpoints. If your app needs a "which tenants can this user switch to" surface, build it in app code (e.g. a SQL join plus the kept `discoverSessionRecords`) with your own auth and rate limiting.
57
-
58
- ## Example (custom adapter)
59
-
60
- ```ts
61
- import type { RedisAdapter } from '@ionite/redis-core';
62
- import {
63
- RedisAccessIndex,
64
- RedisAccessAssignmentStore,
65
- RedisAccessCatalogStore,
66
- RedisUserStore,
67
- RedisSessionStore,
68
- } from '@ionite/redis-core';
69
-
70
- const myAdapter: RedisAdapter = {
71
- async get(key) { /* ... */ return null; },
72
- async set(key, value) { /* ... */ },
73
- async setex(key, seconds, value) { /* ... */ },
74
- async del(key) { /* ... */ },
75
- async sadd(key, ...members) { /* ... */ },
76
- async srem(key, ...members) { /* ... */ },
77
- async smembers(key) { /* ... */ return []; },
78
- async mget(keys) { /* ... */ return keys.map(() => null); },
79
- };
80
-
81
- const keyPrefix = 'es:t:acme:'; // required, distinct per tenant
82
- const userStore = new RedisUserStore({ redis: myAdapter, keyPrefix });
83
- const sessionStore = new RedisSessionStore({ redis: myAdapter, keyPrefix });
84
- const accessStore = new RedisAccessAssignmentStore({ redis: myAdapter, keyPrefix });
85
- const accessIndex = new RedisAccessIndex({ redis: myAdapter, keyPrefix });
86
- const catalogStore = new RedisAccessCatalogStore({ redis: myAdapter, keyPrefix });
87
- ```
88
-
89
- ## Key layout
90
-
91
- Keys are prefixed with the required `keyPrefix` (examples below use `es:`):
92
-
93
- - **User:** `es:user:{id}`, indexes, set `es:user:ids`.
94
- - **Session:** `es:session:{sid}` (with TTL derived from `session.expiresAt`). Opaque auth sessions are looked up by `sid` alone within a configured `SessionStore`; tenant identity and claims are stored in the session value, not in a tenant-specific session key namespace.
95
- - **Magic link:** `es:magic:{token}` (with TTL).
96
- - **Workload token:** `es:workload:token:{workload_id}` (with TTL).
97
- - **Group:** `es:group:{id}`, indexes, set `es:group:ids`.
98
- - **Authz Catalog:** `es:authz:{group|role|permission}:{id}` plus per-kind `ids` sets. `RedisAccessCatalogStore` stores SCIM/IAM-sourced groups, roles, and permissions for SCIM Groups aggregation and OAA full sync (code-authored definitions belong in the code-declared catalog — `defineCatalog(...)` -> `FrameworkConfig.authz.catalog` — and are never seeded here). `set(entity)` returns a ready-aware catalog entry synchronously and performs the Redis write in the background; `entry.ready()` resolves once the write lands (and re-surfaces a failed write) while `entry.isReady()` reports its status.
99
- - **Resources:** `es:authz:resource:{id}` plus an `ids` set. `RedisResourceStore` is a Redis-backed writable `ResourceProvider` (`FrameworkStores.resources`) for apps without their own resource tables: `set(resource)` / `delete(id)` persist and emit the `changed` signal, and the optional `deriveEntitlements`/`resolveEntitlement` pair derives requestable per-resource entitlement Groups on demand.
100
- - **Access roots:** `es:access:{id}` (id = `accessRootId`, scope-aware), subject/target indexes, set `es:access:ids`. `RedisAccessAssignmentStore` stores granted `AccessChange` roots (with provenance, status, and resource scope) for SCIM grant/revoke routing and OAA full sync.
101
- - **Access index (derived closure):** `es:idx:*` presence keys (`idx:has`, `idx:any`) for `MGET` checks, edge-roots sets (`idx:er`), triple sets (`idx:tr`), per-root/per-subject descriptor sets (`idx:root`, `idx:subj`), and a subject-disabled marker (`idx:disabled:{subject}`) that makes every `has*` deny with `metadata.reason: 'subject_disabled'` while it is set (independent of edge materialization, so `clear`/reconcile never wipes it). `RedisAccessIndex` stores root-attributed effective edges so `removeByRoot` is efficient and diamond grants survive partial revokes.
102
- - **Tenant:** `es:tenant:{id}` (record, id encoded per §10.2) plus the `es:tenant:ids` index set. There is no separate company index; `companyId` is a field on the tenant record, not a key namespace.
103
-
104
- ## Key encoding (external ids)
105
-
106
- Externally-sourced id segments (SCIM `id`/`externalId`, `userName`, subject/target/resource ids, client ids, magic-link tokens, session ids, the IAM-delta id-map parts) are **encoded** before they are joined into a key (§10.2), so an id can never split a key on a stray `:` or collide with a structural index segment (a user whose id is literally `ids` cannot clobber the `user:ids` set). SDK-authored literal segments (`idx:has`, `user:ids`, `authz`, …) stay raw. The encoding is total, injective, and length-bounded:
107
-
108
- - **Safe, short ids** (`^[A-Za-z0-9_.@|-]{1,64}$`) → `=<id>` verbatim (readable: `es:user:=alice`).
109
- - **Everything else** (any `:`/`#`/control char, or length > 64) → `#<sha256(id) truncated to 128 bits, base64url>` (fixed length, collision-resistant).
110
-
111
- `#` and `=` never appear in a structural segment and neither branch emits a `:`, so the data and structural key namespaces are provably disjoint. `encodeKeyId(id)` is exported for Ops debugging (compute the stored key form of an id). This is a **greenfield** scheme with no migration from any prior key layout.
112
-
113
- ## Redis access checks
114
-
115
- Fast authorization checks read the **`RedisAccessIndex`** (the derived closure), not the `RedisAccessAssignmentStore` roots. The index is materialized from roots + the catalog by the SDK SCIM orchestration (or `expandRoot` + `materialize`), so unlike a raw edge store these checks already account for group -> role -> permission expansion, nested groups/roles, and `applyToSubResources` sub-resource inheritance:
116
-
117
- - **`RedisAccessAssignmentStore`** — direct grants only (for example `alice → role:editor` and `alice → role:reviewer` are two roots).
118
- - **`RedisAccessIndex`** — effective access after expansion (both roles **and** every permission they confer; overlapping permissions such as `read` from both roles are stored with separate `rootId` attribution so revoking one role does not remove access still justified by the other).
119
-
120
- ```ts
121
- await accessIndex.has(userId, 'permission', permissionId);
122
- await accessIndex.hasGroup(userId, groupId);
123
- await accessIndex.hasRole(userId, roleId);
124
- await accessIndex.hasPermission(userId, permissionId, resourceId /* optional scope */);
125
- await accessIndex.hasAny(userId, [
126
- { targetType: 'role', target: 'admin' },
127
- { targetType: 'permission', target: 'invoice:read' },
128
- ]);
129
- await accessIndex.hasAll(userId, [
130
- { targetType: 'role', target: 'finance' },
131
- { targetType: 'permission', target: 'invoice:approve' },
132
- ]);
133
- ```
134
-
135
- Single checks read presence keys directly; resource-scoped checks also accept an application-wide grant. Bounded `hasAny(...)` and `hasAll(...)` issue per-target reads and log a warning when the requested target set is large. All checks return the shared `AuthzDecision` (`{ allow, matched? }`).
136
-
137
- ## List ordering
138
-
139
- `list()` returns records in a **stable, deterministic order**: ids are read via `SMEMBERS` and sorted lexicographically before `MGET`, so offset pages (`start`/`limit`) are disjoint and complete across calls for an unchanged dataset. There is no caller-specified sort and no sorted-set requirement; UI-style sorting is the application's concern.