@oxyhq/core 17.0.3 → 18.0.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +4 -5
- package/dist/cjs/mixins/OxyServices.accounts.js +36 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +3 -2
- package/dist/cjs/session/refresh.js +9 -14
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +4 -5
- package/dist/esm/mixins/OxyServices.accounts.js +36 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +3 -2
- package/dist/esm/session/refresh.js +9 -14
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +77 -3
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +3 -2
- package/dist/types/models/interfaces.d.ts +17 -1
- package/dist/types/session/refresh.d.ts +13 -18
- package/package.json +2 -2
- package/src/boot/sessionColdBoot.ts +4 -5
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +118 -5
- package/src/mixins/OxyServices.deviceBoot.ts +3 -2
- package/src/models/interfaces.ts +17 -0
- package/src/session/refresh.ts +15 -20
package/dist/types/index.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHand
|
|
|
39
39
|
export { normalizeProfileLinks } from './utils/profileLinks';
|
|
40
40
|
export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
|
|
41
41
|
export type { PublicApplication, ConnectedApp, } from './mixins/OxyServices.connectedApps';
|
|
42
|
-
export type { AccountKind, OrganizationCategory, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, AccountCredentialType, AccountCredentialEnvironment, AccountCredentialStatus, AccountCredential, AccountCredentialWithSecret, RotateAccountCredentialResult, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, InviteAccountMemberInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, CreateAccountCredentialInput, AccountSuccessResult, SwitchAccountResult, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, } from './mixins/OxyServices.accounts';
|
|
42
|
+
export type { AccountKind, OrganizationCategory, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, AccountCredentialType, AccountCredentialEnvironment, AccountCredentialStatus, AccountCredential, AccountCredentialWithSecret, RotateAccountCredentialResult, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, ProvisionChannelInput, ProvisionChannelMemberInput, ProvisionChannelResult, InviteAccountMemberInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, CreateAccountCredentialInput, AccountSuccessResult, SwitchAccountResult, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, } from './mixins/OxyServices.accounts';
|
|
43
43
|
export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts';
|
|
44
44
|
export { buildUserDid } from './mixins/OxyServices.identity';
|
|
45
45
|
export type { IdentityRecordType, UnlinkableAuthMethodType, LinkAuthMethodResult, PublishRecordResult, VerifyRecordResult, VerifyDomainResult, RemoveDomainResult, RotateKeyProof, RotateKeyOptions, RotateKeyResult, } from './mixins/OxyServices.identity';
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* registers the switched session into the operator's device-set directly).
|
|
34
34
|
*/
|
|
35
35
|
import type { User } from '../models/interfaces';
|
|
36
|
-
import type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
|
|
36
|
+
import type { AccountKind, OrganizationCategory, ChildAccountKind } from '@oxyhq/contracts';
|
|
37
37
|
import type { SessionLoginResponse } from '../models/session';
|
|
38
38
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
39
39
|
/**
|
|
@@ -133,8 +133,21 @@ export interface ListAccountsOptions {
|
|
|
133
133
|
}
|
|
134
134
|
/** Input accepted by `createAccount`. */
|
|
135
135
|
export interface CreateAccountInput {
|
|
136
|
-
/**
|
|
137
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Classification of the new account. Every CHILD kind is creatable here with
|
|
138
|
+
* the caller's own bearer, `channel` included: a signed-in person has already
|
|
139
|
+
* proven who they are, and minting a child under their own tree is the same
|
|
140
|
+
* operation whichever kind it is.
|
|
141
|
+
*
|
|
142
|
+
* `channel` used to be excluded, on the reasoning that channels are
|
|
143
|
+
* service-provisioned only. What actually makes a channel safe does not depend
|
|
144
|
+
* on who creates it: `createChildAccount` writes no auth method, so it is born
|
|
145
|
+
* with no login, and `POST /accounts/:id/switch` refuses it via
|
|
146
|
+
* `isActAsEligibleKind`, so no session can ever have a channel as its subject
|
|
147
|
+
* and therefore no bearer exists that could add one. `personal` is excluded
|
|
148
|
+
* because it is a human login, minted at signup.
|
|
149
|
+
*/
|
|
150
|
+
kind: ChildAccountKind;
|
|
138
151
|
/**
|
|
139
152
|
* Parent account `_id` to nest the new account under. Omitted → the API roots
|
|
140
153
|
* it under the caller's personal account.
|
|
@@ -142,9 +155,16 @@ export interface CreateAccountInput {
|
|
|
142
155
|
parentAccountId?: string;
|
|
143
156
|
/** Unique handle for the account (shares the `User.username` unique index). */
|
|
144
157
|
username: string;
|
|
158
|
+
/**
|
|
159
|
+
* A managed account (organization / project / bot / channel) has a TITLE, not
|
|
160
|
+
* a given-and-family name, so it sets `displayName` — the explicit
|
|
161
|
+
* `name_display` column — and leaves `first`/`last` unset. Splitting a title
|
|
162
|
+
* on whitespace into `first`/`last` is what this replaced.
|
|
163
|
+
*/
|
|
145
164
|
name?: {
|
|
146
165
|
first?: string;
|
|
147
166
|
last?: string;
|
|
167
|
+
displayName?: string;
|
|
148
168
|
};
|
|
149
169
|
bio?: string;
|
|
150
170
|
avatar?: string;
|
|
@@ -154,15 +174,47 @@ export interface CreateAccountInput {
|
|
|
154
174
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
155
175
|
export interface UpdateAccountInput {
|
|
156
176
|
username?: string;
|
|
177
|
+
/**
|
|
178
|
+
* Same shape as `CreateAccountInput['name']`. On update, an EMPTY STRING in
|
|
179
|
+
* `displayName` clears the explicit name and falls back to the composed
|
|
180
|
+
* `first`/`last`; omitting the key leaves the stored value untouched. The two
|
|
181
|
+
* are not interchangeable.
|
|
182
|
+
*/
|
|
157
183
|
name?: {
|
|
158
184
|
first?: string;
|
|
159
185
|
last?: string;
|
|
186
|
+
displayName?: string;
|
|
160
187
|
};
|
|
161
188
|
bio?: string | null;
|
|
162
189
|
avatar?: string | null;
|
|
163
190
|
/** Clears the category when `null`; only valid on `kind: 'organization'`. */
|
|
164
191
|
organizationCategory?: OrganizationCategory | null;
|
|
165
192
|
}
|
|
193
|
+
/** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
|
|
194
|
+
export interface ProvisionChannelInput {
|
|
195
|
+
/** Personal account `_id` whose tree owns the new channel. */
|
|
196
|
+
ownerUserId: string;
|
|
197
|
+
username: string;
|
|
198
|
+
name?: {
|
|
199
|
+
first?: string;
|
|
200
|
+
last?: string;
|
|
201
|
+
displayName?: string;
|
|
202
|
+
};
|
|
203
|
+
bio?: string;
|
|
204
|
+
description?: string;
|
|
205
|
+
avatar?: string;
|
|
206
|
+
}
|
|
207
|
+
/** Input accepted by `provisionChannelMember` (service token + `accounts:provision`). */
|
|
208
|
+
export interface ProvisionChannelMemberInput {
|
|
209
|
+
memberUserId: string;
|
|
210
|
+
role: Exclude<AccountRole, 'owner'>;
|
|
211
|
+
inherit?: boolean;
|
|
212
|
+
}
|
|
213
|
+
/** Result of `provisionChannelAccount`. */
|
|
214
|
+
export interface ProvisionChannelResult {
|
|
215
|
+
account: User;
|
|
216
|
+
membership: AccountMember;
|
|
217
|
+
}
|
|
166
218
|
/** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
|
|
167
219
|
export interface InviteAccountMemberInput {
|
|
168
220
|
/**
|
|
@@ -430,6 +482,11 @@ export interface SwitchAccountResult extends SessionLoginResponse {
|
|
|
430
482
|
}
|
|
431
483
|
export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
432
484
|
new (...args: any[]): {
|
|
485
|
+
/**
|
|
486
|
+
* Inherited from the auth mixin at runtime. Declared here so service-scoped
|
|
487
|
+
* account provisioning methods can call it with correct typing.
|
|
488
|
+
*/
|
|
489
|
+
makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
|
|
433
490
|
/**
|
|
434
491
|
* List the accounts the caller can access: their own personal (root)
|
|
435
492
|
* account, accounts they own, and accounts shared with them (including
|
|
@@ -483,6 +540,23 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
|
|
|
483
540
|
* @param data - Account configuration: kind, optional parent, and profile.
|
|
484
541
|
*/
|
|
485
542
|
createAccount(data: CreateAccountInput): Promise<AccountNode>;
|
|
543
|
+
/**
|
|
544
|
+
* Mint a `channel` account under `ownerUserId` via service auth
|
|
545
|
+
* (`accounts:provision` scope). Requires `configureServiceAuth` first.
|
|
546
|
+
*/
|
|
547
|
+
provisionChannelAccount(data: ProvisionChannelInput): Promise<ProvisionChannelResult>;
|
|
548
|
+
/**
|
|
549
|
+
* Grant membership on a channel account via service auth
|
|
550
|
+
* (`accounts:provision` scope).
|
|
551
|
+
*/
|
|
552
|
+
provisionChannelMember(channelAccountId: string, data: ProvisionChannelMemberInput): Promise<{
|
|
553
|
+
member: AccountMember;
|
|
554
|
+
}>;
|
|
555
|
+
/**
|
|
556
|
+
* Revoke membership on a channel account via service auth
|
|
557
|
+
* (`accounts:provision` scope).
|
|
558
|
+
*/
|
|
559
|
+
revokeChannelMember(channelAccountId: string, memberUserId: string): Promise<AccountSuccessResult>;
|
|
486
560
|
/**
|
|
487
561
|
* Update an account's mutable profile fields. Tree placement changes
|
|
488
562
|
* (reparenting) go through the dedicated move endpoint, not here.
|
|
@@ -44,8 +44,9 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
|
|
|
44
44
|
* Zero-cookie mint. Present the first-party `deviceId` + `deviceSecret` to
|
|
45
45
|
* `POST /session/device/token` — NO bearer, NO cookies: possession of the
|
|
46
46
|
* secret IS the device-ownership proof. Returns a fresh short access token
|
|
47
|
-
* for the device's active account plus `nextDeviceSecret` (
|
|
48
|
-
*
|
|
47
|
+
* for the device's active account plus `nextDeviceSecret` (on mint, the same
|
|
48
|
+
* proven secret echoed back — rotation happens on sign-in, not mint) and
|
|
49
|
+
* the projected device-session `state`.
|
|
49
50
|
*
|
|
50
51
|
* `skipAuth`: this call carries no bearer, so a 401 must surface DIRECTLY —
|
|
51
52
|
* never trigger `HttpService`'s 401→refresh→retry dance. The cold boot / re-
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OrganizationCategory, UserNameResponse, UserRelationship, ThemePreference } from '@oxyhq/contracts';
|
|
1
|
+
import type { AccountKind, OrganizationCategory, UserNameResponse, UserRelationship, ThemePreference } from '@oxyhq/contracts';
|
|
2
2
|
export interface OxyConfig {
|
|
3
3
|
baseURL: string;
|
|
4
4
|
cloudURL?: string;
|
|
@@ -118,6 +118,22 @@ export interface User {
|
|
|
118
118
|
following?: number;
|
|
119
119
|
};
|
|
120
120
|
accountExpiresAfterInactivityDays?: number | null;
|
|
121
|
+
/**
|
|
122
|
+
* Account-graph classification — WHAT this account is.
|
|
123
|
+
*
|
|
124
|
+
* ORTHOGONAL to `type` below, and easy to confuse with it: `type` says where
|
|
125
|
+
* the account lives and how it is driven (`local` / `federated` / `agent` /
|
|
126
|
+
* `automated`), `kind` says what it IS (`personal` / `organization` /
|
|
127
|
+
* `project` / `bot` / `channel`). A federated channel is `type: 'federated'`
|
|
128
|
+
* AND `kind: 'channel'`; neither value substitutes for the other.
|
|
129
|
+
*
|
|
130
|
+
* This is what a consumer rendering authored content reads to tell a
|
|
131
|
+
* channel's post from a person's — a channel is the author, so there is no
|
|
132
|
+
* second identity to carry alongside the user.
|
|
133
|
+
*
|
|
134
|
+
* Absent should be read as `personal` (the column's default), not as unknown.
|
|
135
|
+
*/
|
|
136
|
+
kind?: AccountKind;
|
|
121
137
|
type?: 'local' | 'federated' | 'agent' | 'automated';
|
|
122
138
|
isFederated?: boolean;
|
|
123
139
|
/** Allow sharing this user's content on the fediverse. Defaults to true. */
|
|
@@ -34,10 +34,10 @@ export interface RefreshDeps {
|
|
|
34
34
|
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
35
35
|
* the re-mint handler and the cold boot can react per the transport contract
|
|
36
36
|
* without re-classifying the raw error:
|
|
37
|
-
* - `ok` — minted, persisted
|
|
37
|
+
* - `ok` — minted, persisted `nextDeviceSecret`, planted the token.
|
|
38
38
|
* - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
|
|
39
39
|
* - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
|
|
40
|
-
*
|
|
40
|
+
* no longer matches the server's stored hash.
|
|
41
41
|
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
42
42
|
* session (authoritative signed-out).
|
|
43
43
|
* - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
|
|
@@ -45,10 +45,10 @@ export interface RefreshDeps {
|
|
|
45
45
|
* The device secret is FINE — it is the identity binding that went stale, so
|
|
46
46
|
* the caller must re-establish from the local key, never drop the credential.
|
|
47
47
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
48
|
-
* - `persist-failed` — the mint succeeded
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
|
|
49
|
+
* durably persisted. The token is deliberately NOT planted: advertising a
|
|
50
|
+
* healthy session on a secret that will not survive a reload is exactly the
|
|
51
|
+
* divergence that logs users out.
|
|
52
52
|
*/
|
|
53
53
|
export type DeviceSecretMintOutcome = {
|
|
54
54
|
status: 'ok';
|
|
@@ -69,19 +69,14 @@ export type DeviceSecretMintOutcome = {
|
|
|
69
69
|
status: 'persist-failed';
|
|
70
70
|
};
|
|
71
71
|
/**
|
|
72
|
-
* Arm 1 — the
|
|
73
|
-
*
|
|
72
|
+
* Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
|
|
73
|
+
* single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
74
74
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
|
|
81
|
-
* user is signed out. Routing EVERY lane through this one single-flight makes
|
|
82
|
-
* concurrent callers await the SAME in-flight mint and all receive its result, so
|
|
83
|
-
* there is exactly one rotation and the store always converges on the true
|
|
84
|
-
* `current` secret.
|
|
75
|
+
* Concurrent lanes (cold boot, the proactive scheduler, a request-time preflight,
|
|
76
|
+
* a 401 retry, the socket token transport, or a tab-focus reconcile) must not
|
|
77
|
+
* each persist a different view of the mint response. Routing EVERY lane through
|
|
78
|
+
* this one single-flight makes concurrent callers await the SAME in-flight mint
|
|
79
|
+
* and all receive its result, so the durable store converges on one credential.
|
|
85
80
|
*
|
|
86
81
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
87
82
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"@noble/ciphers": "^1.3.0",
|
|
117
117
|
"@noble/hashes": "^1.8.0",
|
|
118
|
-
"@oxyhq/contracts": "^0.
|
|
118
|
+
"@oxyhq/contracts": "^0.22.0",
|
|
119
119
|
"@oxyhq/protocol": "^0.1.6",
|
|
120
120
|
"@scure/bip39": "^1.6.0",
|
|
121
121
|
"@types/elliptic": "^6.4.18",
|
|
@@ -227,10 +227,9 @@ export async function runSessionColdBoot(
|
|
|
227
227
|
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
228
228
|
// a single bearer-less POST (no cookie, no navigation). The mint itself runs
|
|
229
229
|
// through `refreshDeviceSecretArm`, which acquires the client's PROCESS-WIDE
|
|
230
|
-
// single-flight, persists
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// the durable store always converges on the true `current` secret.
|
|
230
|
+
// single-flight, persists `nextDeviceSecret` BEFORE planting the token, and
|
|
231
|
+
// returns a classified outcome — so concurrent mint lanes share one in-flight
|
|
232
|
+
// request and the durable store always converges on the server's credential.
|
|
234
233
|
steps.push({
|
|
235
234
|
id: 'device-secret-mint',
|
|
236
235
|
// Network step — skip entirely when the caller reports the device offline so
|
|
@@ -249,7 +248,7 @@ export async function runSessionColdBoot(
|
|
|
249
248
|
const result = await refreshDeviceSecretArm({ oxy, store, pin });
|
|
250
249
|
switch (result.status) {
|
|
251
250
|
case 'ok':
|
|
252
|
-
// The arm persisted
|
|
251
|
+
// The arm persisted nextDeviceSecret and planted the token.
|
|
253
252
|
return {
|
|
254
253
|
kind: 'session',
|
|
255
254
|
session: {
|
package/src/index.ts
CHANGED
|
@@ -138,6 +138,9 @@ export type {
|
|
|
138
138
|
ListAccountsOptions,
|
|
139
139
|
CreateAccountInput,
|
|
140
140
|
UpdateAccountInput,
|
|
141
|
+
ProvisionChannelInput,
|
|
142
|
+
ProvisionChannelMemberInput,
|
|
143
|
+
ProvisionChannelResult,
|
|
141
144
|
InviteAccountMemberInput,
|
|
142
145
|
UpdateAccountMemberInput,
|
|
143
146
|
TransferAccountOwnershipInput,
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* registers the switched session into the operator's device-set directly).
|
|
34
34
|
*/
|
|
35
35
|
import type { User } from '../models/interfaces';
|
|
36
|
-
import type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
|
|
36
|
+
import type { AccountKind, OrganizationCategory, ChildAccountKind } from '@oxyhq/contracts';
|
|
37
37
|
import type { SessionLoginResponse } from '../models/session';
|
|
38
38
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
39
39
|
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
@@ -148,8 +148,21 @@ export interface ListAccountsOptions {
|
|
|
148
148
|
|
|
149
149
|
/** Input accepted by `createAccount`. */
|
|
150
150
|
export interface CreateAccountInput {
|
|
151
|
-
/**
|
|
152
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Classification of the new account. Every CHILD kind is creatable here with
|
|
153
|
+
* the caller's own bearer, `channel` included: a signed-in person has already
|
|
154
|
+
* proven who they are, and minting a child under their own tree is the same
|
|
155
|
+
* operation whichever kind it is.
|
|
156
|
+
*
|
|
157
|
+
* `channel` used to be excluded, on the reasoning that channels are
|
|
158
|
+
* service-provisioned only. What actually makes a channel safe does not depend
|
|
159
|
+
* on who creates it: `createChildAccount` writes no auth method, so it is born
|
|
160
|
+
* with no login, and `POST /accounts/:id/switch` refuses it via
|
|
161
|
+
* `isActAsEligibleKind`, so no session can ever have a channel as its subject
|
|
162
|
+
* and therefore no bearer exists that could add one. `personal` is excluded
|
|
163
|
+
* because it is a human login, minted at signup.
|
|
164
|
+
*/
|
|
165
|
+
kind: ChildAccountKind;
|
|
153
166
|
/**
|
|
154
167
|
* Parent account `_id` to nest the new account under. Omitted → the API roots
|
|
155
168
|
* it under the caller's personal account.
|
|
@@ -157,7 +170,13 @@ export interface CreateAccountInput {
|
|
|
157
170
|
parentAccountId?: string;
|
|
158
171
|
/** Unique handle for the account (shares the `User.username` unique index). */
|
|
159
172
|
username: string;
|
|
160
|
-
|
|
173
|
+
/**
|
|
174
|
+
* A managed account (organization / project / bot / channel) has a TITLE, not
|
|
175
|
+
* a given-and-family name, so it sets `displayName` — the explicit
|
|
176
|
+
* `name_display` column — and leaves `first`/`last` unset. Splitting a title
|
|
177
|
+
* on whitespace into `first`/`last` is what this replaced.
|
|
178
|
+
*/
|
|
179
|
+
name?: { first?: string; last?: string; displayName?: string };
|
|
161
180
|
bio?: string;
|
|
162
181
|
avatar?: string;
|
|
163
182
|
/** Meaningful only when `kind` is `organization`. */
|
|
@@ -167,13 +186,43 @@ export interface CreateAccountInput {
|
|
|
167
186
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
168
187
|
export interface UpdateAccountInput {
|
|
169
188
|
username?: string;
|
|
170
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Same shape as `CreateAccountInput['name']`. On update, an EMPTY STRING in
|
|
191
|
+
* `displayName` clears the explicit name and falls back to the composed
|
|
192
|
+
* `first`/`last`; omitting the key leaves the stored value untouched. The two
|
|
193
|
+
* are not interchangeable.
|
|
194
|
+
*/
|
|
195
|
+
name?: { first?: string; last?: string; displayName?: string };
|
|
171
196
|
bio?: string | null;
|
|
172
197
|
avatar?: string | null;
|
|
173
198
|
/** Clears the category when `null`; only valid on `kind: 'organization'`. */
|
|
174
199
|
organizationCategory?: OrganizationCategory | null;
|
|
175
200
|
}
|
|
176
201
|
|
|
202
|
+
/** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
|
|
203
|
+
export interface ProvisionChannelInput {
|
|
204
|
+
/** Personal account `_id` whose tree owns the new channel. */
|
|
205
|
+
ownerUserId: string;
|
|
206
|
+
username: string;
|
|
207
|
+
name?: { first?: string; last?: string; displayName?: string };
|
|
208
|
+
bio?: string;
|
|
209
|
+
description?: string;
|
|
210
|
+
avatar?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Input accepted by `provisionChannelMember` (service token + `accounts:provision`). */
|
|
214
|
+
export interface ProvisionChannelMemberInput {
|
|
215
|
+
memberUserId: string;
|
|
216
|
+
role: Exclude<AccountRole, 'owner'>;
|
|
217
|
+
inherit?: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Result of `provisionChannelAccount`. */
|
|
221
|
+
export interface ProvisionChannelResult {
|
|
222
|
+
account: User;
|
|
223
|
+
membership: AccountMember;
|
|
224
|
+
}
|
|
225
|
+
|
|
177
226
|
/** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
|
|
178
227
|
export interface InviteAccountMemberInput {
|
|
179
228
|
/**
|
|
@@ -482,6 +531,17 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
482
531
|
super(...(args as [any]));
|
|
483
532
|
}
|
|
484
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Inherited from the auth mixin at runtime. Declared here so service-scoped
|
|
536
|
+
* account provisioning methods can call it with correct typing.
|
|
537
|
+
*/
|
|
538
|
+
declare makeServiceRequest: <R = unknown>(
|
|
539
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
540
|
+
url: string,
|
|
541
|
+
data?: unknown,
|
|
542
|
+
userId?: string,
|
|
543
|
+
) => Promise<R>;
|
|
544
|
+
|
|
485
545
|
// =========================================================================
|
|
486
546
|
// Accounts
|
|
487
547
|
// =========================================================================
|
|
@@ -620,6 +680,59 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
620
680
|
}
|
|
621
681
|
}
|
|
622
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Mint a `channel` account under `ownerUserId` via service auth
|
|
685
|
+
* (`accounts:provision` scope). Requires `configureServiceAuth` first.
|
|
686
|
+
*/
|
|
687
|
+
async provisionChannelAccount(data: ProvisionChannelInput): Promise<ProvisionChannelResult> {
|
|
688
|
+
try {
|
|
689
|
+
return await this.makeServiceRequest<ProvisionChannelResult>(
|
|
690
|
+
'POST',
|
|
691
|
+
'/accounts/service/channels',
|
|
692
|
+
data,
|
|
693
|
+
);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
throw this.handleError(error);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Grant membership on a channel account via service auth
|
|
701
|
+
* (`accounts:provision` scope).
|
|
702
|
+
*/
|
|
703
|
+
async provisionChannelMember(
|
|
704
|
+
channelAccountId: string,
|
|
705
|
+
data: ProvisionChannelMemberInput,
|
|
706
|
+
): Promise<{ member: AccountMember }> {
|
|
707
|
+
try {
|
|
708
|
+
return await this.makeServiceRequest<{ member: AccountMember }>(
|
|
709
|
+
'POST',
|
|
710
|
+
`/accounts/service/channels/${encodeURIComponent(channelAccountId)}/members`,
|
|
711
|
+
data,
|
|
712
|
+
);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
throw this.handleError(error);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Revoke membership on a channel account via service auth
|
|
720
|
+
* (`accounts:provision` scope).
|
|
721
|
+
*/
|
|
722
|
+
async revokeChannelMember(
|
|
723
|
+
channelAccountId: string,
|
|
724
|
+
memberUserId: string,
|
|
725
|
+
): Promise<AccountSuccessResult> {
|
|
726
|
+
try {
|
|
727
|
+
return await this.makeServiceRequest<AccountSuccessResult>(
|
|
728
|
+
'DELETE',
|
|
729
|
+
`/accounts/service/channels/${encodeURIComponent(channelAccountId)}/members/${encodeURIComponent(memberUserId)}`,
|
|
730
|
+
);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
throw this.handleError(error);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
623
736
|
/**
|
|
624
737
|
* Update an account's mutable profile fields. Tree placement changes
|
|
625
738
|
* (reparenting) go through the dedicated move endpoint, not here.
|
|
@@ -70,8 +70,9 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
70
70
|
* Zero-cookie mint. Present the first-party `deviceId` + `deviceSecret` to
|
|
71
71
|
* `POST /session/device/token` — NO bearer, NO cookies: possession of the
|
|
72
72
|
* secret IS the device-ownership proof. Returns a fresh short access token
|
|
73
|
-
* for the device's active account plus `nextDeviceSecret` (
|
|
74
|
-
*
|
|
73
|
+
* for the device's active account plus `nextDeviceSecret` (on mint, the same
|
|
74
|
+
* proven secret echoed back — rotation happens on sign-in, not mint) and
|
|
75
|
+
* the projected device-session `state`.
|
|
75
76
|
*
|
|
76
77
|
* `skipAuth`: this call carries no bearer, so a 401 must surface DIRECTLY —
|
|
77
78
|
* never trigger `HttpService`'s 401→refresh→retry dance. The cold boot / re-
|
package/src/models/interfaces.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AccountKind,
|
|
2
3
|
OrganizationCategory,
|
|
3
4
|
UserNameResponse,
|
|
4
5
|
UserRelationship,
|
|
@@ -134,6 +135,22 @@ export interface User {
|
|
|
134
135
|
following?: number;
|
|
135
136
|
};
|
|
136
137
|
accountExpiresAfterInactivityDays?: number | null; // Days of inactivity before account expires (null = never expire)
|
|
138
|
+
/**
|
|
139
|
+
* Account-graph classification — WHAT this account is.
|
|
140
|
+
*
|
|
141
|
+
* ORTHOGONAL to `type` below, and easy to confuse with it: `type` says where
|
|
142
|
+
* the account lives and how it is driven (`local` / `federated` / `agent` /
|
|
143
|
+
* `automated`), `kind` says what it IS (`personal` / `organization` /
|
|
144
|
+
* `project` / `bot` / `channel`). A federated channel is `type: 'federated'`
|
|
145
|
+
* AND `kind: 'channel'`; neither value substitutes for the other.
|
|
146
|
+
*
|
|
147
|
+
* This is what a consumer rendering authored content reads to tell a
|
|
148
|
+
* channel's post from a person's — a channel is the author, so there is no
|
|
149
|
+
* second identity to carry alongside the user.
|
|
150
|
+
*
|
|
151
|
+
* Absent should be read as `personal` (the column's default), not as unknown.
|
|
152
|
+
*/
|
|
153
|
+
kind?: AccountKind;
|
|
137
154
|
// User type and external account support
|
|
138
155
|
type?: 'local' | 'federated' | 'agent' | 'automated';
|
|
139
156
|
isFederated?: boolean;
|
package/src/session/refresh.ts
CHANGED
|
@@ -88,10 +88,10 @@ export interface RefreshDeps {
|
|
|
88
88
|
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
89
89
|
* the re-mint handler and the cold boot can react per the transport contract
|
|
90
90
|
* without re-classifying the raw error:
|
|
91
|
-
* - `ok` — minted, persisted
|
|
91
|
+
* - `ok` — minted, persisted `nextDeviceSecret`, planted the token.
|
|
92
92
|
* - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
|
|
93
93
|
* - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
|
|
94
|
-
*
|
|
94
|
+
* no longer matches the server's stored hash.
|
|
95
95
|
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
96
96
|
* session (authoritative signed-out).
|
|
97
97
|
* - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
|
|
@@ -99,10 +99,10 @@ export interface RefreshDeps {
|
|
|
99
99
|
* The device secret is FINE — it is the identity binding that went stale, so
|
|
100
100
|
* the caller must re-establish from the local key, never drop the credential.
|
|
101
101
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
102
|
-
* - `persist-failed` — the mint succeeded
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
102
|
+
* - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
|
|
103
|
+
* durably persisted. The token is deliberately NOT planted: advertising a
|
|
104
|
+
* healthy session on a secret that will not survive a reload is exactly the
|
|
105
|
+
* divergence that logs users out.
|
|
106
106
|
*/
|
|
107
107
|
export type DeviceSecretMintOutcome =
|
|
108
108
|
| { status: 'ok'; token: string; sessionId: string; userId: string }
|
|
@@ -114,19 +114,14 @@ export type DeviceSecretMintOutcome =
|
|
|
114
114
|
| { status: 'persist-failed' };
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* Arm 1 — the
|
|
118
|
-
*
|
|
117
|
+
* Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
|
|
118
|
+
* single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
119
119
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
|
|
126
|
-
* user is signed out. Routing EVERY lane through this one single-flight makes
|
|
127
|
-
* concurrent callers await the SAME in-flight mint and all receive its result, so
|
|
128
|
-
* there is exactly one rotation and the store always converges on the true
|
|
129
|
-
* `current` secret.
|
|
120
|
+
* Concurrent lanes (cold boot, the proactive scheduler, a request-time preflight,
|
|
121
|
+
* a 401 retry, the socket token transport, or a tab-focus reconcile) must not
|
|
122
|
+
* each persist a different view of the mint response. Routing EVERY lane through
|
|
123
|
+
* this one single-flight makes concurrent callers await the SAME in-flight mint
|
|
124
|
+
* and all receive its result, so the durable store converges on one credential.
|
|
130
125
|
*
|
|
131
126
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
132
127
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
@@ -200,8 +195,8 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
200
195
|
expiresAt: mint.expiresAt,
|
|
201
196
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
202
197
|
};
|
|
203
|
-
//
|
|
204
|
-
//
|
|
198
|
+
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
199
|
+
// A failed durable persist must NOT plant.
|
|
205
200
|
const persistedOk = await store.save(next);
|
|
206
201
|
if (!persistedOk) {
|
|
207
202
|
return { status: 'persist-failed' };
|