@oxyhq/core 17.0.2 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +6 -0
  3. package/dist/cjs/i18n/locales/es-ES.json +6 -0
  4. package/dist/cjs/i18n/locales/locales/en-US.json +6 -0
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +6 -0
  6. package/dist/cjs/mixins/OxyServices.accounts.js +39 -1
  7. package/dist/cjs/session/accountDialogController.js +3 -1
  8. package/dist/cjs/session/accountProjection.js +19 -1
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/i18n/locales/en-US.json +6 -0
  11. package/dist/esm/i18n/locales/es-ES.json +6 -0
  12. package/dist/esm/i18n/locales/locales/en-US.json +6 -0
  13. package/dist/esm/i18n/locales/locales/es-ES.json +6 -0
  14. package/dist/esm/mixins/OxyServices.accounts.js +37 -1
  15. package/dist/esm/session/accountDialogController.js +3 -1
  16. package/dist/esm/session/accountProjection.js +19 -1
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/crypto/keyManager.d.ts +2 -2
  19. package/dist/types/index.d.ts +1 -1
  20. package/dist/types/mixins/OxyServices.accounts.d.ts +73 -9
  21. package/dist/types/models/interfaces.d.ts +17 -1
  22. package/dist/types/session/accountDialogController.d.ts +1 -1
  23. package/dist/types/session/accountProjection.d.ts +6 -0
  24. package/package.json +3 -3
  25. package/src/crypto/keyManager.ts +1 -2
  26. package/src/i18n/locales/en-US.json +6 -0
  27. package/src/i18n/locales/es-ES.json +6 -0
  28. package/src/index.ts +4 -0
  29. package/src/mixins/OxyServices.accounts.ts +115 -12
  30. package/src/models/interfaces.ts +17 -0
  31. package/src/session/__tests__/accountDialogController.test.ts +13 -1
  32. package/src/session/__tests__/accountProjection.test.ts +31 -0
  33. package/src/session/accountDialogController.ts +4 -2
  34. package/src/session/accountProjection.ts +19 -1
  35. package/src/types/color.d.ts +0 -20
  36. package/src/types/elliptic.d.ts +0 -70
@@ -4,7 +4,7 @@
4
4
  * Handles secure generation, storage, and retrieval of cryptographic keys.
5
5
  * Private keys are stored securely using expo-secure-store and never leave the device.
6
6
  */
7
- import type { ECKeyPair } from 'elliptic';
7
+ import { ec as EC } from 'elliptic';
8
8
  import { type IdentityMarker } from './identityMarker';
9
9
  /**
10
10
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
@@ -536,7 +536,7 @@ export declare class KeyManager {
536
536
  * Get the elliptic curve key object from the stored private key
537
537
  * Used internally for signing operations
538
538
  */
539
- static getKeyPairObject(): Promise<ECKeyPair | null>;
539
+ static getKeyPairObject(): Promise<EC.KeyPair | null>;
540
540
  /**
541
541
  * Derive public key from a private key (without storing)
542
542
  */
@@ -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, UserCreatableAccountKind, 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,19 +33,20 @@
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 { OrganizationCategory } from '@oxyhq/contracts';
36
+ import type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
37
37
  import type { SessionLoginResponse } from '../models/session';
38
38
  import type { OxyServicesBase } from '../OxyServices.base';
39
39
  /**
40
40
  * Account classification, orthogonal to the federation `type`
41
41
  * (`local|federated|agent|automated`). `personal` accounts have a direct login;
42
- * `organization` / `project` / `bot` accounts are operated via `AccountMember`
43
- * and have no direct login.
42
+ * `organization` / `project` / `bot` / `channel` accounts are operated via
43
+ * `AccountMember` and have no direct login. Of those, only the first three may
44
+ * be acted AS — see `isActAsEligibleKind`.
45
+ *
46
+ * Single source of truth is `@oxyhq/contracts`.
44
47
  */
45
- export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
46
- /** Real-estate / team taxonomy for `kind: 'organization'` accounts. */
47
- export type { OrganizationCategory } from '@oxyhq/contracts';
48
- export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
48
+ export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
49
+ export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
49
50
  /**
50
51
  * The calling user's relationship to an account node, as resolved by the API:
51
52
  * - `self` — the caller's own personal (root) account.
@@ -130,10 +131,12 @@ export interface ListAccountsOptions {
130
131
  */
