@cortexkit/common-auth 0.1.3 → 0.2.0

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 (47) hide show
  1. package/dist/quota/codec.d.ts +11 -0
  2. package/dist/quota/codec.js +12 -0
  3. package/dist/quota/index.d.ts +8 -0
  4. package/dist/quota/index.js +4 -0
  5. package/dist/quota/map.d.ts +63 -0
  6. package/dist/quota/map.js +105 -0
  7. package/dist/quota/merge.d.ts +48 -0
  8. package/dist/quota/merge.js +162 -0
  9. package/dist/quota/projection.d.ts +57 -0
  10. package/dist/quota/projection.js +127 -0
  11. package/dist/routing/admission.d.ts +93 -0
  12. package/dist/routing/admission.js +140 -0
  13. package/dist/routing/index.d.ts +8 -0
  14. package/dist/routing/index.js +4 -0
  15. package/dist/routing/ordered.d.ts +47 -0
  16. package/dist/routing/ordered.js +58 -0
  17. package/dist/routing/pins.d.ts +22 -0
  18. package/dist/routing/pins.js +34 -0
  19. package/dist/routing/sticky.d.ts +118 -0
  20. package/dist/routing/sticky.js +310 -0
  21. package/dist/store/attribution.d.ts +17 -0
  22. package/dist/store/attribution.js +46 -0
  23. package/dist/store/errors.d.ts +52 -0
  24. package/dist/store/errors.js +38 -0
  25. package/dist/store/hooks.d.ts +12 -0
  26. package/dist/store/hooks.js +34 -0
  27. package/dist/store/identity.d.ts +23 -0
  28. package/dist/store/identity.js +53 -0
  29. package/dist/store/index.d.ts +16 -0
  30. package/dist/store/index.js +5 -0
  31. package/dist/store/mutate.d.ts +111 -0
  32. package/dist/store/mutate.js +294 -0
  33. package/dist/store/pool.d.ts +86 -0
  34. package/dist/store/pool.js +98 -0
  35. package/dist/store/pull.d.ts +36 -0
  36. package/dist/store/pull.js +129 -0
  37. package/dist/store/refresh-lock.d.ts +63 -0
  38. package/dist/store/refresh-lock.js +125 -0
  39. package/dist/store/refresh.d.ts +48 -0
  40. package/dist/store/refresh.js +169 -0
  41. package/dist/store/rows.d.ts +53 -0
  42. package/dist/store/rows.js +250 -0
  43. package/dist/store/runtime.d.ts +28 -0
  44. package/dist/store/runtime.js +45 -0
  45. package/dist/store/schema.d.ts +133 -0
  46. package/dist/store/schema.js +323 -0
  47. package/package.json +13 -1
