@oxyhq/core 19.1.2 → 20.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 (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/HttpService.js +23 -18
  5. package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
  6. package/dist/cjs/i18n/accountRoleLabels.js +27 -0
  7. package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
  8. package/dist/cjs/i18n/trustTierLabels.js +19 -0
  9. package/dist/cjs/index.js +19 -9
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
  12. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  13. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  14. package/dist/cjs/mixins/index.js +7 -0
  15. package/dist/cjs/server/rateLimit.js +15 -6
  16. package/dist/cjs/session/accountProjection.js +31 -6
  17. package/dist/cjs/utils/errorUtils.js +65 -1
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/HttpService.js +24 -19
  20. package/dist/esm/i18n/accountCategoryLabels.js +37 -0
  21. package/dist/esm/i18n/accountRoleLabels.js +20 -0
  22. package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
  23. package/dist/esm/i18n/trustTierLabels.js +12 -0
  24. package/dist/esm/index.js +11 -8
  25. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  26. package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
  27. package/dist/esm/mixins/OxyServices.store.js +263 -0
  28. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  29. package/dist/esm/mixins/index.js +7 -0
  30. package/dist/esm/server/rateLimit.js +15 -6
  31. package/dist/esm/session/accountProjection.js +30 -6
  32. package/dist/esm/utils/errorUtils.js +63 -1
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
  35. package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
  36. package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
  37. package/dist/types/i18n/trustTierLabels.d.ts +9 -0
  38. package/dist/types/index.d.ts +14 -2
  39. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  40. package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
  41. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  42. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  43. package/dist/types/mixins/index.d.ts +3 -1
  44. package/dist/types/session/accountProjection.d.ts +20 -4
  45. package/dist/types/utils/errorUtils.d.ts +67 -0
  46. package/package.json +7 -6
  47. package/src/HttpService.ts +29 -22
  48. package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
  49. package/src/__tests__/serverValueImportsDeclared.test.ts +7 -0
  50. package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
  51. package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
  52. package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
  53. package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
  54. package/src/i18n/accountCategoryLabels.ts +44 -0
  55. package/src/i18n/accountRoleLabels.ts +26 -0
  56. package/src/i18n/reputationCategoryLabels.ts +20 -0
  57. package/src/i18n/trustTierLabels.ts +18 -0
  58. package/src/index.ts +43 -6
  59. package/src/mixins/OxyServices.chains.ts +134 -0
  60. package/src/mixins/OxyServices.followGraph.ts +24 -0
  61. package/src/mixins/OxyServices.store.ts +585 -0
  62. package/src/mixins/OxyServices.utility.ts +161 -108
  63. package/src/mixins/__tests__/chains.test.ts +113 -0
  64. package/src/mixins/__tests__/followGraph.test.ts +19 -0
  65. package/src/mixins/__tests__/store.test.ts +304 -0
  66. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  67. package/src/mixins/index.ts +9 -0
  68. package/src/server/__tests__/rateLimit.test.ts +47 -0
  69. package/src/server/rateLimit.ts +18 -8
  70. package/src/session/__tests__/accountProjection.test.ts +98 -0
  71. package/src/session/accountProjection.ts +37 -6
  72. package/src/utils/errorUtils.ts +116 -5
@@ -0,0 +1,47 @@
1
+ import { TRUST_TIERS } from '@oxyhq/contracts';
2
+ import { EN_TRUST_TIER_LABELS, trustTierLabel } from '../trustTierLabels';
3
+
4
+ const SHIPPED_LOCALES = [
5
+ 'en-US',
6
+ 'es-ES',
7
+ 'ca-ES',
8
+ 'fr-FR',
9
+ 'de-DE',
10
+ 'it-IT',
11
+ 'pt-PT',
12
+ 'ja-JP',
13
+ 'ko-KR',
14
+ 'zh-CN',
15
+ 'ar-SA',
16
+ ] as const;
17
+ const REGION_VARIANT = 'es-MX';
18
+ const UNSHIPPED_LOCALE = 'nl-NL';
19
+
20
+ describe('trustTierLabel', () => {
21
+ it('covers the whole vocabulary', () => {
22
+ expect(TRUST_TIERS.length).toBe(5);
23
+ expect(Object.keys(EN_TRUST_TIER_LABELS)).toHaveLength(TRUST_TIERS.length);
24
+ });
25
+
26
+ it.each([...SHIPPED_LOCALES, REGION_VARIANT, UNSHIPPED_LOCALE])(
27
+ 'names every tier in %s — never a key, never a slug, never empty',
28
+ (locale) => {
29
+ for (const tier of TRUST_TIERS) {
30
+ const label = trustTierLabel(locale, tier);
31
+ expect(label).not.toBe('');
32
+ expect(label).not.toBe(`trust.tiers.${tier}`);
33
+ }
34
+ },
35
+ );
36
+
37
+ it('falls back to English for a language with no tier translations', () => {
38
+ expect(trustTierLabel('de-DE', 'high_trust')).toBe(
39
+ EN_TRUST_TIER_LABELS.high_trust,
40
+ );
41
+ });
42
+
43
+ it('resolves a region variant through its base language', () => {
44
+ expect(trustTierLabel('es-MX', 'verified')).toBe(trustTierLabel('es-ES', 'verified'));
45
+ expect(trustTierLabel('es-MX', 'verified')).not.toBe(EN_TRUST_TIER_LABELS.verified);
46
+ });
47
+ });
@@ -0,0 +1,44 @@
1
+ import type { AccountCategoryId } from '@oxyhq/contracts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every account category's English name, keyed by its stable id.
7
+ *
8
+ * **The annotation is the point.** The vocabulary lives in `@oxyhq/contracts`
9
+ * and the names live in `locales/en-US.json`, so they are two lists that must
10
+ * agree and nothing but a type can make them. Declaring the JSON node as a
11
+ * TOTAL `Record<AccountCategoryId, string>` turns "somebody added a category at
12
+ * Oxy and nobody wrote its English" into a `TS2741` naming the missing id, at
13
+ * build time, instead of a picker row that paints `accounts.accountCategory.<id>`
14
+ * at a user trying to choose one.
15
+ *
16
+ * That failure is not hypothetical. The screen previously wrote `t(key) || id`,
17
+ * whose author believed an unnamed id would degrade to its raw slug. It cannot:
18
+ * {@link translate} echoes the KEY when it resolves nothing, and a non-empty
19
+ * string is never falsy, so the `|| id` arm was unreachable and the output was
20
+ * the dotted key. A runtime fallback that cannot run is worse than none,
21
+ * because it reads as protection.
22
+ *
23
+ * Totality is over `ACCOUNT_CATEGORY_IDS`, which RETAINS withdrawn ids, so an
24
+ * account still carrying a retired category keeps rendering its name while no
25
+ * picker offers it again. Retired and unknown are different cases: only an id
26
+ * outside the union is unnameable, which is why this is keyed by
27
+ * `AccountCategoryId` and not by `string`.
28
+ */
29
+ /**
30
+ * Module-scoped, NOT re-exported from the package index: the annotation is the
31
+ * whole job, and it does that job without being public API. It carries no
32
+ * `Object.freeze` and no `Readonly<>` for the same reason — those existed only
33
+ * to make an exported reference safe from a consumer's stray write, and there
34
+ * is no such consumer. Exported from the MODULE so its own test can name it.
35
+ */
36
+ export const EN_ACCOUNT_CATEGORY_LABELS: Record<AccountCategoryId, string> =
37
+ enUS.accounts.accountCategory;
38
+
39
+ export function accountCategoryLabel(
40
+ locale: string | undefined,
41
+ id: AccountCategoryId,
42
+ ): string {
43
+ return translate(locale, `accounts.accountCategory.${id}`);
44
+ }
@@ -0,0 +1,26 @@
1
+ import type { AccountRole } from '../mixins/OxyServices.accounts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every account member role's English name, keyed by its stable id.
7
+ *
8
+ * Totality is over the closed `AccountRole` union so a new role without an
9
+ * English label is a build error, not a members row that paints
10
+ * `accounts.roles.<role>.label`.
11
+ */
12
+ export const EN_ACCOUNT_ROLE_LABELS: Record<AccountRole, string> = {
13
+ owner: enUS.accounts.roles.owner.label,
14
+ admin: enUS.accounts.roles.admin.label,
15
+ editor: enUS.accounts.roles.editor.label,
16
+ developer: enUS.accounts.roles.developer.label,
17
+ billing: enUS.accounts.roles.billing.label,
18
+ viewer: enUS.accounts.roles.viewer.label,
19
+ };
20
+
21
+ export function accountRoleLabel(
22
+ locale: string | undefined,
23
+ role: AccountRole,
24
+ ): string {
25
+ return translate(locale, `accounts.roles.${role}.label`);
26
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReputationCategory } from '@oxyhq/contracts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every reputation rule category's English name, keyed by its stable id.
7
+ *
8
+ * Totality is over `REPUTATION_CATEGORIES` from `@oxyhq/contracts` so a new
9
+ * category added server-side without an English label is a build error, not a
10
+ * Trust Rules section title that paints `trust.rules.categories.<id>`.
11
+ */
12
+ export const EN_REPUTATION_CATEGORY_LABELS: Record<ReputationCategory, string> =
13
+ enUS.trust.rules.categories;
14
+
15
+ export function reputationCategoryLabel(
16
+ locale: string | undefined,
17
+ id: ReputationCategory,
18
+ ): string {
19
+ return translate(locale, `trust.rules.categories.${id}`);
20
+ }
@@ -0,0 +1,18 @@
1
+ import type { TrustTier } from '@oxyhq/contracts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every trust tier's English name, keyed by its stable id.
7
+ *
8
+ * Totality is over `TRUST_TIERS` from `@oxyhq/contracts` so a new tier without
9
+ * an English label is a build error, not a chip that paints `trust.tiers.<id>`.
10
+ */
11
+ export const EN_TRUST_TIER_LABELS: Record<TrustTier, string> = enUS.trust.tiers;
12
+
13
+ export function trustTierLabel(
14
+ locale: string | undefined,
15
+ tier: TrustTier,
16
+ ): string {
17
+ return translate(locale, `trust.tiers.${tier}`);
18
+ }
package/src/index.ts CHANGED
@@ -116,6 +116,29 @@ export type {
116
116
  ConnectedApp,
117
117
  } from './mixins/OxyServices.connectedApps';
118
118
 
119
+ // ---------------------------------------------------------------------------
120
+ // App store (public storefront + reviews + the listing a publisher edits)
121
+ // ---------------------------------------------------------------------------
122
+ export type {
123
+ StoreCategory,
124
+ StoreRating,
125
+ StoreListingSummary,
126
+ StoreListingDetail,
127
+ StoreScreenshot,
128
+ StoreScreenshotPlatform,
129
+ StoreReview,
130
+ StoreOwnReview,
131
+ WriteStoreReviewInput,
132
+ StoreListingStatus,
133
+ PublisherListing,
134
+ WriteListingInput,
135
+ AddScreenshotInput,
136
+ UpdateScreenshotInput,
137
+ StorePage,
138
+ StorePageOptions,
139
+ StoreReviewsOptions,
140
+ } from './mixins/OxyServices.store';
141
+
119
142
  // ---------------------------------------------------------------------------
120
143
  // Accounts (unified account graph: tree, membership, roles, bot credentials)
121
144
  // plus the applications owned within it (Application = OAuth client).
@@ -230,6 +253,13 @@ export type {
230
253
  } from './mixins/OxyServices.civic';
231
254
  export type { UserNodeStatus, UserNodeMode, UserNodeController, UserNodeLivenessStatus, RegisterNodeInput, RemoveNodeResult } from './mixins/OxyServices.nodes';
232
255
 
256
+ /**
257
+ * Chains — the shared per-person record log. `ChainRecord` is generic over the
258
+ * app's own lexicon payload, so a consumer types its records without Oxy
259
+ * knowing any app's schema.
260
+ */
261
+ export type { ChainRecord, ChainRecordPage, AppendedChainRecord } from './mixins/OxyServices.chains';
262
+
233
263
  // ---------------------------------------------------------------------------
234
264
  // Auth helpers (token refresh, error normalisation, retry policies)
235
265
  // ---------------------------------------------------------------------------
@@ -453,6 +483,10 @@ export type { CircuitBreakerState, CircuitBreakerConfig } from './shared/utils/n
453
483
  // i18n
454
484
  // ---------------------------------------------------------------------------
455
485
  export { translate } from './i18n';
486
+ export { accountCategoryLabel } from './i18n/accountCategoryLabels';
487
+ export { accountRoleLabel } from './i18n/accountRoleLabels';
488
+ export { reputationCategoryLabel } from './i18n/reputationCategoryLabels';
489
+ export { trustTierLabel } from './i18n/trustTierLabels';
456
490
 
457
491
  // ---------------------------------------------------------------------------
458
492
  // API request / URL helpers
@@ -476,8 +510,11 @@ export {
476
510
  ErrorCodes,
477
511
  createApiError,
478
512
  handleHttpError,
513
+ isHttpRequestError,
514
+ parseHttpErrorBody,
479
515
  validateRequiredFields,
480
516
  } from './utils/errorUtils';
517
+ export type { HttpRequestError, ParsedHttpErrorBody } from './utils/errorUtils';
481
518
 
482
519
  export { retryAsync } from './utils/asyncUtils';
483
520
 
@@ -647,14 +684,14 @@ export {
647
684
  // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
648
685
  // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
649
686
  // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
650
- // `isSwitchTargetAccount` is the switcher's own question ("can I become this
651
- // account?"), exported so a surface that renders `AccountNode`s rather than the
652
- // projection the Console's workspace switcher, the accounts app's
653
- // managed-accounts rows asks the SAME question instead of testing a kind
654
- // literal. It is NOT `isActAsEligibleKind`: that one is false for `personal`
655
- // too, so gating a switcher on it alone empties the list.
687
+ // `isSwitchTargetAccount` is the structural half ("is this kind switchable at
688
+ // all?"); `canSwitchIntoAccount` adds the caller's `account:act_as` permission.
689
+ // Both are exported so surfaces that render `AccountNode`s rather than the
690
+ // projection — the Console workspace switcher, managed-accounts rows ask the
691
+ // SAME questions instead of testing a kind literal.
656
692
  export {
657
693
  isSwitchTargetAccount,
694
+ canSwitchIntoAccount,
658
695
  projectSwitchableAccounts,
659
696
  switchableAccountIds,
660
697
  } from './session/accountProjection';
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Chains — the shared record log every Oxy app reads and writes.
3
+ *
4
+ * A person has ONE chain. An app appends its own records to it and projects its
5
+ * feeds from what it reads back, instead of keeping a private copy of the same
6
+ * person's activity. This mixin is the client half of `/chains` in oxy-api, and
7
+ * it exists so that adopting the chain costs an app no HTTP of its own — the
8
+ * whole point of the shared substrate is that the second app writes less code
9
+ * than the first, not the same amount in a different file.
10
+ *
11
+ * ## Both calls are SERVICE-authenticated
12
+ *
13
+ * They go through `makeServiceRequest`, so they only work on a backend that has
14
+ * called `configureServiceAuth()`. That is not an accident of implementation: an
15
+ * append writes to someone else's chain and a read spans many subjects, so
16
+ * neither belongs in a browser holding a user session. A frontend that needs
17
+ * this asks its own backend.
18
+ *
19
+ * The authority is checked server-side and cannot be talked out of from here:
20
+ * `chains:write` plus the application's own `chainNamespaces` for an append,
21
+ * `chains:read` plus the public-collection policy for a read. A call that
22
+ * violates either gets a 403 or an empty page — this client adds no
23
+ * pre-validation that could drift from the server's answer.
24
+ */
25
+
26
+ import type { OxyServicesBase } from '../OxyServices.base';
27
+
28
+ /** A signed record as it comes back from a read. */
29
+ export interface ChainRecord<TRecord = Record<string, unknown>> {
30
+ recordId: string;
31
+ /** The subject whose chain it is — the person the record is about. */
32
+ oxyUserId: string;
33
+ /** The lexicon NSID, e.g. `app.mention.feed.post`. */
34
+ collection: string;
35
+ envelope: {
36
+ version: number;
37
+ type: string;
38
+ subject: string;
39
+ issuer: string;
40
+ record: TRecord;
41
+ issuedAt: number;
42
+ seq?: number;
43
+ prev?: string | null;
44
+ collection?: string;
45
+ rkey?: string;
46
+ publicKey: string;
47
+ alg: string;
48
+ signature: string;
49
+ };
50
+ }
51
+
52
+ /** One page of a multi-subject read. */
53
+ export interface ChainRecordPage<TRecord = Record<string, unknown>> {
54
+ records: ChainRecord<TRecord>[];
55
+ /**
56
+ * Opaque. Hand it back as `since` to continue; `null` at the end of the
57
+ * stream as of this snapshot. Never construct one.
58
+ */
59
+ nextCursor: string | null;
60
+ }
61
+
62
+ /** What an append returns once the record is on the chain. */
63
+ export interface AppendedChainRecord {
64
+ recordId: string;
65
+ seq: number;
66
+ envelope: ChainRecord['envelope'];
67
+ verified: boolean;
68
+ }
69
+
70
+ export function OxyServicesChainsMixin<T extends typeof OxyServicesBase>(Base: T) {
71
+ return class extends Base {
72
+ constructor(...args: any[]) {
73
+ super(...(args as [any]));
74
+ }
75
+
76
+ /** Service-token request, implemented by the auth mixin earlier in the pipeline. */
77
+ declare makeServiceRequest: <R = unknown>(
78
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
79
+ url: string,
80
+ data?: unknown,
81
+ userId?: string,
82
+ ) => Promise<R>;
83
+
84
+ /**
85
+ * Append a record to `oxyUserId`'s chain under `collection`/`rkey`.
86
+ *
87
+ * Oxy issues and signs it; the calling app never holds a chain signing key.
88
+ * `rkey` is the app's own id for the thing — reusing it later supersedes the
89
+ * earlier record for that key, which is how an edit works.
90
+ *
91
+ * Requires the `chains:write` scope AND `collection` falling under one of
92
+ * this application's granted `chainNamespaces`. Both are enforced by the
93
+ * server; a violation throws with a 403.
94
+ */
95
+ async appendChainRecord(params: {
96
+ oxyUserId: string;
97
+ collection: string;
98
+ rkey: string;
99
+ record: Record<string, unknown>;
100
+ }): Promise<AppendedChainRecord> {
101
+ return this.makeServiceRequest<AppendedChainRecord>('POST', '/chains/records', params);
102
+ }
103
+
104
+ /**
105
+ * Records published by any of `oxyUserIds` under any of `collections`,
106
+ * oldest first — the read a cross-app feed is projected from.
107
+ *
108
+ * Only collections Oxy declares PUBLIC come back, whatever is asked for; a
109
+ * private one yields nothing rather than an error.
110
+ *
111
+ * **Re-poll from slightly BEFORE your last cursor and dedupe by
112
+ * `recordId`.** The chain's pagination axis is a transaction-start
113
+ * timestamp, so a record can commit behind a cursor that already passed it.
114
+ * Re-delivering one costs bytes; skipping one costs a record that never
115
+ * appears. Projections are expected to be idempotent for exactly this
116
+ * reason.
117
+ */
118
+ async readChainRecords<TRecord = Record<string, unknown>>(params: {
119
+ oxyUserIds: readonly string[];
120
+ collections: readonly string[];
121
+ since?: string | null;
122
+ limit?: number;
123
+ }): Promise<ChainRecordPage<TRecord>> {
124
+ const query = new URLSearchParams({
125
+ authors: params.oxyUserIds.join(','),
126
+ collections: params.collections.join(','),
127
+ });
128
+ if (params.since) query.set('since', params.since);
129
+ if (params.limit !== undefined) query.set('limit', String(params.limit));
130
+
131
+ return this.makeServiceRequest<ChainRecordPage<TRecord>>('GET', `/chains/records?${query.toString()}`);
132
+ }
133
+ };
134
+ }
@@ -210,6 +210,30 @@ export function OxyServicesFollowGraphMixin<T extends typeof OxyServicesBase>(Ba
210
210
  }
211
211
  }
212
212
 
213
+ /**
214
+ * Release a namespace the calling application holds, when nothing is
215
+ registered inside it yet.
216
+ *
217
+ * Idempotent when the namespace is already unowned (`released: false`).
218
+ * Exists because claims are first-come and registration runs on boot — a
219
+ * development build with the wrong client id can bind a name permanently
220
+ * unless the holder can give it back.
221
+ */
222
+ async releaseFollowNamespace(
223
+ namespace: string,
224
+ ): Promise<{ namespace: string; released: boolean }> {
225
+ try {
226
+ return await this.makeRequest(
227
+ 'DELETE',
228
+ `/v2/follow-targets/namespaces/${encodeURIComponent(namespace)}`,
229
+ undefined,
230
+ { cache: false },
231
+ );
232
+ } catch (error) {
233
+ throw this.handleError(error);
234
+ }
235
+ }
236
+
213
237
  /**
214
238
  * Declare what following a kind of thing MEANS: the verb clients render,
215
239
  * whether reverse lookups are public, whether it federates.