@cortexkit/common-auth 0.3.0 → 0.4.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/dist/cachekeep/manager.d.ts +18 -6
- package/dist/cachekeep/manager.js +40 -10
- package/dist/claustrum/consumer.d.ts +13 -4
- package/dist/claustrum/consumer.js +11 -3
- package/dist/claustrum/custody.d.ts +47 -6
- package/dist/claustrum/custody.js +37 -7
- package/dist/claustrum/errors.d.ts +1 -1
- package/dist/claustrum/index.d.ts +3 -3
- package/dist/claustrum/index.js +2 -2
- package/dist/claustrum/interlock.d.ts +15 -17
- package/dist/claustrum/interlock.js +19 -26
- package/dist/claustrum/roster.d.ts +96 -6
- package/dist/claustrum/roster.js +237 -45
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +6 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +1 -1
- package/dist/commands/menu.d.ts +8 -0
- package/dist/commands/menu.js +34 -13
- package/dist/commands/model.d.ts +9 -0
- package/dist/commands/seam.d.ts +40 -4
- package/dist/commands/seam.js +132 -19
- package/dist/dump/index.d.ts +94 -0
- package/dist/dump/index.js +236 -9
- package/dist/logger/engine.d.ts +52 -17
- package/dist/logger/engine.js +178 -135
- package/dist/logger/index.d.ts +2 -2
- package/dist/logger/index.js +1 -1
- package/dist/opencode2/install.d.ts +8 -3
- package/dist/opencode2/install.js +18 -9
- package/dist/opencode2/types.d.ts +22 -3
- package/dist/quota/projection.d.ts +11 -4
- package/dist/quota/projection.js +11 -4
- package/dist/routing/admission.js +3 -1
- package/dist/routing/index.d.ts +2 -2
- package/dist/routing/index.js +1 -1
- package/dist/routing/sticky.d.ts +19 -6
- package/dist/routing/sticky.js +34 -23
- package/dist/rpc/notifications.d.ts +20 -0
- package/dist/rpc/notifications.js +21 -0
- package/dist/rpc/rpc-server.d.ts +9 -1
- package/dist/rpc/rpc-server.js +8 -1
- package/dist/sidebar-file/index.d.ts +1 -1
- package/dist/sidebar-file/sidebar-file.d.ts +50 -2
- package/dist/sidebar-file/sidebar-file.js +92 -21
- package/dist/store/attribution.js +14 -2
- package/dist/store/errors.d.ts +6 -3
- package/dist/store/identity.d.ts +13 -4
- package/dist/store/index.d.ts +1 -1
- package/dist/store/mutate.d.ts +27 -4
- package/dist/store/mutate.js +43 -26
- package/dist/store/pool.d.ts +16 -3
- package/dist/store/pool.js +7 -2
- package/dist/store/rows.d.ts +20 -6
- package/dist/store/rows.js +141 -46
- package/dist/store/schema.d.ts +74 -5
- package/dist/store/schema.js +112 -10
- package/dist/store/torn.d.ts +29 -0
- package/dist/store/torn.js +113 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type QuotaMap, type QuotaObservation } from '../quota/index.js';
|
|
2
2
|
import type { RoutingRow } from '../routing/index.js';
|
|
3
|
-
import type { ClaustrumScopedCustody, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
|
|
3
|
+
import type { ClaustrumScopedAttempt, ClaustrumScopedCustody, SkippedVaultRecord, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
|
|
4
4
|
import { type DeclinedAccount } from './interlock.js';
|
|
5
5
|
/**
|
|
6
6
|
* One vault account as the pool sees it. It carries no bearer material: the
|
|
@@ -27,10 +27,25 @@ export interface VaultRosterRow {
|
|
|
27
27
|
* row is the last good projection kept as it was rather than dropped.
|
|
28
28
|
*/
|
|
29
29
|
stale?: true;
|
|
30
|
+
/**
|
|
31
|
+
* The vault's latest list named no account for `credentialId`, so
|
|
32
|
+
* `accountIdentity` is the last account that credential was known to log
|
|
33
|
+
* into, kept until the vault names one again. It proves neither that the
|
|
34
|
+
* credential still logs into that account nor that it changed accounts.
|
|
35
|
+
*/
|
|
36
|
+
unclaimed?: true;
|
|
30
37
|
}
|
|
31
38
|
export interface VaultRosterFile {
|
|
32
39
|
version: 1;
|
|
33
40
|
view?: string;
|
|
41
|
+
/**
|
|
42
|
+
* False when the vault's reply carried records this consumer could not use.
|
|
43
|
+
* An incomplete reply never removes an account it may still hold: every
|
|
44
|
+
* previous member it does not account for is kept.
|
|
45
|
+
*/
|
|
46
|
+
complete: boolean;
|
|
47
|
+
/** The unusable records of an incomplete reply: a safe id and a fixed reason. */
|
|
48
|
+
rejected?: SkippedVaultRecord[];
|
|
34
49
|
rows: VaultRosterRow[];
|
|
35
50
|
declined: DeclinedAccount[];
|
|
36
51
|
}
|
|
@@ -52,6 +67,16 @@ export declare const DEFAULT_ROUTE_PREFIX = "vault:";
|
|
|
52
67
|
* discovery and commit. Route ids, quota and the time an account was added
|
|
53
68
|
* survive for the same account; a credential that now logs into a different
|
|
54
69
|
* known account gets a new route id and no inherited quota.
|
|
70
|
+
*
|
|
71
|
+
* A record listed without an identity keeps the last account it was known to
|
|
72
|
+
* log into (the row is marked `unclaimed`). Dropping that binding would let a
|
|
73
|
+
* later different account look like an identity learned for the first time
|
|
74
|
+
* and inherit the first account's route and quota.
|
|
75
|
+
*
|
|
76
|
+
* A reply with unusable records is incomplete: a previous member it does not
|
|
77
|
+
* account for (its record was rejected, or a rejected record has no usable id
|
|
78
|
+
* and could be any of them) is kept, as an alias of its live account or as a
|
|
79
|
+
* `stale` row. Only a complete reply removes an account.
|
|
55
80
|
*/
|
|
56
81
|
export declare function projectVaultRoster(previous: VaultRosterFile | undefined, inventory: VaultInventory, options?: ProjectionOptions): VaultRosterFile;
|
|
57
82
|
/**
|
|
@@ -76,16 +101,22 @@ export declare function mutateVaultRoster<T>(path: string, change: (current: Vau
|
|
|
76
101
|
export declare function declineVaultRoute(path: string, routeId: string): Promise<undefined>;
|
|
77
102
|
/** Lift the user's decline for a vault account and every alias of it. */
|
|
78
103
|
export declare function acceptVaultRoute(path: string, routeId: string): Promise<undefined>;
|
|
104
|
+
/**
|
|
105
|
+
* The receipt fields a quota or profile observation must carry to say which
|
|
106
|
+
* send (credential and account) it came from. Pass the
|
|
107
|
+
* `ClaustrumScopedAttempt` that send was authorized with.
|
|
108
|
+
*/
|
|
109
|
+
export type QuotaReceipt = Pick<ClaustrumScopedAttempt, 'credentialId' | 'accountIdentity' | 'accountIdentitySource' | 'expectedAccountIdentity'>;
|
|
79
110
|
/**
|
|
80
111
|
* Merge a quota observation into a vault row through `/quota`'s merge. The
|
|
81
|
-
* write is fenced on the
|
|
82
|
-
* row
|
|
83
|
-
*
|
|
112
|
+
* write is fenced on the receipt the reading was taken with: it lands only
|
|
113
|
+
* while the row still holds that receipt's credential (as representative or
|
|
114
|
+
* alias) and account, so a slow read for a replaced account can never land on
|
|
115
|
+
* its successor. Returns whether the observation was kept.
|
|
84
116
|
*/
|
|
85
|
-
export declare function recordVaultQuota(path: string, input: {
|
|
117
|
+
export declare function recordVaultQuota(path: string, input: QuotaReceipt & {
|
|
86
118
|
routeId: string;
|
|
87
119
|
observation: QuotaObservation;
|
|
88
|
-
accountIdentity?: string;
|
|
89
120
|
}): Promise<boolean>;
|
|
90
121
|
/**
|
|
91
122
|
* Discover and commit the roster. The lease covers the list call and the
|
|
@@ -101,3 +132,62 @@ export declare function refreshVaultRoster(options: {
|
|
|
101
132
|
signal?: AbortSignal;
|
|
102
133
|
projection?: ProjectionOptions | (() => ProjectionOptions);
|
|
103
134
|
}): Promise<VaultRosterFile | undefined>;
|
|
135
|
+
/**
|
|
136
|
+
* A host-owned main account as last verified from the vault: the plugin's
|
|
137
|
+
* logical route, the credential that serves it and the account it logs into.
|
|
138
|
+
*/
|
|
139
|
+
export interface VaultPrimaryBinding {
|
|
140
|
+
routeId: string;
|
|
141
|
+
credentialId: string;
|
|
142
|
+
accountIdentity: string;
|
|
143
|
+
view: string;
|
|
144
|
+
}
|
|
145
|
+
export type VaultPrimaryUnavailableReason = 'malformed' | 'unclaimed' | 'incomplete' | 'identity-changed';
|
|
146
|
+
export type VaultPrimary =
|
|
147
|
+
/**
|
|
148
|
+
* Main is bound. `replaced` is the previous binding when it named a
|
|
149
|
+
* different account: everything owned by that account (quota, profile,
|
|
150
|
+
* backoff, cache affinity) must be dropped, and outstanding observations
|
|
151
|
+
* for it fenced, even though the logical route stays the same.
|
|
152
|
+
*/
|
|
153
|
+
{
|
|
154
|
+
status: 'ready';
|
|
155
|
+
binding: VaultPrimaryBinding;
|
|
156
|
+
replaced?: VaultPrimaryBinding;
|
|
157
|
+
}
|
|
158
|
+
/** A complete reply lists no primary record: there is no main. */
|
|
159
|
+
| {
|
|
160
|
+
status: 'absent';
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The reply cannot say who main is. Not the same as absent: the last
|
|
164
|
+
* verified binding is handed back untouched, and serving it still needs a
|
|
165
|
+
* fresh receipt that matches it.
|
|
166
|
+
*/
|
|
167
|
+
| {
|
|
168
|
+
status: 'unavailable';
|
|
169
|
+
reason: VaultPrimaryUnavailableReason;
|
|
170
|
+
lastVerified?: VaultPrimaryBinding;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* The seam for a plugin that keeps a main account: one fixed account the host
|
|
174
|
+
* owns under a logical route, outside the pool's rotating rows. The library
|
|
175
|
+
* has no main convention of its own: the plugin names the
|
|
176
|
+
* vault record that holds the role (`primaryCredentialId`) and its logical
|
|
177
|
+
* route. Main follows the account that record claims; another credential of
|
|
178
|
+
* that same account may serve it when the record itself is not active, but a
|
|
179
|
+
* credential of any other account is never promoted into main.
|
|
180
|
+
*
|
|
181
|
+
* Inputs are the plugin's filtered inventory, whether that inventory is
|
|
182
|
+
* complete (defaults to the reply having no unusable records; pass the
|
|
183
|
+
* roster's `complete` or false to override), the previous verified binding
|
|
184
|
+
* and, on the request path, the account the chosen request expects.
|
|
185
|
+
*/
|
|
186
|
+
export declare function resolveVaultPrimary(input: {
|
|
187
|
+
inventory: VaultInventory;
|
|
188
|
+
complete?: boolean;
|
|
189
|
+
previous?: VaultPrimaryBinding;
|
|
190
|
+
expectedAccountIdentity?: string;
|
|
191
|
+
primaryCredentialId: string;
|
|
192
|
+
routeId: string;
|
|
193
|
+
}): VaultPrimary;
|
package/dist/claustrum/roster.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { acquireRefreshFileLock, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
4
4
|
import { isQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
5
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
|
-
import { acceptAccount, declineAccount, isDeclined,
|
|
6
|
+
import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
7
7
|
export const DEFAULT_ROUTE_PREFIX = 'vault:';
|
|
8
8
|
function hash(value) {
|
|
9
9
|
return createHash('sha256').update(value).digest('hex');
|
|
@@ -15,51 +15,88 @@ function defaultLabel(credential) {
|
|
|
15
15
|
const label = parts.length >= 3 ? parts.slice(2).join(':').trim() : undefined;
|
|
16
16
|
return label || credential.email || credential.credentialId;
|
|
17
17
|
}
|
|
18
|
-
function
|
|
19
|
-
return
|
|
18
|
+
function groupKey(member) {
|
|
19
|
+
return member.identity !== undefined
|
|
20
|
+
? `identity:${member.identity}`
|
|
21
|
+
: `credential:${member.credential.credentialId}`;
|
|
20
22
|
}
|
|
21
|
-
function
|
|
22
|
-
return
|
|
23
|
-
? `identity:${credential.accountIdentity}`
|
|
24
|
-
: `credential:${credential.credentialId}`;
|
|
23
|
+
function memberIds(row) {
|
|
24
|
+
return [row.credentialId, ...(row.aliases ?? [])];
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Project the vault's list onto the previous roster. Pure: callers serialize
|
|
28
28
|
* discovery and commit. Route ids, quota and the time an account was added
|
|
29
29
|
* survive for the same account; a credential that now logs into a different
|
|
30
30
|
* known account gets a new route id and no inherited quota.
|
|
31
|
+
*
|
|
32
|
+
* A record listed without an identity keeps the last account it was known to
|
|
33
|
+
* log into (the row is marked `unclaimed`). Dropping that binding would let a
|
|
34
|
+
* later different account look like an identity learned for the first time
|
|
35
|
+
* and inherit the first account's route and quota.
|
|
36
|
+
*
|
|
37
|
+
* A reply with unusable records is incomplete: a previous member it does not
|
|
38
|
+
* account for (its record was rejected, or a rejected record has no usable id
|
|
39
|
+
* and could be any of them) is kept, as an alias of its live account or as a
|
|
40
|
+
* `stale` row. Only a complete reply removes an account.
|
|
31
41
|
*/
|
|
32
42
|
export function projectVaultRoster(previous, inventory, options = {}) {
|
|
33
43
|
const now = options.now ?? Date.now();
|
|
34
44
|
const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
|
|
35
45
|
const reserved = options.reservedRouteIds ?? new Set();
|
|
36
46
|
const previousRows = previous?.rows ?? [];
|
|
37
|
-
|
|
47
|
+
// Declines follow the account and outlive its records, so none is pruned.
|
|
48
|
+
const declined = [...(previous?.declined ?? [])];
|
|
49
|
+
const complete = inventory.skipped.length === 0;
|
|
50
|
+
const listed = new Set(inventory.credentials.map((credential) => credential.credentialId));
|
|
51
|
+
const rejectedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
|
|
52
|
+
const unrecoverable = inventory.skipped.some((record) => record.credentialId === undefined);
|
|
53
|
+
const unaccounted = (id) => !listed.has(id) && (rejectedIds.has(id) || unrecoverable);
|
|
54
|
+
const lastKnown = new Map();
|
|
55
|
+
for (const row of previousRows) {
|
|
56
|
+
if (row.accountIdentity === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
for (const id of memberIds(row))
|
|
59
|
+
if (!lastKnown.has(id))
|
|
60
|
+
lastKnown.set(id, row.accountIdentity);
|
|
61
|
+
}
|
|
38
62
|
const groups = new Map();
|
|
39
63
|
for (const credential of inventory.credentials) {
|
|
40
|
-
const
|
|
64
|
+
const member = credential.accountIdentity !== undefined
|
|
65
|
+
? { credential, identity: credential.accountIdentity, claimed: true }
|
|
66
|
+
: {
|
|
67
|
+
credential,
|
|
68
|
+
identity: lastKnown.get(credential.credentialId),
|
|
69
|
+
claimed: false,
|
|
70
|
+
};
|
|
71
|
+
const key = groupKey(member);
|
|
41
72
|
const group = groups.get(key) ?? [];
|
|
42
|
-
group.push(
|
|
73
|
+
group.push(member);
|
|
43
74
|
groups.set(key, group);
|
|
44
75
|
}
|
|
45
76
|
const used = new Set();
|
|
46
77
|
const taken = new Set(reserved);
|
|
47
78
|
const projected = [];
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
79
|
+
const byId = (left, right) => left.credential.credentialId.localeCompare(right.credential.credentialId);
|
|
80
|
+
const sortedGroups = [...groups.values()]
|
|
81
|
+
.map((group) => group.toSorted(byId))
|
|
82
|
+
.sort((left, right) => (left[0] && right[0] ? byId(left[0], right[0]) : 0));
|
|
83
|
+
for (const ordered of sortedGroups) {
|
|
84
|
+
const identity = ordered[0]?.identity;
|
|
85
|
+
const active = ordered.filter((entry) => entry.credential.state === 'active');
|
|
86
|
+
const usable = active.length ? active : ordered;
|
|
87
|
+
// A record that claims the account itself is preferred over one that is
|
|
88
|
+
// only remembered as belonging to it.
|
|
89
|
+
const claimed = usable.filter((entry) => entry.claimed);
|
|
90
|
+
const choices = claimed.length ? claimed : usable;
|
|
91
|
+
const members = new Set(ordered.map((entry) => entry.credential.credentialId));
|
|
92
|
+
// An unknown previous account may be learned now; a known one must match.
|
|
54
93
|
const bound = previousRows.find((row) => !used.has(row) &&
|
|
55
|
-
members.has(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
choices[0];
|
|
60
|
-
if (!representative)
|
|
94
|
+
memberIds(row).some((id) => members.has(id)) &&
|
|
95
|
+
(row.accountIdentity === undefined || row.accountIdentity === identity));
|
|
96
|
+
const chosen = choices.find((entry) => entry.credential.credentialId === bound?.credentialId) ?? choices[0];
|
|
97
|
+
if (!chosen)
|
|
61
98
|
continue;
|
|
62
|
-
const
|
|
99
|
+
const representative = chosen.credential;
|
|
63
100
|
const existing = bound ??
|
|
64
101
|
(identity === undefined
|
|
65
102
|
? undefined
|
|
@@ -74,9 +111,15 @@ export function projectVaultRoster(previous, inventory, options = {}) {
|
|
|
74
111
|
routeId = `${base}~${suffix}`;
|
|
75
112
|
}
|
|
76
113
|
taken.add(routeId);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
const retained = existing ? memberIds(existing).filter(unaccounted) : [];
|
|
115
|
+
const aliases = [
|
|
116
|
+
...new Set([
|
|
117
|
+
...ordered.map((entry) => entry.credential.credentialId),
|
|
118
|
+
...retained,
|
|
119
|
+
]),
|
|
120
|
+
]
|
|
121
|
+
.filter((id) => id !== representative.credentialId)
|
|
122
|
+
.sort((left, right) => left.localeCompare(right));
|
|
80
123
|
const mapped = options.mapAccount?.(representative) ?? {};
|
|
81
124
|
projected.push({
|
|
82
125
|
previousIndex: existing ? previousRows.indexOf(existing) : Infinity,
|
|
@@ -94,22 +137,38 @@ export function projectVaultRoster(previous, inventory, options = {}) {
|
|
|
94
137
|
...(representative.orgName !== undefined && {
|
|
95
138
|
orgName: representative.orgName,
|
|
96
139
|
}),
|
|
97
|
-
enabled: !
|
|
140
|
+
enabled: ![representative.credentialId, ...aliases].some((id) => isDeclined(declined, id, identity)),
|
|
98
141
|
addedAt: existing?.addedAt ?? now,
|
|
99
142
|
...(existing?.quota !== undefined && { quota: existing.quota }),
|
|
143
|
+
...(!chosen.claimed && identity !== undefined && { unclaimed: true }),
|
|
100
144
|
},
|
|
101
145
|
});
|
|
102
146
|
}
|
|
103
|
-
//
|
|
104
|
-
// must not read as the account having been removed
|
|
105
|
-
|
|
147
|
+
// An account the reply does not account for keeps its last good projection:
|
|
148
|
+
// a rejected record must not read as the account having been removed. A
|
|
149
|
+
// credential this reply lists belongs to the live row it was projected into,
|
|
150
|
+
// so it is taken out of the stale row: otherwise one credential could be
|
|
151
|
+
// authorized, and its readings recorded, under two account bindings. A
|
|
152
|
+
// stale row left with no member is dropped.
|
|
106
153
|
previousRows.forEach((row, previousIndex) => {
|
|
107
|
-
if (used.has(row) || !
|
|
154
|
+
if (used.has(row) || !memberIds(row).some(unaccounted))
|
|
108
155
|
return;
|
|
109
156
|
if (taken.has(row.routeId))
|
|
110
157
|
return;
|
|
158
|
+
const [credentialId, ...aliases] = memberIds(row).filter((id) => !listed.has(id));
|
|
159
|
+
if (credentialId === undefined)
|
|
160
|
+
return;
|
|
111
161
|
taken.add(row.routeId);
|
|
112
|
-
|
|
162
|
+
const { aliases: _previousAliases, ...rest } = row;
|
|
163
|
+
projected.push({
|
|
164
|
+
previousIndex,
|
|
165
|
+
row: {
|
|
166
|
+
...rest,
|
|
167
|
+
credentialId,
|
|
168
|
+
...(aliases.length && { aliases }),
|
|
169
|
+
stale: true,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
113
172
|
});
|
|
114
173
|
projected.sort((left, right) => left.previousIndex === right.previousIndex
|
|
115
174
|
? left.row.credentialId.localeCompare(right.row.credentialId)
|
|
@@ -117,6 +176,15 @@ export function projectVaultRoster(previous, inventory, options = {}) {
|
|
|
117
176
|
return {
|
|
118
177
|
version: 1,
|
|
119
178
|
view: inventory.view,
|
|
179
|
+
complete,
|
|
180
|
+
...(!complete && {
|
|
181
|
+
rejected: inventory.skipped.map((record) => ({
|
|
182
|
+
...(record.credentialId !== undefined && {
|
|
183
|
+
credentialId: record.credentialId,
|
|
184
|
+
}),
|
|
185
|
+
reason: record.reason,
|
|
186
|
+
})),
|
|
187
|
+
}),
|
|
120
188
|
rows: projected.map((entry) => entry.row),
|
|
121
189
|
declined,
|
|
122
190
|
};
|
|
@@ -156,16 +224,40 @@ function decodeRow(value) {
|
|
|
156
224
|
(value.aliases !== undefined &&
|
|
157
225
|
(!Array.isArray(value.aliases) ||
|
|
158
226
|
!value.aliases.every((alias) => typeof alias === 'string'))) ||
|
|
159
|
-
(value.quota !== undefined && !isQuotaMap(value.quota))
|
|
227
|
+
(value.quota !== undefined && !isQuotaMap(value.quota)) ||
|
|
228
|
+
(value.stale !== undefined && value.stale !== true) ||
|
|
229
|
+
(value.unclaimed !== undefined && value.unclaimed !== true))
|
|
160
230
|
return undefined;
|
|
161
231
|
return value;
|
|
162
232
|
}
|
|
233
|
+
const SKIPPED_REASONS = new Set([
|
|
234
|
+
'empty credential id',
|
|
235
|
+
'duplicate credential id',
|
|
236
|
+
'blank account identity',
|
|
237
|
+
'empty state',
|
|
238
|
+
]);
|
|
239
|
+
function decodeRejected(value) {
|
|
240
|
+
if (!Array.isArray(value))
|
|
241
|
+
return undefined;
|
|
242
|
+
const records = [];
|
|
243
|
+
for (const entry of value) {
|
|
244
|
+
if (!isRecord(entry) ||
|
|
245
|
+
!optionalString(entry.credentialId) ||
|
|
246
|
+
typeof entry.reason !== 'string' ||
|
|
247
|
+
!SKIPPED_REASONS.has(entry.reason))
|
|
248
|
+
return undefined;
|
|
249
|
+
records.push(entry);
|
|
250
|
+
}
|
|
251
|
+
return records;
|
|
252
|
+
}
|
|
163
253
|
function decodeRoster(value) {
|
|
164
254
|
if (!isRecord(value) ||
|
|
165
255
|
value.version !== 1 ||
|
|
166
256
|
!optionalString(value.view) ||
|
|
167
257
|
!Array.isArray(value.rows) ||
|
|
168
|
-
!Array.isArray(value.declined)
|
|
258
|
+
!Array.isArray(value.declined) ||
|
|
259
|
+
(value.complete !== undefined && typeof value.complete !== 'boolean') ||
|
|
260
|
+
(value.rejected !== undefined && !decodeRejected(value.rejected)))
|
|
169
261
|
throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
|
|
170
262
|
const rows = [];
|
|
171
263
|
for (const entry of value.rows) {
|
|
@@ -182,9 +274,14 @@ function decodeRoster(value) {
|
|
|
182
274
|
throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
|
|
183
275
|
declined.push(entry);
|
|
184
276
|
}
|
|
277
|
+
const rejected = decodeRejected(value.rejected);
|
|
185
278
|
return {
|
|
186
279
|
version: 1,
|
|
187
280
|
...(typeof value.view === 'string' && { view: value.view }),
|
|
281
|
+
// A file written before completeness was recorded came from a reply
|
|
282
|
+
// that was used whole, so it reads as complete.
|
|
283
|
+
complete: value.complete !== false,
|
|
284
|
+
...(rejected && { rejected }),
|
|
188
285
|
rows,
|
|
189
286
|
declined,
|
|
190
287
|
};
|
|
@@ -255,34 +352,74 @@ export function acceptVaultRoute(path, routeId) {
|
|
|
255
352
|
return {
|
|
256
353
|
next: {
|
|
257
354
|
...current,
|
|
258
|
-
declined: acceptAccount(current.declined,
|
|
259
|
-
row.credentialId,
|
|
260
|
-
...(row.aliases ?? []),
|
|
261
|
-
]),
|
|
355
|
+
declined: acceptAccount(current.declined, memberIds(row), row.accountIdentity),
|
|
262
356
|
rows: current.rows.map((entry) => entry === row ? { ...entry, enabled: true } : entry),
|
|
263
357
|
},
|
|
264
358
|
result: undefined,
|
|
265
359
|
};
|
|
266
360
|
});
|
|
267
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* What a receipt may do to a row's quota: `hold` (the row holds the receipt's
|
|
364
|
+
* credential and account), `bind` (the row has never had a known account and
|
|
365
|
+
* the receipt proves one, so the row takes that account with the reading) or
|
|
366
|
+
* `refuse`.
|
|
367
|
+
*/
|
|
368
|
+
function receiptFence(row, receipt) {
|
|
369
|
+
if (!memberIds(row).includes(receipt.credentialId))
|
|
370
|
+
return 'refuse';
|
|
371
|
+
// While the vault makes no claim, the roster's own expectation proves
|
|
372
|
+
// nothing about which account the reading came from.
|
|
373
|
+
if (row.unclaimed &&
|
|
374
|
+
(receipt.accountIdentitySource === 'expected' ||
|
|
375
|
+
receipt.accountIdentitySource === 'none'))
|
|
376
|
+
return 'refuse';
|
|
377
|
+
// Equality, never compatibility: a known account on one side does not match
|
|
378
|
+
// an absent one on the other. The only absent-absent match is a row that
|
|
379
|
+
// has never had a known account and a receipt that names none either.
|
|
380
|
+
if (row.accountIdentity === receipt.accountIdentity)
|
|
381
|
+
return 'hold';
|
|
382
|
+
// A row that has never had a known account, reached by a receipt issued for
|
|
383
|
+
// it in that state whose served or parsed identity names an account. The
|
|
384
|
+
// reading belongs to that account, so keeping it without the account would
|
|
385
|
+
// let the next list that names a different account inherit it as if that
|
|
386
|
+
// account had been learned for the first time. The row takes the account
|
|
387
|
+
// with the reading instead: a later list naming another account is then a
|
|
388
|
+
// known replacement and inherits nothing. Only a single-credential row is
|
|
389
|
+
// bound, since one credential's account says nothing about the others.
|
|
390
|
+
if (row.accountIdentity === undefined &&
|
|
391
|
+
receipt.expectedAccountIdentity === undefined &&
|
|
392
|
+
receipt.accountIdentity !== undefined &&
|
|
393
|
+
(receipt.accountIdentitySource === 'asserted' ||
|
|
394
|
+
receipt.accountIdentitySource === 'parsed') &&
|
|
395
|
+
memberIds(row).length === 1)
|
|
396
|
+
return 'bind';
|
|
397
|
+
return 'refuse';
|
|
398
|
+
}
|
|
268
399
|
/**
|
|
269
400
|
* Merge a quota observation into a vault row through `/quota`'s merge. The
|
|
270
|
-
* write is fenced on the
|
|
271
|
-
* row
|
|
272
|
-
*
|
|
401
|
+
* write is fenced on the receipt the reading was taken with: it lands only
|
|
402
|
+
* while the row still holds that receipt's credential (as representative or
|
|
403
|
+
* alias) and account, so a slow read for a replaced account can never land on
|
|
404
|
+
* its successor. Returns whether the observation was kept.
|
|
273
405
|
*/
|
|
274
406
|
export function recordVaultQuota(path, input) {
|
|
275
407
|
return mutateVaultRoster(path, (current) => {
|
|
276
408
|
const row = current?.rows.find((entry) => entry.routeId === input.routeId);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
!compatible(row.accountIdentity, input.accountIdentity))
|
|
409
|
+
const fence = !current || !row ? 'refuse' : receiptFence(row, input);
|
|
410
|
+
if (!current || !row || fence === 'refuse')
|
|
280
411
|
return { result: false };
|
|
281
412
|
const quota = mergeQuotaObservation(row.quota, input.observation);
|
|
413
|
+
// A bound account is `unclaimed`: the vault's list has not named it, and
|
|
414
|
+
// the next projection keeps it as the account this credential last
|
|
415
|
+
// logged into until the vault names one.
|
|
416
|
+
const bound = fence === 'bind'
|
|
417
|
+
? { accountIdentity: input.accountIdentity, unclaimed: true }
|
|
418
|
+
: {};
|
|
282
419
|
return {
|
|
283
420
|
next: {
|
|
284
421
|
...current,
|
|
285
|
-
rows: current.rows.map((entry) => entry === row ? { ...entry, quota } : entry),
|
|
422
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
|
|
286
423
|
},
|
|
287
424
|
result: true,
|
|
288
425
|
};
|
|
@@ -332,3 +469,58 @@ export async function refreshVaultRoster(options) {
|
|
|
332
469
|
await lease.release();
|
|
333
470
|
}
|
|
334
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* The seam for a plugin that keeps a main account: one fixed account the host
|
|
474
|
+
* owns under a logical route, outside the pool's rotating rows. The library
|
|
475
|
+
* has no main convention of its own: the plugin names the
|
|
476
|
+
* vault record that holds the role (`primaryCredentialId`) and its logical
|
|
477
|
+
* route. Main follows the account that record claims; another credential of
|
|
478
|
+
* that same account may serve it when the record itself is not active, but a
|
|
479
|
+
* credential of any other account is never promoted into main.
|
|
480
|
+
*
|
|
481
|
+
* Inputs are the plugin's filtered inventory, whether that inventory is
|
|
482
|
+
* complete (defaults to the reply having no unusable records; pass the
|
|
483
|
+
* roster's `complete` or false to override), the previous verified binding
|
|
484
|
+
* and, on the request path, the account the chosen request expects.
|
|
485
|
+
*/
|
|
486
|
+
export function resolveVaultPrimary(input) {
|
|
487
|
+
const { inventory, previous } = input;
|
|
488
|
+
const unavailable = (reason) => ({
|
|
489
|
+
status: 'unavailable',
|
|
490
|
+
reason,
|
|
491
|
+
...(previous && { lastVerified: previous }),
|
|
492
|
+
});
|
|
493
|
+
const record = inventory.credentials.find((credential) => credential.credentialId === input.primaryCredentialId);
|
|
494
|
+
if (!record) {
|
|
495
|
+
if (inventory.skipped.some((skipped) => skipped.credentialId === input.primaryCredentialId))
|
|
496
|
+
return unavailable('malformed');
|
|
497
|
+
// A rejected record without a usable id could be the primary record.
|
|
498
|
+
const incomplete = input.complete === false ||
|
|
499
|
+
inventory.skipped.some((skipped) => skipped.credentialId === undefined);
|
|
500
|
+
return incomplete ? unavailable('incomplete') : { status: 'absent' };
|
|
501
|
+
}
|
|
502
|
+
const identity = record.accountIdentity;
|
|
503
|
+
if (identity === undefined)
|
|
504
|
+
return unavailable('unclaimed');
|
|
505
|
+
if (input.expectedAccountIdentity !== undefined &&
|
|
506
|
+
input.expectedAccountIdentity !== identity)
|
|
507
|
+
return unavailable('identity-changed');
|
|
508
|
+
const representative = record.state === 'active'
|
|
509
|
+
? record
|
|
510
|
+
: (inventory.credentials
|
|
511
|
+
.filter((credential) => credential.accountIdentity === identity &&
|
|
512
|
+
credential.state === 'active')
|
|
513
|
+
.toSorted((left, right) => left.credentialId.localeCompare(right.credentialId))[0] ?? record);
|
|
514
|
+
const binding = {
|
|
515
|
+
routeId: input.routeId,
|
|
516
|
+
credentialId: representative.credentialId,
|
|
517
|
+
accountIdentity: identity,
|
|
518
|
+
view: inventory.view,
|
|
519
|
+
};
|
|
520
|
+
return {
|
|
521
|
+
status: 'ready',
|
|
522
|
+
binding,
|
|
523
|
+
...(previous &&
|
|
524
|
+
previous.accountIdentity !== identity && { replaced: previous }),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AddInput, PoolLockSpec, PoolRow, PoolStore, RemoveOptions } from '../store/index.js';
|
|
2
2
|
import type { CommandInvocation, KnobValues, MenuChoice, MenuKnob } from './model.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type ResolvedSection } from './seam.js';
|
|
4
4
|
/**
|
|
5
5
|
* What a plugin's login hands back. `ready` adds the account now. `pending`
|
|
6
6
|
* means the user must finish somewhere else (a browser, a device code):
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// the same locks as any other writer of those files.
|
|
5
5
|
import { isQuotaMap, projectQuota, } from '../quota/index.js';
|
|
6
6
|
import { DEFAULT_FORMER_MAIN_ID, orderForPlacement, resolveRoutingMode, } from '../routing/index.js';
|
|
7
|
+
import { projectFailure, } from './seam.js';
|
|
7
8
|
/** `disabledReason` recorded when the user disables an account from the menu. */
|
|
8
9
|
export const MENU_DISABLED_REASON = 'disabled from the command menu';
|
|
9
10
|
function isRecord(value) {
|
|
@@ -99,8 +100,12 @@ function addedText(name, result) {
|
|
|
99
100
|
return `${name} was already in the pool; its credential was updated.`;
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* What the user is told about a failed late login: the projected message,
|
|
105
|
+
* never the exception's own text, which can quote the login's request.
|
|
106
|
+
*/
|
|
102
107
|
function failureMessage(error) {
|
|
103
|
-
return
|
|
108
|
+
return projectFailure(error).text;
|
|
104
109
|
}
|
|
105
110
|
async function snapshot(store) {
|
|
106
111
|
const load = await store.read();
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -6,5 +6,5 @@ export type { ActionDefinition, ActionInput, ActionOutcome, CommandApplyRequest,
|
|
|
6
6
|
export { SECTION_SLOTS } from './model.js';
|
|
7
7
|
export type { PiMenuOptions, PiMenuUi } from './pi.js';
|
|
8
8
|
export { runPiCommandMenu } from './pi.js';
|
|
9
|
-
export type { SeamLogger } from './seam.js';
|
|
10
|
-
export { DEFAULT_IRREVERSIBLE_CONFIRMATION } from './seam.js';
|
|
9
|
+
export type { ProjectedFailure, SeamLogger, TextRedactor } from './seam.js';
|
|
10
|
+
export { ACTION_FAILED, CommandError, createTextRedactor, DEFAULT_IRREVERSIBLE_CONFIRMATION, projectFailure, } from './seam.js';
|
package/dist/commands/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export { MENU_DISABLED_REASON } from './builtins.js';
|
|
|
2
2
|
export { createCommandMenu, parseApplyRequest } from './menu.js';
|
|
3
3
|
export { SECTION_SLOTS } from './model.js';
|
|
4
4
|
export { runPiCommandMenu } from './pi.js';
|
|
5
|
-
export { DEFAULT_IRREVERSIBLE_CONFIRMATION } from './seam.js';
|
|
5
|
+
export { ACTION_FAILED, CommandError, createTextRedactor, DEFAULT_IRREVERSIBLE_CONFIRMATION, projectFailure, } from './seam.js';
|
package/dist/commands/menu.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type RedactionOptions } from '../logger/index.js';
|
|
1
2
|
import type { PoolLockSpec, PoolStore } from '../store/index.js';
|
|
2
3
|
import { type AccountsSectionOptions, type LimitsSectionOptions, type QuotaSectionOptions, type RoutingSectionOptions } from './builtins.js';
|
|
3
4
|
import type { CommandApplyRequest, CommandApplyResult, CommandDialogPayload, CommandInvocation, PluginExtraSection, PluginSection } from './model.js';
|
|
@@ -22,6 +23,13 @@ export interface CommandMenuOptions {
|
|
|
22
23
|
extras?: readonly PluginExtraSection[];
|
|
23
24
|
/** Receives the seam's warnings and failed actions; defaults to the library logger. */
|
|
24
25
|
logger?: SeamLogger;
|
|
26
|
+
/**
|
|
27
|
+
* The provider's secret shapes, added to the redactor every string that
|
|
28
|
+
* leaves the menu goes through (payloads and notifications). Without a
|
|
29
|
+
* pattern for its key format, a plugin's API key quoted in an outcome's
|
|
30
|
+
* text is not recognised.
|
|
31
|
+
*/
|
|
32
|
+
redaction?: RedactionOptions;
|
|
25
33
|
now?: () => number;
|
|
26
34
|
}
|
|
27
35
|
export interface CommandMenu {
|