@@ -0,0 +1,323 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Top-level key of the config file that holds everything the pool adds. */
3
+ export const POOL_KEY = 'commonAuthPool';
4
+ /** The pool schema this library reads and writes. */
5
+ export const POOL_SCHEMA_VERSION = 1;
6
+ /** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
7
+ export const POOL_ROWS_KEY = 'rows';
8
+ /** The `version` older readers of the same files expect at the top level. */
9
+ export const LEGACY_STORE_VERSION = 1;
10
+ /**
11
+ * How far ahead of the clock a stored `lastRefreshedAt` may sit and still be
12
+ * trusted. Older readers of the state file apply the same bound, and a stamp
13
+ * strictly above it counts as absent when they compare two copies of a token.
14
+ */
15
+ export const REFRESH_STAMP_TOLERANCE_MS = 5 * 60_000;
16
+ export function isRecord(value) {
17
+ return value != null && typeof value === 'object' && !Array.isArray(value);
18
+ }
19
+ const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
20
+ /**
21
+ * Ids are stored exactly as given. Older readers trim ids, so an id with
22
+ * surrounding whitespace would be renamed by them; such ids, empty ids and
23
+ * prototype keys are refused instead of being rewritten.
24
+ */
25
+ export function idProblem(id) {
26
+ if (typeof id !== 'string' || id.length === 0)
27
+ return 'id must be non-empty';
28
+ if (id.trim() !== id)
29
+ return 'id must not carry surrounding whitespace';
30
+ if (UNSAFE_IDS.has(id))
31
+ return 'id is a reserved object key';
32
+ return undefined;
33
+ }
34
+ /** Same URL rule the older readers apply to an API-key row's `baseURL`. */
35
+ export function isValidBaseURL(value) {
36
+ const raw = typeof value === 'string' ? value.trim() : '';
37
+ if (!raw)
38
+ return false;
39
+ try {
40
+ const url = new URL(raw);
41
+ return ((url.protocol === 'http:' || url.protocol === 'https:') &&
42
+ !url.username &&
43
+ !url.password);
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ export function fingerprintOf(credential) {
50
+ const secret = credential.type === 'oauth'
51
+ ? `oauth\0${credential.refresh}`
52
+ : `api\0${credential.apiKey}`;
53
+ return createHash('sha256').update(secret).digest('hex');
54
+ }
55
+ export function classifyConfig(read) {
56
+ if (!read.exists)
57
+ return { status: 'ready', exists: false, config: {} };
58
+ if ('parseError' in read)
59
+ return { status: 'error', reason: 'config file is not valid JSON' };
60
+ const value = read.value;
61
+ if (!isRecord(value))
62
+ return { status: 'error', reason: 'config root is not an object' };
63
+ if ('accounts' in value && !Array.isArray(value.accounts))
64
+ return { status: 'error', reason: 'config accounts is not an array' };
65
+ if (!(POOL_KEY in value)) {
66
+ return Array.isArray(value.accounts)
67
+ ? { status: 'pending-migration', config: value }
68
+ : { status: 'ready', exists: true, config: value };
69
+ }
70
+ const pool = value[POOL_KEY];
71
+ if (!isRecord(pool))
72
+ return { status: 'error', reason: `${POOL_KEY} is not an object` };
73
+ const version = pool.schemaVersion;
74
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 1)
75
+ return { status: 'error', reason: `${POOL_KEY}.schemaVersion is invalid` };
76
+ if (version > POOL_SCHEMA_VERSION)
77
+ return {
78
+ status: 'error',
79
+ reason: `${POOL_KEY}.schemaVersion ${version} is newer than ${POOL_SCHEMA_VERSION}`,
80
+ };
81
+ if (POOL_ROWS_KEY in pool && !isRecord(pool[POOL_ROWS_KEY]))
82
+ return {
83
+ status: 'error',
84
+ reason: `${POOL_KEY}.${POOL_ROWS_KEY} is not an object`,
85
+ };
86
+ return { status: 'ready', exists: true, config: value };
87
+ }
88
+ export function classifyState(read) {
89
+ if (!read.exists)
90
+ return { status: 'ready', exists: false, state: {} };
91
+ if ('parseError' in read)
92
+ return { status: 'error', reason: 'state file is not valid JSON' };
93
+ if (!isRecord(read.value))
94
+ return { status: 'error', reason: 'state root is not an object' };
95
+ if ('accounts' in read.value && !isRecord(read.value.accounts))
96
+ return { status: 'error', reason: 'state accounts is not an object' };
97
+ return { status: 'ready', exists: true, state: read.value };
98
+ }
99
+ export function rosterOf(config) {
100
+ return Array.isArray(config.accounts) ? config.accounts : [];
101
+ }
102
+ export function entriesOf(config) {
103
+ const pool = config[POOL_KEY];
104
+ if (!isRecord(pool))
105
+ return {};
106
+ const rows = pool[POOL_ROWS_KEY];
107
+ return isRecord(rows) ? rows : {};
108
+ }
109
+ function stateAccountsOf(state) {
110
+ return isRecord(state.accounts) ? state.accounts : {};
111
+ }
112
+ /** The reason a roster row cannot be loaded, mirroring the older readers. */
113
+ function rosterRowProblem(raw) {
114
+ if (!isRecord(raw))
115
+ return 'not an object';
116
+ const problem = idProblem(raw.id);
117
+ if (problem)
118
+ return problem;
119
+ if (raw.type === 'api')
120
+ return isValidBaseURL(raw.baseURL) ? undefined : 'invalid baseURL';
121
+ if (raw.type === 'oauth')
122
+ return undefined;
123
+ return 'unknown type';
124
+ }
125
+ function parseEntry(raw, codec) {
126
+ if (!isRecord(raw))
127
+ return undefined;
128
+ const epoch = raw.credentialEpoch;
129
+ if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 1)
130
+ return undefined;
131
+ if ('needsFirstReading' in raw && typeof raw.needsFirstReading !== 'boolean')
132
+ return undefined;
133
+ if ('disabledReason' in raw && typeof raw.disabledReason !== 'string')
134
+ return undefined;
135
+ if ('quota' in raw && !codec.validate(raw.quota))
136
+ return undefined;
137
+ return {
138
+ credentialEpoch: epoch,
139
+ needsFirstReading: raw.needsFirstReading === true,
140
+ ...(typeof raw.disabledReason === 'string'
141
+ ? { disabledReason: raw.disabledReason }
142
+ : {}),
143
+ ...('quota' in raw ? { quota: raw.quota } : {}),
144
+ };
145
+ }
146
+ function credentialFor(raw, stateEntry) {
147
+ if (!isRecord(stateEntry))
148
+ return undefined;
149
+ if (raw.type === 'api') {
150
+ const apiKey = typeof stateEntry.apiKey === 'string' ? stateEntry.apiKey.trim() : '';
151
+ if (!apiKey)
152
+ return undefined;
153
+ return {
154
+ type: 'api',
155
+ apiKey,
156
+ baseURL: String(raw.baseURL).trim(),
157
+ authHeader: raw.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer',
158
+ };
159
+ }
160
+ if (raw.corrupt === true)
161
+ return undefined;
162
+ const refresh = stateEntry.refresh;
163
+ if (typeof refresh !== 'string' || !refresh.trim())
164
+ return undefined;
165
+ return {
166
+ type: 'oauth',
167
+ refresh,
168
+ ...(typeof stateEntry.access === 'string'
169
+ ? { access: stateEntry.access }
170
+ : {}),
171
+ ...(typeof stateEntry.expires === 'number'
172
+ ? { expires: stateEntry.expires }
173
+ : {}),
174
+ ...(typeof stateEntry.lastRefreshedAt === 'number'
175
+ ? { lastRefreshedAt: stateEntry.lastRefreshedAt }
176
+ : {}),
177
+ };
178
+ }
179
+ /**
180
+ * Builds the rows of a ready pool. A roster row the older readers would
181
+ * reject, a duplicate id, or a malformed per-row entry makes that one row
182
+ * invalid (never a candidate) and blocks nothing else.
183
+ */
184
+ export function buildRows(config, state, codec) {
185
+ const entries = entriesOf(config);
186
+ const stateAccounts = stateAccountsOf(state);
187
+ const seen = new Set();
188
+ const rows = [];
189
+ for (const raw of rosterOf(config)) {
190
+ const problem = rosterRowProblem(raw);
191
+ const id = isRecord(raw) && typeof raw.id === 'string' ? raw.id : undefined;
192
+ if (problem || !isRecord(raw) || id === undefined || seen.has(id)) {
193
+ if (id !== undefined && !seen.has(id))
194
+ seen.add(id);
195
+ if (id !== undefined)
196
+ rows.push({
197
+ id,
198
+ type: isRecord(raw) && raw.type === 'api' ? 'api' : 'oauth',
199
+ enabled: false,
200
+ needsFirstReading: true,
201
+ hasEntry: Object.hasOwn(entries, id),
202
+ candidate: false,
203
+ invalid: 'roster',
204
+ });
205
+ continue;
206
+ }
207
+ seen.add(id);
208
+ const type = raw.type === 'api' ? 'api' : 'oauth';
209
+ const hasEntry = Object.hasOwn(entries, id);
210
+ const entry = hasEntry ? parseEntry(entries[id], codec) : undefined;
211
+ const credential = credentialFor(raw, stateAccounts[id]);
212
+ const enabled = raw.enabled !== false;
213
+ const row = {
214
+ id,
215
+ type,
216
+ enabled,
217
+ needsFirstReading: entry ? entry.needsFirstReading : type === 'oauth',
218
+ hasEntry,
219
+ candidate: false,
220
+ ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
221
+ ...(typeof raw.addedAt === 'number' ? { addedAt: raw.addedAt } : {}),
222
+ ...(typeof raw.accountId === 'string' && raw.accountId
223
+ ? { identity: raw.accountId }
224
+ : {}),
225
+ ...(credential
226
+ ? { credential, fingerprint: fingerprintOf(credential) }
227
+ : {}),
228
+ ...(entry ? { credentialEpoch: entry.credentialEpoch } : {}),
229
+ ...(entry?.disabledReason !== undefined
230
+ ? { disabledReason: entry.disabledReason }
231
+ : {}),
232
+ ...(entry && 'quota' in entry ? { quota: entry.quota } : {}),
233
+ };
234
+ if (hasEntry && !entry) {
235
+ row.invalid = 'entry';
236
+ }
237
+ else {
238
+ row.candidate = enabled && credential !== undefined;
239
+ }
240
+ rows.push(row);
241
+ }
242
+ return rows;
243
+ }
244
+ /** The row-lock key: recorded wire identity when known, else the local id. */
245
+ export function rowLockKey(row) {
246
+ return row.identity ?? row.id;
247
+ }
248
+ /**
249
+ * The refresh stamp a write persists: the clock, raised to one past a prior
250
+ * stamp that is still trusted, so a stale copy of the previous token can never
251
+ * compare newer than the rotation. A prior stamp beyond the trust bound says
252
+ * nothing and is ignored.
253
+ */
254
+ export function rotationStamp(prior, now) {
255
+ if (prior === undefined || prior > now + REFRESH_STAMP_TOLERANCE_MS)
256
+ return now;
257
+ return Math.max(now, prior + 1);
258
+ }
259
+ /** True when a rotation stamped now would itself be past the trust bound. */
260
+ export function rotationStampUntrusted(prior, now) {
261
+ return rotationStamp(prior, now) > now + REFRESH_STAMP_TOLERANCE_MS;
262
+ }
263
+ /** The legacy-shaped roster row for a new row. */
264
+ export function rosterRowFor(input) {
265
+ const { id, credential, identity, label, addedAt } = input;
266
+ return {
267
+ id,
268
+ ...(label !== undefined ? { label } : {}),
269
+ type: credential.type,
270
+ addedAt,
271
+ ...(identity !== undefined ? { accountId: identity } : {}),
272
+ ...(credential.type === 'api'
273
+ ? {
274
+ baseURL: credential.baseURL.trim(),
275
+ authHeader: credential.authHeader ?? 'authorization-bearer',
276
+ }
277
+ : {}),
278
+ };
279
+ }
280
+ /** The state-file fields a credential occupies, in the older readers' names. */
281
+ export function stateFieldsFor(credential, lastRefreshedAt) {
282
+ if (credential.type === 'api')
283
+ return { apiKey: credential.apiKey };
284
+ return {
285
+ ...(credential.access !== undefined ? { access: credential.access } : {}),
286
+ refresh: credential.refresh,
287
+ ...(credential.expires !== undefined
288
+ ? { expires: credential.expires }
289
+ : {}),
290
+ ...(lastRefreshedAt !== undefined ? { lastRefreshedAt } : {}),
291
+ };
292
+ }
293
+ export function storedCredential(credential, lastRefreshedAt) {
294
+ if (credential.type === 'api')
295
+ return {
296
+ ...credential,
297
+ baseURL: credential.baseURL.trim(),
298
+ authHeader: credential.authHeader ?? 'authorization-bearer',
299
+ };
300
+ return {
301
+ ...credential,
302
+ ...(lastRefreshedAt !== undefined ? { lastRefreshedAt } : {}),
303
+ };
304
+ }
305
+ export function credentialProblem(credential) {
306
+ if (!isRecord(credential))
307
+ return 'credential must be an object';
308
+ if (credential.type === 'oauth') {
309
+ if (typeof credential.refresh !== 'string' || !credential.refresh.trim())
310
+ return 'oauth credential needs a refresh token';
311
+ return undefined;
312
+ }
313
+ if (credential.type === 'api') {
314
+ if (typeof credential.apiKey !== 'string' || !credential.apiKey.trim())
315
+ return 'api credential needs an api key';
316
+ if (credential.apiKey.trim() !== credential.apiKey)
317
+ return 'api key must not carry surrounding whitespace';
318
+ if (!isValidBaseURL(credential.baseURL))
319
+ return 'api credential needs a valid baseURL';
320
+ return undefined;
321
+ }
322
+ return 'credential type must be oauth or api';
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Shared plumbing for the CortexKit auth plugins: loopback RPC, file locks and atomic writes, logger, sidebar state file, TUI preferences and TUI build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,18 @@
39
39
  "./tui": {
40
40
  "types": "./dist/tui/index.d.ts",
41
41
  "import": "./dist/tui/index.js"
42
+ },
43
+ "./store": {
44
+ "types": "./dist/store/index.d.ts",
45
+ "import": "./dist/store/index.js"
46
+ },
47
+ "./quota": {
48
+ "types": "./dist/quota/index.d.ts",
49
+ "import": "./dist/quota/index.js"
50
+ },
51
+ "./routing": {
52
+ "types": "./dist/routing/index.d.ts",
53
+ "import": "./dist/routing/index.js"
42
54
  }
43
55
  },
44
56
  "files": [