@flow-industries/id 0.26.3 → 0.26.5
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/README.md +38 -0
- package/contracts/v1/sdk-exports.json +95 -0
- package/dist/sdk/caller.d.ts +29 -0
- package/dist/sdk/caller.js +37 -0
- package/dist/sdk/client/atproto-resolver.d.ts +8 -0
- package/dist/sdk/client/atproto-resolver.js +87 -0
- package/dist/sdk/client/atproto.js +9 -1
- package/dist/sdk/client/create-flow.js +10 -0
- package/dist/sdk/client/index.d.ts +6 -1
- package/dist/sdk/client/index.js +4 -0
- package/dist/sdk/client/note-transport.d.ts +15 -0
- package/dist/sdk/client/note-transport.js +104 -0
- package/dist/sdk/client/passkey-prf.d.ts +8 -0
- package/dist/sdk/client/passkey-prf.js +67 -0
- package/dist/sdk/client/session.d.ts +2 -0
- package/dist/sdk/client/session.js +23 -0
- package/dist/sdk/client/static-flow.js +2 -0
- package/dist/sdk/db/invitations.d.ts +310 -0
- package/dist/sdk/db/invitations.js +42 -0
- package/dist/sdk/db/schema.d.ts +248 -0
- package/dist/sdk/db/schema.js +25 -0
- package/dist/sdk/session-view.d.ts +15 -0
- package/dist/sdk/session-view.js +32 -0
- package/dist/sdk/types/atproto-resolver.d.ts +46 -0
- package/dist/sdk/types/atproto-resolver.js +0 -0
- package/dist/sdk/types/atproto.d.ts +24 -0
- package/dist/sdk/types/auth.d.ts +4 -0
- package/dist/sdk/types/dialog.d.ts +3 -0
- package/dist/sdk/types/hosted-oauth.d.ts +23 -0
- package/dist/sdk/types/hosted-oauth.js +0 -0
- package/dist/sdk/types/hosting-quota.d.ts +28 -0
- package/dist/sdk/types/hosting-quota.js +0 -0
- package/dist/sdk/types/index.d.ts +7 -2
- package/dist/sdk/types/managed-provisioning.d.ts +51 -0
- package/dist/sdk/types/managed-provisioning.js +0 -0
- package/dist/sdk/types/note-transport.d.ts +140 -0
- package/dist/sdk/types/note-transport.js +0 -0
- package/dist/sdk/types/sdk.d.ts +7 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -23,6 +23,33 @@ partially migrated schema.
|
|
|
23
23
|
|
|
24
24
|
### AT Protocol identity
|
|
25
25
|
|
|
26
|
+
Consumers discover the signed-in user's current PDS through
|
|
27
|
+
`await flow.atproto.resolve({ freshness: "cached" })`. Use `"refresh"` after an
|
|
28
|
+
endpoint or authorization failure, and call `flow.atproto.invalidate()` after
|
|
29
|
+
identity or migration events. Flow account switches, logout and the first-party
|
|
30
|
+
linking helpers invalidate automatically. Invalidation forces the next request
|
|
31
|
+
to refresh. Discovery uses the configured Auth host, cookie or Flow bearer
|
|
32
|
+
session, and canonical user ID; it never provisions an account.
|
|
33
|
+
|
|
34
|
+
The result includes the DID, verified display handle (or null), current PDS
|
|
35
|
+
origin, hosting ownership, lifecycle and authorization status. DID documents
|
|
36
|
+
are authoritative for the endpoint. No OAuth tokens or private keys are
|
|
37
|
+
returned. `connected` indicates an unexpired stored grant whose audience and
|
|
38
|
+
issuer match public resource metadata; it does not prove that a remote grant
|
|
39
|
+
has not since been revoked. Writes must obtain fresh AT OAuth authorization,
|
|
40
|
+
and must not forward old credentials to a migrated host or replay mutations
|
|
41
|
+
with unknown outcomes. `AtprotoRequestError.code` distinguishes sign-in,
|
|
42
|
+
missing identity, pending or failed provisioning, account changes, unsupported
|
|
43
|
+
DID methods, invalid identity and unavailable resolution.
|
|
44
|
+
|
|
45
|
+
`ATPROTO_MANAGED_PDS_ORIGINS` is an optional comma-separated list of public
|
|
46
|
+
HTTPS PDS origins owned by Flow, used only to classify current hosting.
|
|
47
|
+
`ATPROTO_RESOLUTION_TTL_MS` enables a server metadata cache keyed by DID and
|
|
48
|
+
mapping version, bounded to five minutes; unset or invalid values disable
|
|
49
|
+
caching. Authorization status is checked separately on each request. A cached
|
|
50
|
+
authorization failure forces one fresh DID resolution. Public discovery works
|
|
51
|
+
without OAuth credential configuration and reports `reconnect_required`.
|
|
52
|
+
|
|
26
53
|
Bluesky and other AT Protocol accounts can sign in to Flow ID or connect to an
|
|
27
54
|
existing account. Flow user IDs remain stable; the provider DID identifies the
|
|
28
55
|
connection. New accounts do not need an email, passkey, or wallet to enroll.
|
|
@@ -223,3 +250,14 @@ still need complete request/response descriptions and generated client coverage
|
|
|
223
250
|
before the full HTTP contract work is complete.
|
|
224
251
|
|
|
225
252
|
Server deployments may set `FLOW_ID_API_URL` to a trusted internal HTTP(S) origin for API and JWKS requests, or pass `apiUrl` to server session/verification helpers. `issuerUrl` remains the public JWT issuer and browser navigation origin. The transport URL must contain no credentials, path, query, or fragment.
|
|
253
|
+
|
|
254
|
+
PDS discovery requires explicit nonnegative integer budgets in
|
|
255
|
+
`ATPROTO_RESOLUTION_USER_HOURLY_CAP` and
|
|
256
|
+
`ATPROTO_RESOLUTION_GLOBAL_HOURLY_CAP`. Unset, invalid or zero budgets refuse
|
|
257
|
+
outbound discovery with HTTP 429 and retryable `RESOLUTION_UNAVAILABLE`. Every discovery
|
|
258
|
+
request, including cached reads and forced refreshes, consumes durable
|
|
259
|
+
per-user and fleet-wide hourly admission before any provider request. Failed
|
|
260
|
+
outbound attempts retain admission consumption; counters use the existing
|
|
261
|
+
bounded verification retention and canonical account erasure mechanism.
|
|
262
|
+
These caps bound request admission across replicas; operators must choose
|
|
263
|
+
budgets appropriate to replica count, outbound latency and capacity.
|
|
@@ -92,6 +92,31 @@
|
|
|
92
92
|
"type": false,
|
|
93
93
|
"value": true
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
"name": "AtprotoApi",
|
|
97
|
+
"type": true,
|
|
98
|
+
"value": false
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "AtprotoRequestError",
|
|
102
|
+
"type": true,
|
|
103
|
+
"value": true
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "AtprotoResolution",
|
|
107
|
+
"type": true,
|
|
108
|
+
"value": false
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "AtprotoResolutionFailure",
|
|
112
|
+
"type": true,
|
|
113
|
+
"value": false
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "AtprotoResolveOptions",
|
|
117
|
+
"type": true,
|
|
118
|
+
"value": false
|
|
119
|
+
},
|
|
95
120
|
{
|
|
96
121
|
"name": "BrowserAccount",
|
|
97
122
|
"type": true,
|
|
@@ -142,6 +167,11 @@
|
|
|
142
167
|
"type": false,
|
|
143
168
|
"value": true
|
|
144
169
|
},
|
|
170
|
+
{
|
|
171
|
+
"name": "createAtprotoApi",
|
|
172
|
+
"type": false,
|
|
173
|
+
"value": true
|
|
174
|
+
},
|
|
145
175
|
{
|
|
146
176
|
"name": "createDialogHost",
|
|
147
177
|
"type": false,
|
|
@@ -167,6 +197,11 @@
|
|
|
167
197
|
"type": false,
|
|
168
198
|
"value": true
|
|
169
199
|
},
|
|
200
|
+
{
|
|
201
|
+
"name": "createNoteTransport",
|
|
202
|
+
"type": false,
|
|
203
|
+
"value": true
|
|
204
|
+
},
|
|
170
205
|
{
|
|
171
206
|
"name": "createProfileButton",
|
|
172
207
|
"type": false,
|
|
@@ -222,6 +257,11 @@
|
|
|
222
257
|
"type": false,
|
|
223
258
|
"value": true
|
|
224
259
|
},
|
|
260
|
+
{
|
|
261
|
+
"name": "evaluatePasskeyPrf",
|
|
262
|
+
"type": false,
|
|
263
|
+
"value": true
|
|
264
|
+
},
|
|
225
265
|
{
|
|
226
266
|
"name": "fieldsCoveredBySubjects",
|
|
227
267
|
"type": false,
|
|
@@ -317,6 +357,11 @@
|
|
|
317
357
|
"type": false,
|
|
318
358
|
"value": true
|
|
319
359
|
},
|
|
360
|
+
{
|
|
361
|
+
"name": "getConnectedPasskey",
|
|
362
|
+
"type": false,
|
|
363
|
+
"value": true
|
|
364
|
+
},
|
|
320
365
|
{
|
|
321
366
|
"name": "getFlow",
|
|
322
367
|
"type": false,
|
|
@@ -397,6 +442,46 @@
|
|
|
397
442
|
"type": false,
|
|
398
443
|
"value": true
|
|
399
444
|
},
|
|
445
|
+
{
|
|
446
|
+
"name": "NoteBlob",
|
|
447
|
+
"type": true,
|
|
448
|
+
"value": false
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
"name": "NoteCollection",
|
|
452
|
+
"type": true,
|
|
453
|
+
"value": false
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"name": "NoteOperation",
|
|
457
|
+
"type": true,
|
|
458
|
+
"value": false
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
"name": "NoteRecord",
|
|
462
|
+
"type": true,
|
|
463
|
+
"value": false
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
"name": "NoteResponses",
|
|
467
|
+
"type": true,
|
|
468
|
+
"value": false
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"name": "NoteStoredRecord",
|
|
472
|
+
"type": true,
|
|
473
|
+
"value": false
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
"name": "NoteTransportError",
|
|
477
|
+
"type": true,
|
|
478
|
+
"value": true
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
"name": "NoteVaultHeader",
|
|
482
|
+
"type": true,
|
|
483
|
+
"value": false
|
|
484
|
+
},
|
|
400
485
|
{
|
|
401
486
|
"name": "NotificationCounts",
|
|
402
487
|
"type": true,
|
|
@@ -432,6 +517,16 @@
|
|
|
432
517
|
"type": true,
|
|
433
518
|
"value": false
|
|
434
519
|
},
|
|
520
|
+
{
|
|
521
|
+
"name": "PasskeyPrfError",
|
|
522
|
+
"type": true,
|
|
523
|
+
"value": true
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
"name": "PasskeyPrfOptions",
|
|
527
|
+
"type": true,
|
|
528
|
+
"value": false
|
|
529
|
+
},
|
|
435
530
|
{
|
|
436
531
|
"name": "ProfileButtonHandle",
|
|
437
532
|
"type": true,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AppDb } from "./db/handle";
|
|
2
|
+
import type { SessionUser } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Caller-identity core, shared by the dialog-facing routes and the tests. Takes
|
|
5
|
+
* the drizzle handle as a parameter (postgres-js in the server, PGlite in
|
|
6
|
+
* tests) instead of importing the singleton, which requires DATABASE_URL at
|
|
7
|
+
* import time. The singleton-bound entry point is `resolveCaller` in
|
|
8
|
+
* `routes/caller.ts`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The user behind a dialog request, and the better-auth session row that
|
|
12
|
+
* authenticated it — null when the caller presented an access JWT instead,
|
|
13
|
+
* since a bearer holds no session row.
|
|
14
|
+
*/
|
|
15
|
+
/** The session row as callers read it: opaque beyond the fields session-view names. */
|
|
16
|
+
export type CallerSession = {
|
|
17
|
+
id: string;
|
|
18
|
+
audience?: string | null;
|
|
19
|
+
};
|
|
20
|
+
export type Caller = {
|
|
21
|
+
user: SessionUser;
|
|
22
|
+
session: CallerSession | null;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Reads the user a verified bearer's `sub` names, in the shape better-auth puts
|
|
26
|
+
* on `session.user`. Null when the subject no longer exists — a token for a
|
|
27
|
+
* deleted account is unauthorized, not a caller with an empty profile.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadCallerUser(dbi: AppDb, userId: string): Promise<SessionUser | null>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import * as schema from "./db/schema";
|
|
3
|
+
import { sessionUserOf } from "./session-view";
|
|
4
|
+
/**
|
|
5
|
+
* The columns better-auth puts on `session.user` (base fields plus the
|
|
6
|
+
* configured `additionalFields`), so a bearer-authenticated caller reads back
|
|
7
|
+
* the same user shape a cookie-authenticated one does. `sessionEpoch` is
|
|
8
|
+
* deliberately absent: it is an internal revocation lever, not profile data.
|
|
9
|
+
*/
|
|
10
|
+
const USER_COLUMNS = {
|
|
11
|
+
id: schema.user.id,
|
|
12
|
+
name: schema.user.name,
|
|
13
|
+
username: schema.user.username,
|
|
14
|
+
email: schema.user.email,
|
|
15
|
+
emailVerified: schema.user.emailVerified,
|
|
16
|
+
image: schema.user.image,
|
|
17
|
+
bio: schema.user.bio,
|
|
18
|
+
isGuest: schema.user.isGuest,
|
|
19
|
+
role: schema.user.role,
|
|
20
|
+
walletAddress: schema.user.walletAddress,
|
|
21
|
+
subscribedToNews: schema.user.subscribedToNews,
|
|
22
|
+
createdAt: schema.user.createdAt,
|
|
23
|
+
updatedAt: schema.user.updatedAt,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Reads the user a verified bearer's `sub` names, in the shape better-auth puts
|
|
27
|
+
* on `session.user`. Null when the subject no longer exists — a token for a
|
|
28
|
+
* deleted account is unauthorized, not a caller with an empty profile.
|
|
29
|
+
*/
|
|
30
|
+
export async function loadCallerUser(dbi, userId) {
|
|
31
|
+
const [user] = await dbi
|
|
32
|
+
.select(USER_COLUMNS)
|
|
33
|
+
.from(schema.user)
|
|
34
|
+
.where(eq(schema.user.id, userId))
|
|
35
|
+
.limit(1);
|
|
36
|
+
return user ? sessionUserOf(user) : null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AtprotoApi, AtprotoResolutionFailure } from "../types/atproto";
|
|
2
|
+
export declare class AtprotoRequestError extends Error {
|
|
3
|
+
readonly code: AtprotoResolutionFailure;
|
|
4
|
+
readonly retryable: boolean;
|
|
5
|
+
constructor(code: AtprotoResolutionFailure, retryable?: boolean);
|
|
6
|
+
}
|
|
7
|
+
/** Identity changes and migration completion should invalidate before another discovery. */
|
|
8
|
+
export declare function createAtprotoApi(host: string, getToken: () => Promise<string | null>, getUserId: () => string | null): AtprotoApi;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const failureSchema = z.enum([
|
|
3
|
+
"SIGN_IN_REQUIRED",
|
|
4
|
+
"NO_AT_IDENTITY",
|
|
5
|
+
"PROVISIONING_PENDING",
|
|
6
|
+
"PROVISIONING_FAILED",
|
|
7
|
+
"ACCOUNT_CHANGED",
|
|
8
|
+
"UNSUPPORTED_DID_METHOD",
|
|
9
|
+
"IDENTITY_INVALID",
|
|
10
|
+
"RESOLUTION_UNAVAILABLE",
|
|
11
|
+
]);
|
|
12
|
+
const resolutionSchema = z.object({
|
|
13
|
+
flowUserId: z.string(),
|
|
14
|
+
did: z.string(),
|
|
15
|
+
handle: z.string().nullable(),
|
|
16
|
+
pdsUrl: z.url(),
|
|
17
|
+
hosting: z.enum(["flow", "external"]),
|
|
18
|
+
lifecycle: z.enum(["active", "suspended", "migrated", "deletion_pending"]),
|
|
19
|
+
authorization: z.enum(["connected", "reconnect_required"]),
|
|
20
|
+
resolvedAt: z.iso.datetime(),
|
|
21
|
+
expiresAt: z.iso.datetime(),
|
|
22
|
+
identityVersion: z.string(),
|
|
23
|
+
});
|
|
24
|
+
export class AtprotoRequestError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
retryable;
|
|
27
|
+
constructor(code, retryable = false) {
|
|
28
|
+
super(code);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.retryable = retryable;
|
|
31
|
+
this.name = "AtprotoRequestError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Identity changes and migration completion should invalidate before another discovery. */
|
|
35
|
+
export function createAtprotoApi(host, getToken, getUserId) {
|
|
36
|
+
let generation = 0;
|
|
37
|
+
let dirty = false;
|
|
38
|
+
return {
|
|
39
|
+
invalidate() {
|
|
40
|
+
generation++;
|
|
41
|
+
dirty = true;
|
|
42
|
+
},
|
|
43
|
+
async resolve(options = {}) {
|
|
44
|
+
options.signal?.throwIfAborted();
|
|
45
|
+
const userId = getUserId();
|
|
46
|
+
if (!userId)
|
|
47
|
+
throw new AtprotoRequestError("SIGN_IN_REQUIRED");
|
|
48
|
+
const requestGeneration = generation;
|
|
49
|
+
const unchanged = () => {
|
|
50
|
+
if (generation !== requestGeneration || getUserId() !== userId)
|
|
51
|
+
throw new AtprotoRequestError("ACCOUNT_CHANGED");
|
|
52
|
+
};
|
|
53
|
+
const token = await getToken();
|
|
54
|
+
unchanged();
|
|
55
|
+
const headers = new Headers({ "X-Flow-User-Id": userId });
|
|
56
|
+
if (token)
|
|
57
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(`${host}/api/atproto/resolve?freshness=${dirty ? "refresh" : (options.freshness ?? "cached")}`, { headers, credentials: "include", signal: options.signal });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
options.signal?.throwIfAborted();
|
|
64
|
+
unchanged();
|
|
65
|
+
throw new AtprotoRequestError("RESOLUTION_UNAVAILABLE", true);
|
|
66
|
+
}
|
|
67
|
+
unchanged();
|
|
68
|
+
const body = await response.json().catch(() => null);
|
|
69
|
+
options.signal?.throwIfAborted();
|
|
70
|
+
unchanged();
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const parsed = z.object({ code: failureSchema }).safeParse(body);
|
|
73
|
+
const code = parsed.success
|
|
74
|
+
? parsed.data.code
|
|
75
|
+
: "RESOLUTION_UNAVAILABLE";
|
|
76
|
+
throw new AtprotoRequestError(code, ["RESOLUTION_UNAVAILABLE", "PROVISIONING_PENDING"].includes(code));
|
|
77
|
+
}
|
|
78
|
+
const parsed = resolutionSchema.safeParse(body);
|
|
79
|
+
if (!parsed.success)
|
|
80
|
+
throw new AtprotoRequestError("IDENTITY_INVALID");
|
|
81
|
+
if (parsed.data.flowUserId !== userId)
|
|
82
|
+
throw new AtprotoRequestError("ACCOUNT_CHANGED");
|
|
83
|
+
dirty = false;
|
|
84
|
+
return parsed.data;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getFlow } from "./create-flow";
|
|
1
2
|
async function readResponse(response) {
|
|
2
3
|
const body = await response.json();
|
|
3
4
|
if (!response.ok)
|
|
@@ -17,6 +18,7 @@ export async function unlinkAtproto(expectedUserId) {
|
|
|
17
18
|
headers: { "Content-Type": "application/json" },
|
|
18
19
|
body: JSON.stringify({ expectedUserId }),
|
|
19
20
|
}));
|
|
21
|
+
getFlow()?.atproto.invalidate();
|
|
20
22
|
}
|
|
21
23
|
const completionKey = "flow.atproto.completion";
|
|
22
24
|
export async function connectAtproto(request, signal) {
|
|
@@ -30,6 +32,7 @@ export async function connectAtproto(request, signal) {
|
|
|
30
32
|
}));
|
|
31
33
|
signal.throwIfAborted();
|
|
32
34
|
sessionStorage.setItem(completionKey, result.completionToken);
|
|
35
|
+
getFlow()?.atproto.invalidate();
|
|
33
36
|
window.location.assign(result.url);
|
|
34
37
|
}
|
|
35
38
|
let completion = null;
|
|
@@ -44,6 +47,11 @@ export function completeAtproto() {
|
|
|
44
47
|
credentials: "include",
|
|
45
48
|
headers: { "Content-Type": "application/json" },
|
|
46
49
|
body: JSON.stringify({ completionToken }),
|
|
47
|
-
})
|
|
50
|
+
})
|
|
51
|
+
.then((readResponse))
|
|
52
|
+
.then((result) => {
|
|
53
|
+
getFlow()?.atproto.invalidate();
|
|
54
|
+
return result;
|
|
55
|
+
});
|
|
48
56
|
return completion;
|
|
49
57
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BROWSER_FLOW_TTL_MS } from "../browser-contract";
|
|
2
2
|
import { DEFAULT_SESSION_PATH, isLocalHostname, resolveIdHost, } from "../id-host";
|
|
3
3
|
import { isExpiring } from "../token-expiry";
|
|
4
|
+
import { createAtprotoApi } from "./atproto-resolver";
|
|
4
5
|
import { createDialogHost } from "./dialog-host";
|
|
5
6
|
import { idb } from "./idb";
|
|
6
7
|
import { createRoomsApi } from "./rooms";
|
|
@@ -510,6 +511,14 @@ export function createFlow(options = {}) {
|
|
|
510
511
|
};
|
|
511
512
|
window.addEventListener("focus", refreshOnFocus);
|
|
512
513
|
document.addEventListener("visibilitychange", refreshOnFocus);
|
|
514
|
+
const atproto = createAtprotoApi(host, getToken, () => store.getSnapshot().user?.id ?? null);
|
|
515
|
+
let resolverUserId = store.getSnapshot().user?.id ?? null;
|
|
516
|
+
store.subscribe((state) => {
|
|
517
|
+
const userId = state.user?.id ?? null;
|
|
518
|
+
if (userId !== resolverUserId)
|
|
519
|
+
atproto.invalidate();
|
|
520
|
+
resolverUserId = userId;
|
|
521
|
+
});
|
|
513
522
|
const flow = {
|
|
514
523
|
get user() {
|
|
515
524
|
return store.getSnapshot().user;
|
|
@@ -541,6 +550,7 @@ export function createFlow(options = {}) {
|
|
|
541
550
|
getToken,
|
|
542
551
|
mintAudience,
|
|
543
552
|
rooms: createRoomsApi(host, getToken),
|
|
553
|
+
atproto,
|
|
544
554
|
signMessage: async (args) => {
|
|
545
555
|
const mod = await import("./signing");
|
|
546
556
|
return mod.signMessage(buildSigningContext(), args);
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
-
export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, BrowserAccount, BrowserAccounts, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MarkNotificationsReadInput, MethodName, MountFlowWidgetOptions, MountProfileOptions, NotificationCounts, NotificationEntry, NotificationPage, NotificationReadResult, NotificationRefusal, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, SwitchAccountOptions, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
|
|
2
|
+
export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, AtprotoApi, AtprotoResolution, AtprotoResolutionFailure, AtprotoResolveOptions, BrowserAccount, BrowserAccounts, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MarkNotificationsReadInput, MethodName, MountFlowWidgetOptions, MountProfileOptions, NotificationCounts, NotificationEntry, NotificationPage, NotificationReadResult, NotificationRefusal, OpenProfileOptions, PasskeyPrfOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, SwitchAccountOptions, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
|
|
3
|
+
export type { NoteBlob, NoteCollection, NoteOperation, NoteRecord, NoteResponses, NoteStoredRecord, NoteVaultHeader, } from "../types/note-transport";
|
|
3
4
|
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
4
5
|
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
6
|
+
export { AtprotoRequestError, createAtprotoApi } from "./atproto-resolver";
|
|
5
7
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
6
8
|
export { createDialogHost } from "./dialog-host";
|
|
7
9
|
export { createFlowWidget } from "./flow-widget";
|
|
8
10
|
export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
|
|
9
11
|
export { METHODS } from "./methods";
|
|
12
|
+
export { createNoteTransport, NoteTransportError } from "./note-transport";
|
|
10
13
|
export { closeProfile, openProfile } from "./open-profile";
|
|
14
|
+
export { evaluatePasskeyPrf, PasskeyPrfError } from "./passkey-prf";
|
|
11
15
|
export { createProfileButton } from "./profile-button";
|
|
12
16
|
export type { ReportOperation, ReportRequest, ReportResponse } from "./reports";
|
|
13
17
|
export { createReportsApi, ReportRequestError } from "./reports";
|
|
14
18
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
19
|
+
export { getConnectedPasskey } from "./session";
|
|
15
20
|
export { createStaticFlow } from "./static-flow";
|
|
16
21
|
export { createStudyApi, StudyRequestError } from "./study";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
2
|
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
3
3
|
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
4
|
+
export { AtprotoRequestError, createAtprotoApi } from "./atproto-resolver";
|
|
4
5
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
5
6
|
export { createDialogHost } from "./dialog-host";
|
|
6
7
|
export { createFlowWidget } from "./flow-widget";
|
|
7
8
|
export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
|
|
8
9
|
export { METHODS } from "./methods";
|
|
10
|
+
export { createNoteTransport, NoteTransportError } from "./note-transport";
|
|
9
11
|
export { closeProfile, openProfile } from "./open-profile";
|
|
12
|
+
export { evaluatePasskeyPrf, PasskeyPrfError } from "./passkey-prf";
|
|
10
13
|
export { createProfileButton } from "./profile-button";
|
|
11
14
|
export { createReportsApi, ReportRequestError } from "./reports";
|
|
12
15
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
16
|
+
export { getConnectedPasskey } from "./session";
|
|
13
17
|
export { createStaticFlow } from "./static-flow";
|
|
14
18
|
export { createStudyApi, StudyRequestError } from "./study";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AtprotoApi, AtprotoResolution } from "../types/atproto";
|
|
2
|
+
import type { NoteBlob, NoteOperation, NoteResponses } from "../types/note-transport";
|
|
3
|
+
export declare class NoteTransportError extends Error {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(code: string, status: number);
|
|
7
|
+
}
|
|
8
|
+
/** Ciphertext-only Auth transport; the browser never receives provider tokens or DPoP keys. */
|
|
9
|
+
export declare function createNoteTransport(host: string, getToken: () => Promise<string | null>, getUserId: () => string | null, getSessionVersion: () => number, resolver: AtprotoApi, expectedIdentity: Pick<AtprotoResolution, "did" | "identityVersion">): {
|
|
10
|
+
records<T extends NoteOperation>(operation: T, signal?: AbortSignal): Promise<NoteResponses[T["method"]]>;
|
|
11
|
+
download(cid: string, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>;
|
|
12
|
+
upload(bytes: Uint8Array<ArrayBuffer>, signal?: AbortSignal): Promise<{
|
|
13
|
+
blob: NoteBlob;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export class NoteTransportError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(code, status) {
|
|
6
|
+
super(code);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = "NoteTransportError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Ciphertext-only Auth transport; the browser never receives provider tokens or DPoP keys. */
|
|
13
|
+
export function createNoteTransport(host, getToken, getUserId, getSessionVersion, resolver, expectedIdentity) {
|
|
14
|
+
const pinnedIdentity = { ...expectedIdentity };
|
|
15
|
+
async function request(action, body, signal) {
|
|
16
|
+
signal?.throwIfAborted();
|
|
17
|
+
const userId = getUserId();
|
|
18
|
+
const version = getSessionVersion();
|
|
19
|
+
if (!userId)
|
|
20
|
+
throw new NoteTransportError("SIGN_IN_REQUIRED", 401);
|
|
21
|
+
const unchanged = () => {
|
|
22
|
+
if (getUserId() !== userId || getSessionVersion() !== version)
|
|
23
|
+
throw new NoteTransportError("ACCOUNT_CHANGED", 409);
|
|
24
|
+
};
|
|
25
|
+
const resolution = await resolver.resolve({ freshness: "refresh", signal });
|
|
26
|
+
unchanged();
|
|
27
|
+
if (resolution.did !== pinnedIdentity.did ||
|
|
28
|
+
resolution.identityVersion !== pinnedIdentity.identityVersion) {
|
|
29
|
+
resolver.invalidate();
|
|
30
|
+
throw new NoteTransportError("ACCOUNT_CHANGED", 409);
|
|
31
|
+
}
|
|
32
|
+
if (resolution.lifecycle !== "active" ||
|
|
33
|
+
resolution.authorization !== "connected")
|
|
34
|
+
throw new NoteTransportError("RECONNECT_REQUIRED", 403);
|
|
35
|
+
const token = await getToken();
|
|
36
|
+
unchanged();
|
|
37
|
+
if (!token)
|
|
38
|
+
throw new NoteTransportError("SIGN_IN_REQUIRED", 401);
|
|
39
|
+
let response;
|
|
40
|
+
try {
|
|
41
|
+
response = await fetch(`${host}/api/atproto/note/${action}`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
"X-Flow-User-Id": userId,
|
|
46
|
+
"X-Flow-At-Did": pinnedIdentity.did,
|
|
47
|
+
"X-Flow-Identity-Version": pinnedIdentity.identityVersion,
|
|
48
|
+
"Content-Type": action === "upload"
|
|
49
|
+
? "application/octet-stream"
|
|
50
|
+
: "application/json",
|
|
51
|
+
},
|
|
52
|
+
body,
|
|
53
|
+
signal,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
unchanged();
|
|
59
|
+
throw new NoteTransportError("PDS_UNAVAILABLE", 503);
|
|
60
|
+
}
|
|
61
|
+
const data = action === "download" && response.ok
|
|
62
|
+
? new Uint8Array(await response.arrayBuffer())
|
|
63
|
+
: await response.json().catch(() => null);
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
unchanged();
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const parsed = z.object({ code: z.string() }).safeParse(data);
|
|
68
|
+
const code = parsed.success ? parsed.data.code : "PDS_UNAVAILABLE";
|
|
69
|
+
if (code === "ACCOUNT_CHANGED" || code === "RECONNECT_REQUIRED")
|
|
70
|
+
resolver.invalidate();
|
|
71
|
+
throw new NoteTransportError(code, response.status);
|
|
72
|
+
}
|
|
73
|
+
const after = await resolver.resolve({ freshness: "refresh", signal });
|
|
74
|
+
unchanged();
|
|
75
|
+
if (after.did !== pinnedIdentity.did ||
|
|
76
|
+
after.identityVersion !== pinnedIdentity.identityVersion) {
|
|
77
|
+
resolver.invalidate();
|
|
78
|
+
throw new NoteTransportError("ACCOUNT_CHANGED", 409);
|
|
79
|
+
}
|
|
80
|
+
if (data instanceof Uint8Array)
|
|
81
|
+
return new Uint8Array(data);
|
|
82
|
+
const parsed = z.record(z.string(), z.unknown()).safeParse(data);
|
|
83
|
+
if (!parsed.success)
|
|
84
|
+
throw new NoteTransportError("INVALID_RESPONSE", 502);
|
|
85
|
+
// SAFETY: Auth validates method-specific ciphertext schemas and strips provider secrets; the SDK preserves those named contracts.
|
|
86
|
+
return parsed.data;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
async records(operation, signal) {
|
|
90
|
+
// SAFETY: Auth validates the selected operation's named response schema.
|
|
91
|
+
return (await request("records", JSON.stringify(operation), signal));
|
|
92
|
+
},
|
|
93
|
+
async download(cid, signal) {
|
|
94
|
+
const result = await request("download", JSON.stringify({ cid }), signal);
|
|
95
|
+
if (!(result instanceof Uint8Array))
|
|
96
|
+
throw new NoteTransportError("INVALID_RESPONSE", 502);
|
|
97
|
+
return result;
|
|
98
|
+
},
|
|
99
|
+
async upload(bytes, signal) {
|
|
100
|
+
// SAFETY: Auth validates the upload response as a single standard octet-stream blob reference.
|
|
101
|
+
return (await request("upload", bytes, signal));
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FlowCredential } from "../types/auth";
|
|
2
|
+
import type { PasskeyPrfOptions } from "../types/sdk";
|
|
3
|
+
export declare class PasskeyPrfError extends Error {
|
|
4
|
+
readonly code: "PASSKEY_REQUIRED" | "PRF_UNAVAILABLE" | "ACCOUNT_CHANGED" | "INVALID_INPUT";
|
|
5
|
+
constructor(code: "PASSKEY_REQUIRED" | "PRF_UNAVAILABLE" | "ACCOUNT_CHANGED" | "INVALID_INPUT");
|
|
6
|
+
}
|
|
7
|
+
/** Evaluate only the connected passkey. Output is client-only key material: never serialize or log it. */
|
|
8
|
+
export declare function evaluatePasskeyPrf(getCredential: () => FlowCredential | null, getUserId: () => string | null, getSessionVersion: () => number, options: PasskeyPrfOptions): Promise<Uint8Array<ArrayBuffer>>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export class PasskeyPrfError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code) {
|
|
4
|
+
super(code);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "PasskeyPrfError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Evaluate only the connected passkey. Output is client-only key material: never serialize or log it. */
|
|
10
|
+
export async function evaluatePasskeyPrf(getCredential, getUserId, getSessionVersion, options) {
|
|
11
|
+
options.signal?.throwIfAborted();
|
|
12
|
+
const credential = getCredential();
|
|
13
|
+
const userId = getUserId();
|
|
14
|
+
const sessionVersion = getSessionVersion();
|
|
15
|
+
if (!credential || !userId)
|
|
16
|
+
throw new PasskeyPrfError("PASSKEY_REQUIRED");
|
|
17
|
+
if (options.input.byteLength !== 32 || !options.rpId)
|
|
18
|
+
throw new PasskeyPrfError("INVALID_INPUT");
|
|
19
|
+
let id;
|
|
20
|
+
try {
|
|
21
|
+
if (!/^[A-Za-z0-9_-]+$/.test(credential.id))
|
|
22
|
+
throw new Error();
|
|
23
|
+
id = Uint8Array.from(atob(credential.id.replace(/-/g, "+").replace(/_/g, "/")), (character) => character.charCodeAt(0));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new PasskeyPrfError("INVALID_INPUT");
|
|
27
|
+
}
|
|
28
|
+
const unchanged = () => {
|
|
29
|
+
if (getSessionVersion() !== sessionVersion ||
|
|
30
|
+
getUserId() !== userId ||
|
|
31
|
+
getCredential()?.id !== credential.id)
|
|
32
|
+
throw new PasskeyPrfError("ACCOUNT_CHANGED");
|
|
33
|
+
};
|
|
34
|
+
if (!globalThis.navigator?.credentials || !globalThis.PublicKeyCredential)
|
|
35
|
+
throw new PasskeyPrfError("PRF_UNAVAILABLE");
|
|
36
|
+
let assertion;
|
|
37
|
+
try {
|
|
38
|
+
assertion = await navigator.credentials.get({
|
|
39
|
+
publicKey: {
|
|
40
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
41
|
+
rpId: options.rpId,
|
|
42
|
+
allowCredentials: [{ type: "public-key", id }],
|
|
43
|
+
userVerification: "required",
|
|
44
|
+
timeout: 60_000,
|
|
45
|
+
extensions: { prf: { eval: { first: options.input.slice().buffer } } },
|
|
46
|
+
},
|
|
47
|
+
signal: options.signal,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
options.signal?.throwIfAborted();
|
|
52
|
+
unchanged();
|
|
53
|
+
throw new PasskeyPrfError("PRF_UNAVAILABLE");
|
|
54
|
+
}
|
|
55
|
+
options.signal?.throwIfAborted();
|
|
56
|
+
unchanged();
|
|
57
|
+
if (!(assertion instanceof PublicKeyCredential))
|
|
58
|
+
throw new PasskeyPrfError("PRF_UNAVAILABLE");
|
|
59
|
+
const actualId = new Uint8Array(assertion.rawId);
|
|
60
|
+
if (actualId.length !== id.length ||
|
|
61
|
+
actualId.some((byte, i) => byte !== id[i]))
|
|
62
|
+
throw new PasskeyPrfError("PRF_UNAVAILABLE");
|
|
63
|
+
const result = assertion.getClientExtensionResults().prf?.results?.first;
|
|
64
|
+
if (!(result instanceof ArrayBuffer) || result.byteLength !== 32)
|
|
65
|
+
throw new PasskeyPrfError("PRF_UNAVAILABLE");
|
|
66
|
+
return new Uint8Array(result.slice(0));
|
|
67
|
+
}
|