@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.
@@ -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
- /** Classification of the new account. `personal` accounts are not created here. */
137
- kind: AccountKind;
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` (rotation-in-use)
48
- * and the projected device-session `state`.
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 the rotated secret, planted the token.
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
- * diverged (another tab/device rotated it past the grace window).
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 (the SERVER rotated the secret) but
49
- * the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
50
- * deliberately NOT planted: advertising a healthy session on a secret that
51
- * will not survive a reload is exactly the divergence that logs users out.
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 rotating device-secret mint, run under the owning client's
73
- * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
72
+ * Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
73
+ * single-flight (`httpService.runSingleFlightDeviceSecretMint`).
74
74
  *
75
- * The server ROTATES the presented `deviceSecret` on every successful mint and
76
- * the just-presented secret is valid only for a short grace window. If two lanes
77
- * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
78
- * the socket token transport, or a tab-focus reconcile) minted concurrently they
79
- * would double-rotate the server and the durable store could converge on the
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": "17.0.3",
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.21.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 the rotated `nextDeviceSecret` BEFORE planting the
231
- // token, and returns a classified outcome — so this step can never
232
- // double-rotate the server against the scheduler/transport/401 lanes, and
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 the rotated secret and planted the token.
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
- /** Classification of the new account. `personal` accounts are not created here. */
152
- kind: AccountKind;
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
- name?: { first?: string; last?: string };
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
- name?: { first?: string; last?: string };
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` (rotation-in-use)
74
- * and the projected device-session `state`.
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-
@@ -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;
@@ -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 the rotated secret, planted the token.
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
- * diverged (another tab/device rotated it past the grace window).
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 (the SERVER rotated the secret) but
103
- * the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
104
- * deliberately NOT planted: advertising a healthy session on a secret that
105
- * will not survive a reload is exactly the divergence that logs users out.
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 rotating device-secret mint, run under the owning client's
118
- * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
117
+ * Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
118
+ * single-flight (`httpService.runSingleFlightDeviceSecretMint`).
119
119
  *
120
- * The server ROTATES the presented `deviceSecret` on every successful mint and
121
- * the just-presented secret is valid only for a short grace window. If two lanes
122
- * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
123
- * the socket token transport, or a tab-focus reconcile) minted concurrently they
124
- * would double-rotate the server and the durable store could converge on the
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
- // Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
204
- // landed BEFORE planting the token. A failed durable persist must NOT plant.
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' };