131
132
  tree?: boolean;
132
133
  }
134
+ /** Kinds a signed-in user may create via `POST /accounts`. Channels are service-provisioned only. */
135
+ export type UserCreatableAccountKind = 'organization' | 'project' | 'bot';
133
136
  /** Input accepted by `createAccount`. */
134
137
  export interface CreateAccountInput {
135
- /** Classification of the new account. `personal` accounts are not created here. */
136
- kind: AccountKind;
138
+ /** Classification of the new account. `personal` and `channel` are not creatable here. */
139
+ kind: UserCreatableAccountKind;
137
140
  /**
138
141
  * Parent account `_id` to nest the new account under. Omitted → the API roots
139
142
  * it under the caller's personal account.
@@ -141,9 +144,16 @@ export interface CreateAccountInput {
141
144
  parentAccountId?: string;
142
145
  /** Unique handle for the account (shares the `User.username` unique index). */
143
146
  username: string;
147
+ /**
148
+ * A managed account (organization / project / bot / channel) has a TITLE, not
149
+ * a given-and-family name, so it sets `displayName` — the explicit
150
+ * `name_display` column — and leaves `first`/`last` unset. Splitting a title
151
+ * on whitespace into `first`/`last` is what this replaced.
152
+ */
144
153
  name?: {
145
154
  first?: string;
146
155
  last?: string;
156
+ displayName?: string;
147
157
  };
148
158
  bio?: string;
149
159
  avatar?: string;
@@ -153,15 +163,47 @@ export interface CreateAccountInput {
153
163
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
154
164
  export interface UpdateAccountInput {
155
165
  username?: string;
166
+ /**
167
+ * Same shape as `CreateAccountInput['name']`. On update, an EMPTY STRING in
168
+ * `displayName` clears the explicit name and falls back to the composed
169
+ * `first`/`last`; omitting the key leaves the stored value untouched. The two
170
+ * are not interchangeable.
171
+ */
156
172
  name?: {
157
173
  first?: string;
158
174
  last?: string;
175
+ displayName?: string;
159
176
  };
160
177
  bio?: string | null;
161
178
  avatar?: string | null;
162
179
  /** Clears the category when `null`; only valid on `kind: 'organization'`. */
163
180
  organizationCategory?: OrganizationCategory | null;
164
181
  }
182
+ /** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
183
+ export interface ProvisionChannelInput {
184
+ /** Personal account `_id` whose tree owns the new channel. */
185
+ ownerUserId: string;
186
+ username: string;
187
+ name?: {
188
+ first?: string;
189
+ last?: string;
190
+ displayName?: string;
191
+ };
192
+ bio?: string;
193
+ description?: string;
194
+ avatar?: string;
195
+ }
196
+ /** Input accepted by `provisionChannelMember` (service token + `accounts:provision`). */
197
+ export interface ProvisionChannelMemberInput {
198
+ memberUserId: string;
199
+ role: Exclude<AccountRole, 'owner'>;
200
+ inherit?: boolean;
201
+ }
202
+ /** Result of `provisionChannelAccount`. */
203
+ export interface ProvisionChannelResult {
204
+ account: User;
205
+ membership: AccountMember;
206
+ }
165
207
  /** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
166
208
  export interface InviteAccountMemberInput {
167
209
  /**
@@ -429,6 +471,11 @@ export interface SwitchAccountResult extends SessionLoginResponse {
429
471
  }
430
472
  export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base: T): {
431
473
  new (...args: any[]): {
474
+ /**
475
+ * Inherited from the auth mixin at runtime. Declared here so service-scoped
476
+ * account provisioning methods can call it with correct typing.
477
+ */
478
+ makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
432
479
  /**
433
480
  * List the accounts the caller can access: their own personal (root)
434
481
  * account, accounts they own, and accounts shared with them (including
@@ -482,6 +529,23 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
482
529
  * @param data - Account configuration: kind, optional parent, and profile.
483
530
  */
484
531
  createAccount(data: CreateAccountInput): Promise<AccountNode>;
532
+ /**
533
+ * Mint a `channel` account under `ownerUserId` via service auth
534
+ * (`accounts:provision` scope). Requires `configureServiceAuth` first.
535
+ */
536
+ provisionChannelAccount(data: ProvisionChannelInput): Promise<ProvisionChannelResult>;
537
+ /**
538
+ * Grant membership on a channel account via service auth
539
+ * (`accounts:provision` scope).
540
+ */
541
+ provisionChannelMember(channelAccountId: string, data: ProvisionChannelMemberInput): Promise<{
542
+ member: AccountMember;
543
+ }>;
544
+ /**
545
+ * Revoke membership on a channel account via service auth
546
+ * (`accounts:provision` scope).
547
+ */
548
+ revokeChannelMember(channelAccountId: string, memberUserId: string): Promise<AccountSuccessResult>;
485
549
  /**
486
550
  * Update an account's mutable profile fields. Tree placement changes
487
551
  * (reparenting) go through the dedicated move endpoint, not here.
@@ -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. */
@@ -434,7 +434,7 @@ export declare class AccountDialogController {
434
434
  * subscription, which re-projects the active row. Concurrent switches are
435
435
  * ignored while one is in flight.
436
436
  */
437
- switchTo(accountId: string): Promise<void>;
437
+ switchTo(accountId: string): Promise<boolean>;
438
438
  /**
439
439
  * Start "Sign in with Oxy". Native devices with a shared identity mint a
440
440
  * session silently (`signInWithSharedIdentity`); everything else (web, or a
@@ -130,6 +130,9 @@ export interface ProjectSwitchableAccountsInput {
130
130
  * graph-only rows (in graph order). An account present as BOTH a device session
131
131
  * and a graph node is deduped into ONE device row enriched with the graph
132
132
  * metadata (relationship / kind / parent / membership).
133
+ *
134
+ * Graph nodes of a kind nobody may act as (`channel`) are omitted — see the
135
+ * filter below.
133
136
  */
134
137
  export declare function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[];
135
138
  /**
@@ -138,5 +141,8 @@ export declare function projectSwitchableAccounts(input: ProjectSwitchableAccoun
138
141
  * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
139
142
  * document, but including their ids lets the caller pass one id set and lets the
140
143
  * projection prefer freshly-fetched profiles uniformly.
144
+ *
145
+ * Applies the SAME act-as filter as {@link projectSwitchableAccounts} to graph
146
+ * nodes, so this never fetches a profile for a row the projection will drop.
141
147
  */
142
148
  export declare function switchableAccountIds(state: DeviceSessionState | null, graph: AccountNode[]): string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "17.0.2",
3
+ "version": "17.1.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,9 +115,10 @@
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
+ "@types/elliptic": "^6.4.18",
121
122
  "buffer": "^6.0.3",
122
123
  "elliptic": "^6.6.1",
123
124
  "invariant": "^2.2.4",
@@ -154,7 +155,6 @@
154
155
  "devDependencies": {
155
156
  "@biomejs/biome": "^1.9.4",
156
157
  "@react-native-async-storage/async-storage": "^2.2.0",
157
- "@types/elliptic": "^6.4.18",
158
158
  "@types/express": "^4.17.21",
159
159
  "@types/invariant": "^2.2.34",
160
160
  "@types/node": "^20.19.43",
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { ec as EC } from 'elliptic';
9
- import type { ECKeyPair } from 'elliptic';
10
9
  import { isWeb, isIOS, isAndroid } from '../utils/platform';
11
10
  import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadAsyncStorage, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
12
11
  import { isDev, logger } from '../logger';
@@ -2490,7 +2489,7 @@ export class KeyManager {
2490
2489
  * Get the elliptic curve key object from the stored private key
2491
2490
  * Used internally for signing operations
2492
2491
  */
2493
- static async getKeyPairObject(): Promise<ECKeyPair | null> {
2492
+ static async getKeyPairObject(): Promise<EC.KeyPair | null> {
2494
2493
  if (isWebPlatform()) {
2495
2494
  return null; // Identity storage is only available on native platforms
2496
2495
  }
@@ -54,6 +54,12 @@
54
54
  "status": {
55
55
  "accountSwitched": "Now using {{name}}",
56
56
  "signingIn": "Signing in…"
57
+ },
58
+ "errors": {
59
+ "notConfigured": "Sign-in isn't available",
60
+ "notConfiguredDescription": "{{app}} isn't set up for sign-in yet. Contact the app's developer.",
61
+ "failed": "Couldn't start sign-in",
62
+ "failedDescription": "Something went wrong. Please try again."
57
63
  }
58
64
  },
59
65
  "signup": {
@@ -54,6 +54,12 @@
54
54
  "status": {
55
55
  "accountSwitched": "Ahora usando {{name}}",
56
56
  "signingIn": "Iniciando sesión…"
57
+ },
58
+ "errors": {
59
+ "notConfigured": "El inicio de sesión no está disponible",
60
+ "notConfiguredDescription": "{{app}} todavía no está configurada para iniciar sesión. Contacta con quien desarrolla la app.",
61
+ "failed": "No se pudo iniciar sesión",
62
+ "failedDescription": "Algo ha ido mal. Inténtalo de nuevo."
57
63
  }
58
64
  },
59
65
  "signup": {
package/src/index.ts CHANGED
@@ -136,8 +136,12 @@ export type {
136
136
  AccountCredentialWithSecret,
137
137
  RotateAccountCredentialResult,
138
138
  ListAccountsOptions,
139
+ UserCreatableAccountKind,
139
140
  CreateAccountInput,
140
141
  UpdateAccountInput,
142
+ ProvisionChannelInput,
143
+ ProvisionChannelMemberInput,
144
+ ProvisionChannelResult,
141
145
  InviteAccountMemberInput,
142
146
  UpdateAccountMemberInput,
143
147
  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 { OrganizationCategory } from '@oxyhq/contracts';
36
+ import type { AccountKind, OrganizationCategory } 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';
@@ -46,14 +46,14 @@ import { CACHE_TIMES } from './mixinHelpers';
46
46
  /**
47
47
  * Account classification, orthogonal to the federation `type`
48
48
  * (`local|federated|agent|automated`). `personal` accounts have a direct login;
49
- * `organization` / `project` / `bot` accounts are operated via `AccountMember`
50
- * and have no direct login.
49
+ * `organization` / `project` / `bot` / `channel` accounts are operated via
50
+ * `AccountMember` and have no direct login. Of those, only the first three may
51
+ * be acted AS — see `isActAsEligibleKind`.
52
+ *
53
+ * Single source of truth is `@oxyhq/contracts`.
51
54
  */
52
- export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
53
-
54
- /** Real-estate / team taxonomy for `kind: 'organization'` accounts. */
55
- export type { OrganizationCategory } from '@oxyhq/contracts';
56
- export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
55
+ export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
56
+ export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
57
57
 
58
58
  /**
59
59
  * The calling user's relationship to an account node, as resolved by the API:
@@ -146,10 +146,13 @@ export interface ListAccountsOptions {
146
146
  tree?: boolean;
147
147
  }
148
148
 
149
+ /** Kinds a signed-in user may create via `POST /accounts`. Channels are service-provisioned only. */
150
+ export type UserCreatableAccountKind = 'organization' | 'project' | 'bot';
151
+
149
152
  /** Input accepted by `createAccount`. */
150
153
  export interface CreateAccountInput {
151
- /** Classification of the new account. `personal` accounts are not created here. */
152
- kind: AccountKind;
154
+ /** Classification of the new account. `personal` and `channel` are not creatable here. */
155
+ kind: UserCreatableAccountKind;
153
156
  /**
154
157
  * Parent account `_id` to nest the new account under. Omitted → the API roots
155
158
  * it under the caller's personal account.
@@ -157,7 +160,13 @@ export interface CreateAccountInput {
157
160
  parentAccountId?: string;
158
161
  /** Unique handle for the account (shares the `User.username` unique index). */
159
162
  username: string;
160
- name?: { first?: string; last?: string };
163
+ /**
164
+ * A managed account (organization / project / bot / channel) has a TITLE, not
165
+ * a given-and-family name, so it sets `displayName` — the explicit
166
+ * `name_display` column — and leaves `first`/`last` unset. Splitting a title
167
+ * on whitespace into `first`/`last` is what this replaced.
168
+ */
169
+ name?: { first?: string; last?: string; displayName?: string };
161
170
  bio?: string;
162
171
  avatar?: string;
163
172
  /** Meaningful only when `kind` is `organization`. */
@@ -167,13 +176,43 @@ export interface CreateAccountInput {
167
176
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
168
177
  export interface UpdateAccountInput {
169
178
  username?: string;
170
- name?: { first?: string; last?: string };
179
+ /**
180
+ * Same shape as `CreateAccountInput['name']`. On update, an EMPTY STRING in
181
+ * `displayName` clears the explicit name and falls back to the composed
182
+ * `first`/`last`; omitting the key leaves the stored value untouched. The two
183
+ * are not interchangeable.
184
+ */
185
+ name?: { first?: string; last?: string; displayName?: string };
171
186
  bio?: string | null;
172
187
  avatar?: string | null;
173
188
  /** Clears the category when `null`; only valid on `kind: 'organization'`. */
174
189
  organizationCategory?: OrganizationCategory | null;
175
190
  }
176
191
 
192
+ /** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
193
+ export interface ProvisionChannelInput {
194
+ /** Personal account `_id` whose tree owns the new channel. */
195
+ ownerUserId: string;
196
+ username: string;
197
+ name?: { first?: string; last?: string; displayName?: string };
198
+ bio?: string;
199
+ description?: string;
200
+ avatar?: string;
201
+ }
202
+
203
+ /** Input accepted by `provisionChannelMember` (service token + `accounts:provision`). */
204
+ export interface ProvisionChannelMemberInput {
205
+ memberUserId: string;
206
+ role: Exclude<AccountRole, 'owner'>;
207
+ inherit?: boolean;
208
+ }
209
+
210
+ /** Result of `provisionChannelAccount`. */
211
+ export interface ProvisionChannelResult {
212
+ account: User;
213
+ membership: AccountMember;
214
+ }
215
+
177
216
  /** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
178
217
  export interface InviteAccountMemberInput {
179
218
  /**
@@ -482,6 +521,17 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
482
521
  super(...(args as [any]));
483
522
  }
484
523
 
524
+ /**
525
+ * Inherited from the auth mixin at runtime. Declared here so service-scoped
526
+ * account provisioning methods can call it with correct typing.
527
+ */
528
+ declare makeServiceRequest: <R = unknown>(
529
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
530
+ url: string,
531
+ data?: unknown,
532
+ userId?: string,
533
+ ) => Promise<R>;
534
+
485
535
  // =========================================================================
486
536
  // Accounts
487
537
  // =========================================================================
@@ -620,6 +670,59 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
620
670
  }
621
671
  }
622
672
 
673
+ /**
674
+ * Mint a `channel` account under `ownerUserId` via service auth
675
+ * (`accounts:provision` scope). Requires `configureServiceAuth` first.
676
+ */
677
+ async provisionChannelAccount(data: ProvisionChannelInput): Promise<ProvisionChannelResult> {
678
+ try {
679
+ return await this.makeServiceRequest<ProvisionChannelResult>(
680
+ 'POST',
681
+ '/accounts/service/channels',
682
+ data,
683
+ );
684
+ } catch (error) {
685
+ throw this.handleError(error);
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Grant membership on a channel account via service auth
691
+ * (`accounts:provision` scope).
692
+ */
693
+ async provisionChannelMember(
694
+ channelAccountId: string,
695
+ data: ProvisionChannelMemberInput,
696
+ ): Promise<{ member: AccountMember }> {
697
+ try {
698
+ return await this.makeServiceRequest<{ member: AccountMember }>(
699
+ 'POST',
700
+ `/accounts/service/channels/${encodeURIComponent(channelAccountId)}/members`,
701
+ data,
702
+ );
703
+ } catch (error) {
704
+ throw this.handleError(error);
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Revoke membership on a channel account via service auth
710
+ * (`accounts:provision` scope).
711
+ */
712
+ async revokeChannelMember(
713
+ channelAccountId: string,
714
+ memberUserId: string,
715
+ ): Promise<AccountSuccessResult> {
716
+ try {
717
+ return await this.makeServiceRequest<AccountSuccessResult>(
718
+ 'DELETE',
719
+ `/accounts/service/channels/${encodeURIComponent(channelAccountId)}/members/${encodeURIComponent(memberUserId)}`,
720
+ );
721
+ } catch (error) {
722
+ throw this.handleError(error);
723
+ }
724
+ }
725
+
623
726
  /**
624
727
  * Update an account's mutable profile fields. Tree placement changes
625
728
  * (reparenting) go through the dedicated move endpoint, not here.
@@ -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;
@@ -472,7 +472,7 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
472
472
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
473
473
  oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
474
474
 
475
- await expect(controller.switchTo('org1')).resolves.toBeUndefined();
475
+ await expect(controller.switchTo('org1')).resolves.toBe(false);
476
476
 
477
477
  const snap = controller.getSnapshot();
478
478
  expect(snap.error).toBe('Cannot switch into a personal account');
@@ -517,6 +517,18 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
517
517
  release();
518
518
  await first;
519
519
  });
520
+
521
+ it('returns true when the switch succeeds even if the post-switch refresh fails', async () => {
522
+ const { controller, oxy, sc } = makeHarness();
523
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
524
+ jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
525
+ oxy.listAccounts.mockRejectedValue(new Error('network down'));
526
+
527
+ await expect(controller.switchTo('a2')).resolves.toBe(true);
528
+
529
+ expect(controller.getSnapshot().error).toBe('network down');
530
+ expect(controller.getSnapshot().switchingAccountId).toBeNull();
531
+ });
520
532
  });
521
533
 
522
534
  describe('AccountDialogController — sign in with Oxy', () => {
@@ -115,6 +115,29 @@ describe('projectSwitchableAccounts', () => {
115
115
  expect(org.parentAccountId).toBe('a1');
116
116
  });
117
117
 
118
+ /**
119
+ * A channel is a content identity nobody acts as, so it must never be offered
120
+ * as a switch target. It reaches the projection through the GRAPH lane, not
121
+ * the device lane — a channel has no credentials and so can never be a device
122
+ * session, which is exactly why "no-login accounts can't appear here by
123
+ * construction" is false: `listAccounts()` contributes credential-less
124
+ * accounts on purpose (that is how an org first becomes switchable).
125
+ */
126
+ it('omits a graph-only channel account from the switcher', () => {
127
+ const rows = projectSwitchableAccounts({
128
+ state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
129
+ graph: [
130
+ graphNode('org1', { kind: 'organization' }),
131
+ graphNode('chan1', { kind: 'channel' }),
132
+ ],
133
+ profilesById: mapOf(user('a1')),
134
+ resolveAvatarUrl: noAvatar,
135
+ });
136
+
137
+ expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
138
+ expect(rows.some((r) => r.kind === 'channel')).toBe(false);
139
+ });
140
+
118
141
  it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
119
142
  const rows = projectSwitchableAccounts({
120
143
  state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
@@ -208,4 +231,12 @@ describe('switchableAccountIds', () => {
208
231
  it('returns [] for null state and empty graph', () => {
209
232
  expect(switchableAccountIds(null, [])).toEqual([]);
210
233
  });
234
+
235
+ it('omits a graph-only channel, so no profile is fetched for a dropped row', () => {
236
+ const ids = switchableAccountIds(null, [
237
+ graphNode('org1', { kind: 'organization' }),
238
+ graphNode('chan1', { kind: 'channel' }),
239
+ ]);
240
+ expect(ids).toEqual(['org1']);
241
+ });
211
242
  });
@@ -783,8 +783,8 @@ export class AccountDialogController {
783
783
  * subscription, which re-projects the active row. Concurrent switches are
784
784
  * ignored while one is in flight.
785
785
  */
786
- async switchTo(accountId: string): Promise<void> {
787
- if (this.switchingAccountId) return;
786
+ async switchTo(accountId: string): Promise<boolean> {
787
+ if (this.switchingAccountId) return false;
788
788
  this.switchingAccountId = accountId;
789
789
  this.error = null;
790
790
  this.emit();
@@ -815,8 +815,10 @@ export class AccountDialogController {
815
815
  }
816
816
  // Re-project + refetch immediately; the subscription also fires.
817
817
  await this.refresh();
818
+ return true;
818
819
  } catch (error) {
819
820
  this.error = errorMessage(error);
821
+ return false;
820
822
  } finally {
821
823
  this.switchingAccountId = null;
822
824
  this.emit();