@oxyhq/core 20.0.0 → 21.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/NOTICE +10 -9
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.utility.js +159 -104
- package/dist/cjs/mixins/index.js +7 -0
- package/dist/cjs/server/rateLimit.js +15 -6
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.utility.js +159 -104
- package/dist/esm/mixins/index.js +7 -0
- package/dist/esm/server/rateLimit.js +15 -6
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +15 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +105 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.utility.ts +161 -108
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/index.ts +9 -0
- package/src/models/session.ts +11 -0
- package/src/server/__tests__/rateLimit.test.ts +47 -0
- package/src/server/rateLimit.ts +18 -8
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
package/src/index.ts
CHANGED
|
@@ -49,7 +49,11 @@ export type {
|
|
|
49
49
|
CommonsDeliveryPlatform,
|
|
50
50
|
CommonsDeliveryRoute,
|
|
51
51
|
} from './utils/commonsDelivery';
|
|
52
|
-
export type {
|
|
52
|
+
export type {
|
|
53
|
+
ServiceTokenResponse,
|
|
54
|
+
OAuthUserInfoResponse,
|
|
55
|
+
OAuthTokenExchangeResult,
|
|
56
|
+
} from './mixins/OxyServices.auth';
|
|
53
57
|
// "Sign in with Oxy" — handoff (Workstream C)
|
|
54
58
|
export type {
|
|
55
59
|
CommonsSignInHandle,
|
|
@@ -116,6 +120,29 @@ export type {
|
|
|
116
120
|
ConnectedApp,
|
|
117
121
|
} from './mixins/OxyServices.connectedApps';
|
|
118
122
|
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// App store (public storefront + reviews + the listing a publisher edits)
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
export type {
|
|
127
|
+
StoreCategory,
|
|
128
|
+
StoreRating,
|
|
129
|
+
StoreListingSummary,
|
|
130
|
+
StoreListingDetail,
|
|
131
|
+
StoreScreenshot,
|
|
132
|
+
StoreScreenshotPlatform,
|
|
133
|
+
StoreReview,
|
|
134
|
+
StoreOwnReview,
|
|
135
|
+
WriteStoreReviewInput,
|
|
136
|
+
StoreListingStatus,
|
|
137
|
+
PublisherListing,
|
|
138
|
+
WriteListingInput,
|
|
139
|
+
AddScreenshotInput,
|
|
140
|
+
UpdateScreenshotInput,
|
|
141
|
+
StorePage,
|
|
142
|
+
StorePageOptions,
|
|
143
|
+
StoreReviewsOptions,
|
|
144
|
+
} from './mixins/OxyServices.store';
|
|
145
|
+
|
|
119
146
|
// ---------------------------------------------------------------------------
|
|
120
147
|
// Accounts (unified account graph: tree, membership, roles, bot credentials)
|
|
121
148
|
// plus the applications owned within it (Application = OAuth client).
|
|
@@ -230,6 +257,13 @@ export type {
|
|
|
230
257
|
} from './mixins/OxyServices.civic';
|
|
231
258
|
export type { UserNodeStatus, UserNodeMode, UserNodeController, UserNodeLivenessStatus, RegisterNodeInput, RemoveNodeResult } from './mixins/OxyServices.nodes';
|
|
232
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Chains — the shared per-person record log. `ChainRecord` is generic over the
|
|
262
|
+
* app's own lexicon payload, so a consumer types its records without Oxy
|
|
263
|
+
* knowing any app's schema.
|
|
264
|
+
*/
|
|
265
|
+
export type { ChainRecord, ChainRecordPage, AppendedChainRecord } from './mixins/OxyServices.chains';
|
|
266
|
+
|
|
233
267
|
// ---------------------------------------------------------------------------
|
|
234
268
|
// Auth helpers (token refresh, error normalisation, retry policies)
|
|
235
269
|
// ---------------------------------------------------------------------------
|
|
@@ -650,26 +684,55 @@ export {
|
|
|
650
684
|
accountIdsOf,
|
|
651
685
|
} from './session/projectSessionState';
|
|
652
686
|
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
687
|
+
// Pure projections over the device DIRECTORY (`GET /session/device/directory`,
|
|
688
|
+
// ADR 0002) — the read model that keeps the actor (the human who authenticated)
|
|
689
|
+
// and the subject (the account being acted as) apart. The flat
|
|
690
|
+
// `DeviceSessionState` collapses them into one row, so it can neither tell
|
|
691
|
+
// "signed in as an org" from "a person operating that org" nor hold two people
|
|
692
|
+
// reaching the same org on one device.
|
|
693
|
+
// `canActivateContext` is the switchability question — `available` alone, never
|
|
694
|
+
// composed with `onDevice`, which is a different fact in both directions.
|
|
695
|
+
// `projectDevicePrincipals` is the switcher's shape: people, each with what
|
|
696
|
+
// they may become. Grouped rather than flat because the same organization
|
|
697
|
+
// reached through two people is TWO rows under two humans, which a list keyed
|
|
698
|
+
// by account cannot say.
|
|
699
|
+
export {
|
|
700
|
+
canActivateContext,
|
|
701
|
+
directoryDisplayName,
|
|
702
|
+
directoryHandle,
|
|
703
|
+
projectDevicePrincipals,
|
|
704
|
+
resolveActiveContext,
|
|
705
|
+
resolveDeviceContext,
|
|
706
|
+
} from './session/deviceDirectory';
|
|
707
|
+
export type {
|
|
708
|
+
DeviceContext,
|
|
709
|
+
DeviceContextActor,
|
|
710
|
+
DeviceContextSubject,
|
|
711
|
+
DevicePrincipalGroup,
|
|
712
|
+
} from './session/deviceDirectory';
|
|
713
|
+
|
|
714
|
+
// The switcher's RENDER model over that projection — names, handles and avatar
|
|
715
|
+
// URLs resolved once. Shared by `@oxyhq/services`' account dialog and the
|
|
716
|
+
// auth.oxy.so chooser so the two cannot drift, the same reason the flat
|
|
717
|
+
// projection lived here before it.
|
|
718
|
+
export { buildSwitcherRows, showsPrincipalHeaders } from './session/deviceSwitcherRows';
|
|
719
|
+
export type {
|
|
720
|
+
ResolveAvatarUrl,
|
|
721
|
+
SwitcherContextRow,
|
|
722
|
+
SwitcherPrincipalRow,
|
|
723
|
+
} from './session/deviceSwitcherRows';
|
|
724
|
+
|
|
725
|
+
// The switch-target predicates over the account GRAPH — a list of accounts to
|
|
726
|
+
// manage, not the device's list of identities to become (that is the directory
|
|
727
|
+
// above). `isSwitchTargetAccount` is the structural half ("is this kind
|
|
728
|
+
// switchable at all?"); `canSwitchIntoAccount` adds the caller's
|
|
729
|
+
// `account:act_as` permission. Exported so the surfaces that render
|
|
730
|
+
// `AccountNode`s — the Console workspace switcher, managed-accounts rows — ask
|
|
731
|
+
// the SAME questions instead of testing a kind literal.
|
|
662
732
|
export {
|
|
663
733
|
isSwitchTargetAccount,
|
|
664
734
|
canSwitchIntoAccount,
|
|
665
|
-
|
|
666
|
-
switchableAccountIds,
|
|
667
|
-
} from './session/accountProjection';
|
|
668
|
-
export type {
|
|
669
|
-
SwitchableAccount,
|
|
670
|
-
SwitchableAccountUser,
|
|
671
|
-
ProjectSwitchableAccountsInput,
|
|
672
|
-
} from './session/accountProjection';
|
|
735
|
+
} from './session/accountSwitchTargets';
|
|
673
736
|
|
|
674
737
|
// Headless controller for the unified account dialog. Framework-agnostic
|
|
675
738
|
// state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
|
|
@@ -711,6 +774,30 @@ export type {
|
|
|
711
774
|
NativeKeyValueStorage,
|
|
712
775
|
} from './session/authStateStore';
|
|
713
776
|
|
|
777
|
+
// The shared NATIVE DeviceSession credential — how several official apps on one
|
|
778
|
+
// device end up on ONE `DeviceSession` and therefore one active context. It is an
|
|
779
|
+
// ordinary rotatable/revocable `deviceId` + `deviceSecret`, deliberately NOT the
|
|
780
|
+
// Commons private identity key: an app that only needs a session must never be
|
|
781
|
+
// handed the key that signs identity approvals.
|
|
782
|
+
export {
|
|
783
|
+
createSharedMirroringAuthStateStore,
|
|
784
|
+
decideSharedDeviceJoin,
|
|
785
|
+
decideSharedDevicePublish,
|
|
786
|
+
normalizeSharedDeviceSessionRead,
|
|
787
|
+
publishProvenDeviceCredential,
|
|
788
|
+
readLocalDeviceCredential,
|
|
789
|
+
} from './session/sharedDeviceCredential';
|
|
790
|
+
export type {
|
|
791
|
+
SharedDeviceCredential,
|
|
792
|
+
SharedDeviceCredentialRead,
|
|
793
|
+
SharedDeviceCredentialStore,
|
|
794
|
+
SharedDeviceJoinDecision,
|
|
795
|
+
SharedDeviceJoinSkipReason,
|
|
796
|
+
SharedDevicePublishDecision,
|
|
797
|
+
SharedDevicePublishOutcome,
|
|
798
|
+
SharedDevicePublishSkipReason,
|
|
799
|
+
} from './session/sharedDeviceCredential';
|
|
800
|
+
|
|
714
801
|
// Identity-bound sessions (the identity vault). The pin is the durable
|
|
715
802
|
// `{publicKey, accountId}` binding between this device's PRIMARY identity key
|
|
716
803
|
// and the account it authenticates as; it is what keeps such a client from
|
|
@@ -7,7 +7,6 @@ import type { User } from '../models/interfaces';
|
|
|
7
7
|
import type {
|
|
8
8
|
UserNameResponse,
|
|
9
9
|
LoginResult,
|
|
10
|
-
LoginSessionResult,
|
|
11
10
|
CommonsDenyReason,
|
|
12
11
|
} from '@oxyhq/contracts';
|
|
13
12
|
import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
|
|
@@ -74,6 +73,42 @@ export interface OAuthUserInfoResponse {
|
|
|
74
73
|
picture?: string;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The session an OAuth authorization-code exchange yields.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately NOT `LoginSessionResult`. That type mirrors the API's
|
|
80
|
+
* `buildSessionAuthResponse`, which every FIRST-PARTY sign-in lane emits, and it
|
|
81
|
+
* requires `deviceId` because those lanes always join the origin's DeviceSession.
|
|
82
|
+
* `POST /auth/oauth/token` is the RFC 6749 token endpoint and serves third
|
|
83
|
+
* parties, whose grant is deliberately ISOLATED: an untrusted application must be
|
|
84
|
+
* able to receive a session carrying NO DeviceSession credential at all.
|
|
85
|
+
*
|
|
86
|
+
* Both device fields are therefore optional here, and a response omitting them is
|
|
87
|
+
* a well-formed device-less grant rather than a malformed payload. What that
|
|
88
|
+
* costs the session is spelled out on `exchangeOAuthCode` below.
|
|
89
|
+
*/
|
|
90
|
+
export interface OAuthTokenExchangeResult {
|
|
91
|
+
sessionId: string;
|
|
92
|
+
/** ISO-8601 expiry of {@link accessToken}, derived from RFC 6749 `expires_in`. */
|
|
93
|
+
expiresAt: string;
|
|
94
|
+
accessToken?: string;
|
|
95
|
+
/**
|
|
96
|
+
* The DeviceSession this grant joined, when the server issued one. ABSENT for
|
|
97
|
+
* an isolated third-party grant — never assume a string.
|
|
98
|
+
*/
|
|
99
|
+
deviceId?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The zero-cookie mint credential for {@link deviceId}. Present only alongside
|
|
102
|
+
* it; absent for an isolated third-party grant.
|
|
103
|
+
*/
|
|
104
|
+
deviceSecret?: string;
|
|
105
|
+
user: {
|
|
106
|
+
id: string;
|
|
107
|
+
username?: string;
|
|
108
|
+
avatar?: string;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
// ===========================================================================
|
|
78
113
|
// "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
|
|
79
114
|
// ===========================================================================
|
|
@@ -1699,13 +1734,30 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1699
1734
|
* response this method used before were an Oxy invention no OAuth library
|
|
1700
1735
|
* could interoperate with; the endpoint no longer accepts them. The method's
|
|
1701
1736
|
* OWN signature is unchanged, so callers are unaffected.
|
|
1737
|
+
*
|
|
1738
|
+
* `deviceId` + `deviceSecret` are OPTIONAL and their absence is a valid
|
|
1739
|
+
* outcome, not an error. A third-party grant is meant to be isolated from the
|
|
1740
|
+
* browser's shared DeviceSession, so the token endpoint must be free to return
|
|
1741
|
+
* no device credential at all — the guard that used to require the pair made
|
|
1742
|
+
* that omission unshippable, since it turned every third-party sign-in through
|
|
1743
|
+
* the SDK into a silent `exchange-failed`.
|
|
1744
|
+
*
|
|
1745
|
+
* The cost is real and deliberate: a DEVICE-LESS session cannot use the
|
|
1746
|
+
* zero-cookie mint lane (`POST /session/device/token`), because that lane's
|
|
1747
|
+
* whole proof is possession of a `deviceSecret`. Its lifetime is therefore the
|
|
1748
|
+
* access token itself — nothing persists a restore credential, the cold boot's
|
|
1749
|
+
* `device-secret-mint` step reports `no-secret` and skips, and the refresh
|
|
1750
|
+
* scheduler has nothing to re-mint from. When the token expires the session
|
|
1751
|
+
* ends LOUDLY: the 401 lane clears the tokens and the provider resolves signed
|
|
1752
|
+
* out, so the app can run the OAuth flow again. It never degrades into a
|
|
1753
|
+
* session that looks alive and cannot refresh.
|
|
1702
1754
|
*/
|
|
1703
1755
|
async exchangeOAuthCode(params: {
|
|
1704
1756
|
code: string;
|
|
1705
1757
|
clientId: string;
|
|
1706
1758
|
redirectUri: string;
|
|
1707
1759
|
codeVerifier: string;
|
|
1708
|
-
}): Promise<
|
|
1760
|
+
}): Promise<OAuthTokenExchangeResult> {
|
|
1709
1761
|
try {
|
|
1710
1762
|
const form = new URLSearchParams({
|
|
1711
1763
|
grant_type: 'authorization_code',
|
|
@@ -1730,7 +1782,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1730
1782
|
const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
|
|
1731
1783
|
const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
|
|
1732
1784
|
const userRaw = record.user;
|
|
1733
|
-
|
|
1785
|
+
// The device pair is NOT part of this guard — see the note above. What is
|
|
1786
|
+
// still mandatory is what identifies the session at all.
|
|
1787
|
+
if (!sessionId || !userRaw || typeof userRaw !== 'object') {
|
|
1734
1788
|
throw new Error('auth/oauth/token returned an incomplete session payload');
|
|
1735
1789
|
}
|
|
1736
1790
|
const userObj = userRaw as Record<string, unknown>;
|
|
@@ -1744,12 +1798,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1744
1798
|
if (accessToken) {
|
|
1745
1799
|
this.setTokens(accessToken);
|
|
1746
1800
|
}
|
|
1801
|
+
if (!deviceId || !deviceSecret) {
|
|
1802
|
+
logger.debug(
|
|
1803
|
+
'auth/oauth/token returned no device credential — this session lives only as long as its access token',
|
|
1804
|
+
{ component: 'oxy.auth', method: 'exchangeOAuthCode' },
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1747
1807
|
return {
|
|
1748
1808
|
sessionId,
|
|
1749
|
-
deviceId,
|
|
1750
1809
|
expiresAt,
|
|
1751
1810
|
accessToken,
|
|
1752
|
-
|
|
1811
|
+
// Omitted rather than set to `undefined` when the server sent no device
|
|
1812
|
+
// credential, so a device-less grant serializes as the absence it is.
|
|
1813
|
+
...(deviceId ? { deviceId } : {}),
|
|
1814
|
+
...(deviceSecret ? { deviceSecret } : {}),
|
|
1753
1815
|
user: {
|
|
1754
1816
|
id: userId,
|
|
1755
1817
|
username: typeof userObj.username === 'string' ? userObj.username : undefined,
|
|
@@ -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
|
+
}
|