@hypercerts-org/sdk-core 0.10.0-beta.3 → 0.10.0-beta.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/CHANGELOG.md +169 -0
- package/README.md +136 -6
- package/dist/index.cjs +2566 -288
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1919 -172
- package/dist/index.mjs +3579 -1331
- package/dist/index.mjs.map +1 -1
- package/dist/lexicons.cjs +446 -34
- package/dist/lexicons.cjs.map +1 -1
- package/dist/lexicons.d.ts +248 -8
- package/dist/lexicons.mjs +422 -11
- package/dist/lexicons.mjs.map +1 -1
- package/dist/testing.d.ts +56 -9
- package/dist/types.cjs +89 -9
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.ts +387 -79
- package/dist/types.mjs +89 -9
- package/dist/types.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -3,9 +3,41 @@
|
|
|
3
3
|
var oauthClientNode = require('@atproto/oauth-client-node');
|
|
4
4
|
var zod = require('zod');
|
|
5
5
|
var api = require('@atproto/api');
|
|
6
|
-
var
|
|
6
|
+
var lexicon$1 = require('@atproto/lexicon');
|
|
7
7
|
var lexicon = require('@hypercerts-org/lexicon');
|
|
8
|
-
require('
|
|
8
|
+
var eventemitter3 = require('eventemitter3');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Type guard to check if a URL is a loopback address.
|
|
12
|
+
*
|
|
13
|
+
* Loopback addresses are used for local development and testing.
|
|
14
|
+
* They include localhost, 127.0.0.1, and [::1] (IPv6 loopback).
|
|
15
|
+
*
|
|
16
|
+
* @param url - The URL to check
|
|
17
|
+
* @returns True if the URL is a loopback address
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* isLoopbackUrl('http://localhost:3000'); // true
|
|
22
|
+
* isLoopbackUrl('http://127.0.0.1:8080'); // true
|
|
23
|
+
* isLoopbackUrl('http://[::1]:3000'); // true
|
|
24
|
+
* isLoopbackUrl('http://example.com'); // false
|
|
25
|
+
* isLoopbackUrl('https://localhost:3000'); // false (must be http)
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
function isLoopbackUrl(url) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new URL(url);
|
|
31
|
+
if (parsed.protocol !== "http:") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
35
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
9
41
|
|
|
10
42
|
/**
|
|
11
43
|
* Base error class for all SDK errors.
|
|
@@ -599,24 +631,47 @@ const IdentityAttrSchema = zod.z.enum(["handle", "*"]);
|
|
|
599
631
|
/**
|
|
600
632
|
* Zod schema for MIME type patterns.
|
|
601
633
|
*
|
|
602
|
-
* Validates MIME type strings
|
|
634
|
+
* Validates MIME type strings used in blob permissions.
|
|
635
|
+
* Supports standard MIME types and wildcard patterns per ATProto spec.
|
|
636
|
+
*
|
|
637
|
+
* **References:**
|
|
638
|
+
* - ATProto Permission Spec: https://atproto.com/specs/permission (blob resource)
|
|
639
|
+
* - RFC 2045 (MIME): https://www.rfc-editor.org/rfc/rfc2045 (token definition)
|
|
640
|
+
* - IANA Media Types: https://www.iana.org/assignments/media-types/
|
|
641
|
+
*
|
|
642
|
+
* **Implementation:**
|
|
643
|
+
* This is a "good enough" validation that allows common real-world MIME types:
|
|
644
|
+
* - Type: letters, digits (e.g., "3gpp")
|
|
645
|
+
* - Subtype: letters, digits, hyphens, plus signs, dots, underscores, wildcards
|
|
646
|
+
* - Examples: "image/png", "application/vnd.api+json", "video/*", "clue_info+xml"
|
|
647
|
+
*
|
|
648
|
+
* Note: We use a simplified regex rather than full RFC 2045 token validation
|
|
649
|
+
* for practicality. Zod v4 has native MIME support (z.file().mime()) but would
|
|
650
|
+
* require a larger migration effort.
|
|
603
651
|
*
|
|
604
652
|
* @example
|
|
605
653
|
* ```typescript
|
|
606
|
-
* MimeTypeSchema.parse('image/*'); // Valid
|
|
607
|
-
* MimeTypeSchema.parse('video/mp4'); // Valid
|
|
654
|
+
* MimeTypeSchema.parse('image/*'); // Valid - wildcard
|
|
655
|
+
* MimeTypeSchema.parse('video/mp4'); // Valid - standard
|
|
656
|
+
* MimeTypeSchema.parse('application/vnd.api+json'); // Valid - with dots/plus
|
|
608
657
|
* MimeTypeSchema.parse('invalid'); // Throws ZodError
|
|
609
658
|
* ```
|
|
610
659
|
*/
|
|
611
660
|
const MimeTypeSchema = zod.z
|
|
612
661
|
.string()
|
|
613
|
-
.regex(/^[a-
|
|
662
|
+
.regex(/^[a-z0-9]+\/[a-z0-9*+._-]+$/i, 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*", "video/mp4", "application/vnd.api+json")');
|
|
614
663
|
/**
|
|
615
664
|
* Zod schema for NSID (Namespaced Identifier).
|
|
616
665
|
*
|
|
617
666
|
* NSIDs are reverse-DNS style identifiers used throughout ATProto
|
|
618
667
|
* (e.g., "app.bsky.feed.post" or "com.example.myrecord").
|
|
619
668
|
*
|
|
669
|
+
* Official ATProto NSID spec requires:
|
|
670
|
+
* - Each segment must be 1-63 characters
|
|
671
|
+
* - Authority segments (all but last) can contain hyphens, but not at boundaries
|
|
672
|
+
* - Name segment (last) must start with a letter and contain only alphanumerics
|
|
673
|
+
* - Hyphens only allowed in authority segments, not in the name segment
|
|
674
|
+
*
|
|
620
675
|
* @see https://atproto.com/specs/nsid
|
|
621
676
|
*
|
|
622
677
|
* @example
|
|
@@ -628,7 +683,7 @@ const MimeTypeSchema = zod.z
|
|
|
628
683
|
*/
|
|
629
684
|
const NsidSchema = zod.z
|
|
630
685
|
.string()
|
|
631
|
-
.regex(/^[a-zA-Z][a-zA-Z0-9-]
|
|
686
|
+
.regex(/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/, 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")');
|
|
632
687
|
/**
|
|
633
688
|
* Zod schema for account permission.
|
|
634
689
|
*
|
|
@@ -1379,27 +1434,119 @@ const ScopePresets = {
|
|
|
1379
1434
|
function parseScope(scope) {
|
|
1380
1435
|
return scope.trim().split(/\s+/).filter(Boolean);
|
|
1381
1436
|
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Helper function to match MIME type patterns with wildcard support.
|
|
1439
|
+
*
|
|
1440
|
+
* Implements MIME type matching for blob permissions per ATProto spec.
|
|
1441
|
+
*
|
|
1442
|
+
* **Reference:**
|
|
1443
|
+
* - ATProto Permission Spec: https://atproto.com/specs/permission
|
|
1444
|
+
* Supports "MIME types or partial MIME type glob patterns"
|
|
1445
|
+
*
|
|
1446
|
+
* **Supported patterns:**
|
|
1447
|
+
* - Exact matches: "image/png" matches "image/png"
|
|
1448
|
+
* - Type wildcards: "image/*" matches "image/png", "image/jpeg", etc.
|
|
1449
|
+
* - Full wildcards: `*` `/` `*` matches any MIME type
|
|
1450
|
+
*
|
|
1451
|
+
* @param pattern - The MIME type pattern (may contain wildcards)
|
|
1452
|
+
* @param mimeType - The actual MIME type to check
|
|
1453
|
+
* @returns True if the MIME type matches the pattern
|
|
1454
|
+
*
|
|
1455
|
+
* @example
|
|
1456
|
+
* ```typescript
|
|
1457
|
+
* matchMimePattern("image/*", "image/png"); // true
|
|
1458
|
+
* matchMimePattern("*" + "/" + "*", "video/mp4"); // true - matches any MIME
|
|
1459
|
+
* matchMimePattern("image/*", "video/mp4"); // false
|
|
1460
|
+
* ```
|
|
1461
|
+
*/
|
|
1462
|
+
function matchMimePattern(pattern, mimeType) {
|
|
1463
|
+
if (pattern === "*/*")
|
|
1464
|
+
return true;
|
|
1465
|
+
if (pattern === mimeType)
|
|
1466
|
+
return true;
|
|
1467
|
+
const [patternType, patternSubtype] = pattern.split("/");
|
|
1468
|
+
const [mimeTypeType] = mimeType.split("/");
|
|
1469
|
+
if (patternSubtype === "*" && patternType === mimeTypeType) {
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1382
1474
|
/**
|
|
1383
1475
|
* Check if a scope string contains a specific permission.
|
|
1384
1476
|
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1477
|
+
* Implements permission matching per ATProto OAuth spec with support for
|
|
1478
|
+
* exact matching and limited wildcard patterns.
|
|
1479
|
+
*
|
|
1480
|
+
* **References:**
|
|
1481
|
+
* - ATProto Permission Spec: https://atproto.com/specs/permission
|
|
1482
|
+
* - ATProto OAuth Spec: https://atproto.com/specs/oauth
|
|
1483
|
+
*
|
|
1484
|
+
* **Supported wildcards (per spec):**
|
|
1485
|
+
* - `repo:*` - Matches any repository collection (e.g., `repo:app.bsky.feed.post`)
|
|
1486
|
+
* - `rpc:*` - Matches any RPC lexicon (but aud cannot also be wildcard)
|
|
1487
|
+
* - `blob:image/*` - MIME type wildcards (e.g., matches `blob:image/png`, `blob:image/jpeg`)
|
|
1488
|
+
* - `blob:` + wildcard MIME - Matches any MIME type (using `*` + `/` + `*` pattern)
|
|
1489
|
+
* - `identity:*` - Full control of DID document and handle (spec allows `*` as attr value)
|
|
1490
|
+
*
|
|
1491
|
+
* **NOT supported (per spec):**
|
|
1492
|
+
* - `account:*` - Account attr does not support wildcards (only `email` and `repo` allowed)
|
|
1493
|
+
* - `include:*` - Include NSID does not support wildcards
|
|
1494
|
+
* - Partial wildcards like `com.example.*` are not supported
|
|
1388
1495
|
*
|
|
1389
1496
|
* @param scope - Space-separated scope string
|
|
1390
1497
|
* @param permission - The permission to check for
|
|
1391
1498
|
* @returns True if the scope contains the permission
|
|
1392
1499
|
*
|
|
1393
|
-
* @example
|
|
1500
|
+
* @example Exact matching
|
|
1394
1501
|
* ```typescript
|
|
1395
|
-
* const scope = "account:email
|
|
1396
|
-
* hasPermission(scope, "account:email
|
|
1502
|
+
* const scope = "account:email repo:app.bsky.feed.post";
|
|
1503
|
+
* hasPermission(scope, "account:email"); // true
|
|
1397
1504
|
* hasPermission(scope, "account:repo"); // false
|
|
1398
1505
|
* ```
|
|
1506
|
+
*
|
|
1507
|
+
* @example Wildcard matching
|
|
1508
|
+
* ```typescript
|
|
1509
|
+
* const scope = "repo:* blob:image/* identity:*";
|
|
1510
|
+
* hasPermission(scope, "repo:app.bsky.feed.post"); // true
|
|
1511
|
+
* hasPermission(scope, "blob:image/png"); // true
|
|
1512
|
+
* hasPermission(scope, "blob:video/mp4"); // false
|
|
1513
|
+
* hasPermission(scope, "identity:handle"); // true
|
|
1514
|
+
* ```
|
|
1399
1515
|
*/
|
|
1400
1516
|
function hasPermission(scope, permission) {
|
|
1401
1517
|
const permissions = parseScope(scope);
|
|
1402
|
-
|
|
1518
|
+
// 1. Check exact match first
|
|
1519
|
+
if (permissions.includes(permission)) {
|
|
1520
|
+
return true;
|
|
1521
|
+
}
|
|
1522
|
+
// 2. Check wildcard matches (only those supported by ATProto spec)
|
|
1523
|
+
for (const scopePermission of permissions) {
|
|
1524
|
+
// repo:* - Wildcard for repository collections
|
|
1525
|
+
// Spec: "Wildcard (*) is allowed in scope string syntax"
|
|
1526
|
+
if (scopePermission.startsWith("repo:*") && permission.startsWith("repo:")) {
|
|
1527
|
+
return true;
|
|
1528
|
+
}
|
|
1529
|
+
// rpc:* - Wildcard for RPC lexicons
|
|
1530
|
+
// Spec: "Wildcard (*) is allowed in scope string syntax for lxm parameter"
|
|
1531
|
+
if (scopePermission.startsWith("rpc:*") && permission.startsWith("rpc:")) {
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1534
|
+
// blob MIME wildcards - image/*, video/*, or full wildcard
|
|
1535
|
+
// Spec: "MIME types or partial MIME type glob patterns"
|
|
1536
|
+
if (scopePermission.startsWith("blob:") && permission.startsWith("blob:")) {
|
|
1537
|
+
const scopeMime = scopePermission.substring(5).split("?")[0]; // Remove "blob:" prefix and query params
|
|
1538
|
+
const permMime = permission.substring(5).split("?")[0];
|
|
1539
|
+
if (matchMimePattern(scopeMime, permMime)) {
|
|
1540
|
+
return true;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// identity:* - Full control of DID document and handle
|
|
1544
|
+
// Spec: "* - Full control of DID document and handle" (as attr value)
|
|
1545
|
+
if (scopePermission === "identity:*" && permission.startsWith("identity:")) {
|
|
1546
|
+
return true;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return false;
|
|
1403
1550
|
}
|
|
1404
1551
|
/**
|
|
1405
1552
|
* Check if a scope string contains all of the specified permissions.
|
|
@@ -1623,6 +1770,26 @@ class OAuthClient {
|
|
|
1623
1770
|
async getClient() {
|
|
1624
1771
|
return this.clientPromise;
|
|
1625
1772
|
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Detects if a URL is a loopback address (localhost or 127.0.0.1 or [::1]).
|
|
1775
|
+
*
|
|
1776
|
+
* @param urlString - The URL to check
|
|
1777
|
+
* @returns True if the URL is an HTTP loopback address
|
|
1778
|
+
* @internal
|
|
1779
|
+
*/
|
|
1780
|
+
isLoopbackUrl(urlString) {
|
|
1781
|
+
try {
|
|
1782
|
+
const url = new URL(urlString);
|
|
1783
|
+
if (url.protocol !== "http:") {
|
|
1784
|
+
return false;
|
|
1785
|
+
}
|
|
1786
|
+
const hostname = url.hostname.toLowerCase();
|
|
1787
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
1788
|
+
}
|
|
1789
|
+
catch {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1626
1793
|
/**
|
|
1627
1794
|
* Builds OAuth client metadata from configuration.
|
|
1628
1795
|
*
|
|
@@ -1641,6 +1808,23 @@ class OAuthClient {
|
|
|
1641
1808
|
*/
|
|
1642
1809
|
buildClientMetadata() {
|
|
1643
1810
|
const clientIdUrl = new URL(this.config.oauth.clientId);
|
|
1811
|
+
// Detect and warn about loopback configuration
|
|
1812
|
+
const isDevelopment = isLoopbackUrl(this.config.oauth.clientId) || isLoopbackUrl(this.config.oauth.redirectUri);
|
|
1813
|
+
if (isDevelopment && !this.config.oauth.developmentMode) {
|
|
1814
|
+
this.logger?.warn("Using HTTP loopback URLs without explicit developmentMode flag", {
|
|
1815
|
+
clientId: this.config.oauth.clientId,
|
|
1816
|
+
redirectUri: this.config.oauth.redirectUri,
|
|
1817
|
+
note: "This is suitable for local development only. For production, use HTTPS URLs.",
|
|
1818
|
+
recommendation: "Set oauth.developmentMode: true to suppress this warning.",
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
if (isDevelopment) {
|
|
1822
|
+
this.logger?.info("Running in development mode with loopback URLs", {
|
|
1823
|
+
clientId: this.config.oauth.clientId,
|
|
1824
|
+
redirectUri: this.config.oauth.redirectUri,
|
|
1825
|
+
note: "Authorization server must support loopback clients (optional per AT Protocol spec)",
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1644
1828
|
const metadata = {
|
|
1645
1829
|
client_id: this.config.oauth.clientId,
|
|
1646
1830
|
client_name: "ATProto SDK Client",
|
|
@@ -2078,6 +2262,299 @@ class ConfigurableAgent extends api.Agent {
|
|
|
2078
2262
|
}
|
|
2079
2263
|
}
|
|
2080
2264
|
|
|
2265
|
+
/**
|
|
2266
|
+
* LexiconRegistry - Manages custom lexicon registration and validation.
|
|
2267
|
+
*
|
|
2268
|
+
* This module provides a registry for AT Protocol lexicon schemas,
|
|
2269
|
+
* allowing developers to register custom lexicons and validate records
|
|
2270
|
+
* against registered schemas.
|
|
2271
|
+
*
|
|
2272
|
+
* @packageDocumentation
|
|
2273
|
+
*/
|
|
2274
|
+
/**
|
|
2275
|
+
* Registry for managing AT Protocol lexicon schemas.
|
|
2276
|
+
*
|
|
2277
|
+
* The LexiconRegistry allows developers to:
|
|
2278
|
+
* - Register custom lexicon definitions
|
|
2279
|
+
* - Validate records against registered schemas
|
|
2280
|
+
* - Query registered lexicons
|
|
2281
|
+
* - Add lexicons to AT Protocol agents
|
|
2282
|
+
*
|
|
2283
|
+
* @example Basic usage
|
|
2284
|
+
* ```typescript
|
|
2285
|
+
* const registry = new LexiconRegistry();
|
|
2286
|
+
*
|
|
2287
|
+
* // Register a custom lexicon
|
|
2288
|
+
* registry.register({
|
|
2289
|
+
* lexicon: 1,
|
|
2290
|
+
* id: "org.myapp.customRecord",
|
|
2291
|
+
* defs: {
|
|
2292
|
+
* main: {
|
|
2293
|
+
* type: "record",
|
|
2294
|
+
* key: "tid",
|
|
2295
|
+
* record: {
|
|
2296
|
+
* type: "object",
|
|
2297
|
+
* required: ["$type", "title"],
|
|
2298
|
+
* properties: {
|
|
2299
|
+
* "$type": { type: "string", const: "org.myapp.customRecord" },
|
|
2300
|
+
* title: { type: "string" }
|
|
2301
|
+
* }
|
|
2302
|
+
* }
|
|
2303
|
+
* }
|
|
2304
|
+
* }
|
|
2305
|
+
* });
|
|
2306
|
+
*
|
|
2307
|
+
* // Validate a record
|
|
2308
|
+
* const result = registry.validate("org.myapp.customRecord", {
|
|
2309
|
+
* $type: "org.myapp.customRecord",
|
|
2310
|
+
* title: "My Record"
|
|
2311
|
+
* });
|
|
2312
|
+
*
|
|
2313
|
+
* if (!result.valid) {
|
|
2314
|
+
* console.error(result.error);
|
|
2315
|
+
* }
|
|
2316
|
+
* ```
|
|
2317
|
+
*/
|
|
2318
|
+
class LexiconRegistry {
|
|
2319
|
+
/**
|
|
2320
|
+
* Creates a new LexiconRegistry instance.
|
|
2321
|
+
*
|
|
2322
|
+
* @param initialLexicons - Optional array of lexicons to register on initialization
|
|
2323
|
+
*/
|
|
2324
|
+
constructor(initialLexicons) {
|
|
2325
|
+
this.lexicons = new lexicon$1.Lexicons();
|
|
2326
|
+
this.registeredIds = new Set();
|
|
2327
|
+
if (initialLexicons && initialLexicons.length > 0) {
|
|
2328
|
+
this.registerMany(initialLexicons);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Registers a single lexicon definition.
|
|
2333
|
+
*
|
|
2334
|
+
* @param lexicon - The lexicon document to register
|
|
2335
|
+
* @throws {Error} If the lexicon is invalid or already registered
|
|
2336
|
+
*
|
|
2337
|
+
* @example
|
|
2338
|
+
* ```typescript
|
|
2339
|
+
* registry.register({
|
|
2340
|
+
* lexicon: 1,
|
|
2341
|
+
* id: "org.myapp.customRecord",
|
|
2342
|
+
* defs: { ... }
|
|
2343
|
+
* });
|
|
2344
|
+
* ```
|
|
2345
|
+
*/
|
|
2346
|
+
register(lexicon) {
|
|
2347
|
+
if (!lexicon.id) {
|
|
2348
|
+
throw new Error("Lexicon must have an id");
|
|
2349
|
+
}
|
|
2350
|
+
if (this.registeredIds.has(lexicon.id)) {
|
|
2351
|
+
throw new Error(`Lexicon ${lexicon.id} is already registered`);
|
|
2352
|
+
}
|
|
2353
|
+
try {
|
|
2354
|
+
// Check if the lexicon already exists in the internal store
|
|
2355
|
+
// (e.g., after unregister which only removes from registeredIds)
|
|
2356
|
+
const existingLexicon = this.lexicons.get(lexicon.id);
|
|
2357
|
+
if (!existingLexicon) {
|
|
2358
|
+
// Lexicon is truly new, add it to the store
|
|
2359
|
+
this.lexicons.add(lexicon);
|
|
2360
|
+
}
|
|
2361
|
+
// Always add to registeredIds (re-enable if previously unregistered)
|
|
2362
|
+
this.registeredIds.add(lexicon.id);
|
|
2363
|
+
}
|
|
2364
|
+
catch (error) {
|
|
2365
|
+
throw new Error(`Failed to register lexicon ${lexicon.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Registers multiple lexicon definitions at once.
|
|
2370
|
+
*
|
|
2371
|
+
* @param lexicons - Array of lexicon documents to register
|
|
2372
|
+
* @throws {Error} If any lexicon is invalid or already registered
|
|
2373
|
+
*
|
|
2374
|
+
* @example
|
|
2375
|
+
* ```typescript
|
|
2376
|
+
* registry.registerMany([lexicon1, lexicon2, lexicon3]);
|
|
2377
|
+
* ```
|
|
2378
|
+
*/
|
|
2379
|
+
registerMany(lexicons) {
|
|
2380
|
+
for (const lexicon of lexicons) {
|
|
2381
|
+
this.register(lexicon);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Registers a lexicon from a JSON object.
|
|
2386
|
+
*
|
|
2387
|
+
* This is a convenience method for registering lexicons loaded from JSON files.
|
|
2388
|
+
*
|
|
2389
|
+
* @param lexiconJson - The lexicon as a plain JavaScript object
|
|
2390
|
+
* @throws {ValidationError} If the lexicon is not a valid object
|
|
2391
|
+
* @throws {Error} If the lexicon is invalid or already registered
|
|
2392
|
+
*
|
|
2393
|
+
* @example
|
|
2394
|
+
* ```typescript
|
|
2395
|
+
* import customLexicon from "./custom-lexicon.json";
|
|
2396
|
+
* registry.registerFromJSON(customLexicon);
|
|
2397
|
+
* ```
|
|
2398
|
+
*/
|
|
2399
|
+
registerFromJSON(lexiconJson) {
|
|
2400
|
+
// Validate that input is an object and not null
|
|
2401
|
+
if (typeof lexiconJson !== "object" || lexiconJson === null) {
|
|
2402
|
+
throw new ValidationError("Lexicon JSON must be a valid object");
|
|
2403
|
+
}
|
|
2404
|
+
// Now we can safely cast to LexiconDoc and register
|
|
2405
|
+
this.register(lexiconJson);
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Unregisters a lexicon by its NSID.
|
|
2409
|
+
*
|
|
2410
|
+
* @param nsid - The NSID of the lexicon to unregister
|
|
2411
|
+
* @returns True if the lexicon was unregistered, false if it wasn't registered
|
|
2412
|
+
*
|
|
2413
|
+
* @example
|
|
2414
|
+
* ```typescript
|
|
2415
|
+
* registry.unregister("org.myapp.customRecord");
|
|
2416
|
+
* ```
|
|
2417
|
+
*/
|
|
2418
|
+
unregister(nsid) {
|
|
2419
|
+
if (!this.registeredIds.has(nsid)) {
|
|
2420
|
+
return false;
|
|
2421
|
+
}
|
|
2422
|
+
this.registeredIds.delete(nsid);
|
|
2423
|
+
// Note: Lexicons class doesn't have a remove method,
|
|
2424
|
+
// so we can't actually remove from the internal store.
|
|
2425
|
+
// We track removal in our Set for isRegistered checks.
|
|
2426
|
+
return true;
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Checks if a lexicon is registered.
|
|
2430
|
+
*
|
|
2431
|
+
* @param nsid - The NSID to check
|
|
2432
|
+
* @returns True if the lexicon is registered
|
|
2433
|
+
*
|
|
2434
|
+
* @example
|
|
2435
|
+
* ```typescript
|
|
2436
|
+
* if (registry.isRegistered("org.myapp.customRecord")) {
|
|
2437
|
+
* // Lexicon is available
|
|
2438
|
+
* }
|
|
2439
|
+
* ```
|
|
2440
|
+
*/
|
|
2441
|
+
isRegistered(nsid) {
|
|
2442
|
+
return this.registeredIds.has(nsid);
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Gets a lexicon definition by its NSID.
|
|
2446
|
+
*
|
|
2447
|
+
* @param nsid - The NSID of the lexicon to retrieve
|
|
2448
|
+
* @returns The lexicon document, or undefined if not found
|
|
2449
|
+
*
|
|
2450
|
+
* @example
|
|
2451
|
+
* ```typescript
|
|
2452
|
+
* const lexicon = registry.get("org.myapp.customRecord");
|
|
2453
|
+
* if (lexicon) {
|
|
2454
|
+
* console.log(lexicon.defs);
|
|
2455
|
+
* }
|
|
2456
|
+
* ```
|
|
2457
|
+
*/
|
|
2458
|
+
get(nsid) {
|
|
2459
|
+
if (!this.isRegistered(nsid)) {
|
|
2460
|
+
return undefined;
|
|
2461
|
+
}
|
|
2462
|
+
return this.lexicons.get(nsid);
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Gets all registered lexicon NSIDs.
|
|
2466
|
+
*
|
|
2467
|
+
* @returns Array of registered NSIDs
|
|
2468
|
+
*
|
|
2469
|
+
* @example
|
|
2470
|
+
* ```typescript
|
|
2471
|
+
* const registered = registry.getAll();
|
|
2472
|
+
* console.log(`Registered lexicons: ${registered.join(", ")}`);
|
|
2473
|
+
* ```
|
|
2474
|
+
*/
|
|
2475
|
+
getAll() {
|
|
2476
|
+
return Array.from(this.registeredIds);
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Validates a record against a registered lexicon.
|
|
2480
|
+
*
|
|
2481
|
+
* @param nsid - The collection NSID to validate against
|
|
2482
|
+
* @param record - The record data to validate
|
|
2483
|
+
* @returns Validation result with success status and optional error message
|
|
2484
|
+
*
|
|
2485
|
+
* @example
|
|
2486
|
+
* ```typescript
|
|
2487
|
+
* const result = registry.validate("org.myapp.customRecord", {
|
|
2488
|
+
* $type: "org.myapp.customRecord",
|
|
2489
|
+
* title: "My Record"
|
|
2490
|
+
* });
|
|
2491
|
+
*
|
|
2492
|
+
* if (!result.valid) {
|
|
2493
|
+
* console.error(`Validation failed: ${result.error}`);
|
|
2494
|
+
* }
|
|
2495
|
+
* ```
|
|
2496
|
+
*/
|
|
2497
|
+
validate(nsid, record) {
|
|
2498
|
+
if (!this.isRegistered(nsid)) {
|
|
2499
|
+
return {
|
|
2500
|
+
valid: false,
|
|
2501
|
+
error: `Lexicon ${nsid} is not registered`,
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
try {
|
|
2505
|
+
this.lexicons.assertValidRecord(nsid, record);
|
|
2506
|
+
return { valid: true };
|
|
2507
|
+
}
|
|
2508
|
+
catch (error) {
|
|
2509
|
+
return {
|
|
2510
|
+
valid: false,
|
|
2511
|
+
error: error instanceof Error ? error.message : "Validation failed",
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Adds all registered lexicons to an AT Protocol Agent.
|
|
2517
|
+
*
|
|
2518
|
+
* This method is currently a no-op as the AT Protocol Agent
|
|
2519
|
+
* doesn't provide a public API for adding lexicons at runtime.
|
|
2520
|
+
* Lexicons must be registered with the server.
|
|
2521
|
+
*
|
|
2522
|
+
* This method is kept for future compatibility if the API
|
|
2523
|
+
* adds support for client-side lexicon registration.
|
|
2524
|
+
*
|
|
2525
|
+
* @param _agent - The AT Protocol Agent (currently unused)
|
|
2526
|
+
*
|
|
2527
|
+
* @example
|
|
2528
|
+
* ```typescript
|
|
2529
|
+
* const agent = new Agent(session);
|
|
2530
|
+
* registry.addToAgent(agent);
|
|
2531
|
+
* // Reserved for future use
|
|
2532
|
+
* ```
|
|
2533
|
+
*/
|
|
2534
|
+
addToAgent(_agent) {
|
|
2535
|
+
// No-op: AT Protocol Agent doesn't support client-side lexicon addition
|
|
2536
|
+
// Lexicons are validated client-side via this registry,
|
|
2537
|
+
// but server-side validation is performed by the PDS/SDS
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Gets the underlying Lexicons instance.
|
|
2541
|
+
*
|
|
2542
|
+
* This provides direct access to the AT Protocol Lexicons object
|
|
2543
|
+
* for advanced use cases.
|
|
2544
|
+
*
|
|
2545
|
+
* @returns The internal Lexicons instance
|
|
2546
|
+
*
|
|
2547
|
+
* @example
|
|
2548
|
+
* ```typescript
|
|
2549
|
+
* const lexicons = registry.getLexicons();
|
|
2550
|
+
* // Use lexicons directly for advanced operations
|
|
2551
|
+
* ```
|
|
2552
|
+
*/
|
|
2553
|
+
getLexicons() {
|
|
2554
|
+
return this.lexicons;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2081
2558
|
/**
|
|
2082
2559
|
* RecordOperationsImpl - Low-level record CRUD operations.
|
|
2083
2560
|
*
|
|
@@ -2132,12 +2609,14 @@ class RecordOperationsImpl {
|
|
|
2132
2609
|
*
|
|
2133
2610
|
* @param agent - AT Protocol Agent for making API calls
|
|
2134
2611
|
* @param repoDid - DID of the repository to operate on
|
|
2612
|
+
* @param lexiconRegistry - Optional registry for validating records against lexicon schemas
|
|
2135
2613
|
*
|
|
2136
2614
|
* @internal
|
|
2137
2615
|
*/
|
|
2138
|
-
constructor(agent, repoDid) {
|
|
2616
|
+
constructor(agent, repoDid, lexiconRegistry) {
|
|
2139
2617
|
this.agent = agent;
|
|
2140
2618
|
this.repoDid = repoDid;
|
|
2619
|
+
this.lexiconRegistry = lexiconRegistry;
|
|
2141
2620
|
}
|
|
2142
2621
|
/**
|
|
2143
2622
|
* Creates a new record in the specified collection.
|
|
@@ -2147,14 +2626,17 @@ class RecordOperationsImpl {
|
|
|
2147
2626
|
* @param params.record - Record data conforming to the collection's lexicon schema
|
|
2148
2627
|
* @param params.rkey - Optional record key. If not provided, a TID (timestamp-based ID)
|
|
2149
2628
|
* is automatically generated by the server.
|
|
2629
|
+
* @param params.skipValidation - Optional flag to skip lexicon validation (default: false)
|
|
2150
2630
|
* @returns Promise resolving to the created record's URI and CID
|
|
2151
2631
|
* @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
|
|
2152
2632
|
* @throws {@link NetworkError} if the API request fails
|
|
2153
2633
|
*
|
|
2154
2634
|
* @remarks
|
|
2155
2635
|
* The record is validated against the collection's lexicon before sending
|
|
2156
|
-
* to the server
|
|
2157
|
-
* is
|
|
2636
|
+
* to the server if the collection is registered in the LexiconRegistry.
|
|
2637
|
+
* If no lexicon is registered for the collection, validation is skipped
|
|
2638
|
+
* (allowing custom record types). Use `skipValidation: true` to bypass
|
|
2639
|
+
* validation even for registered collections.
|
|
2158
2640
|
*
|
|
2159
2641
|
* **AT-URI Format**: `at://{did}/{collection}/{rkey}`
|
|
2160
2642
|
*
|
|
@@ -2174,6 +2656,13 @@ class RecordOperationsImpl {
|
|
|
2174
2656
|
*/
|
|
2175
2657
|
async create(params) {
|
|
2176
2658
|
try {
|
|
2659
|
+
// Validate record against registered lexicon if available
|
|
2660
|
+
if (!params.skipValidation && this.lexiconRegistry?.isRegistered(params.collection)) {
|
|
2661
|
+
const validation = this.lexiconRegistry.validate(params.collection, params.record);
|
|
2662
|
+
if (!validation.valid) {
|
|
2663
|
+
throw new ValidationError(`Invalid record for collection ${params.collection}: ${validation.error}`);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2177
2666
|
const result = await this.agent.com.atproto.repo.createRecord({
|
|
2178
2667
|
repo: this.repoDid,
|
|
2179
2668
|
collection: params.collection,
|
|
@@ -2198,6 +2687,7 @@ class RecordOperationsImpl {
|
|
|
2198
2687
|
* @param params.collection - NSID of the collection
|
|
2199
2688
|
* @param params.rkey - Record key (the last segment of the AT-URI)
|
|
2200
2689
|
* @param params.record - New record data (completely replaces existing record)
|
|
2690
|
+
* @param params.skipValidation - Optional flag to skip lexicon validation (default: false)
|
|
2201
2691
|
* @returns Promise resolving to the updated record's URI and new CID
|
|
2202
2692
|
* @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
|
|
2203
2693
|
* @throws {@link NetworkError} if the API request fails
|
|
@@ -2207,6 +2697,10 @@ class RecordOperationsImpl {
|
|
|
2207
2697
|
* record is replaced with the new data. To preserve existing fields,
|
|
2208
2698
|
* first fetch the record with {@link get}, modify it, then update.
|
|
2209
2699
|
*
|
|
2700
|
+
* The record is validated against the collection's lexicon before sending
|
|
2701
|
+
* to the server if the collection is registered in the LexiconRegistry.
|
|
2702
|
+
* Use `skipValidation: true` to bypass validation.
|
|
2703
|
+
*
|
|
2210
2704
|
* @example
|
|
2211
2705
|
* ```typescript
|
|
2212
2706
|
* // Get existing record
|
|
@@ -2228,6 +2722,13 @@ class RecordOperationsImpl {
|
|
|
2228
2722
|
*/
|
|
2229
2723
|
async update(params) {
|
|
2230
2724
|
try {
|
|
2725
|
+
// Validate record against registered lexicon if available
|
|
2726
|
+
if (!params.skipValidation && this.lexiconRegistry?.isRegistered(params.collection)) {
|
|
2727
|
+
const validation = this.lexiconRegistry.validate(params.collection, params.record);
|
|
2728
|
+
if (!validation.valid) {
|
|
2729
|
+
throw new ValidationError(`Invalid record for collection ${params.collection}: ${validation.error}`);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2231
2732
|
const result = await this.agent.com.atproto.repo.putRecord({
|
|
2232
2733
|
repo: this.repoDid,
|
|
2233
2734
|
collection: params.collection,
|
|
@@ -2884,20 +3385,21 @@ class ProfileOperationsImpl {
|
|
|
2884
3385
|
const HYPERCERT_LEXICONS = [
|
|
2885
3386
|
lexicon.CERTIFIED_DEFS_LEXICON_JSON,
|
|
2886
3387
|
lexicon.LOCATION_LEXICON_JSON,
|
|
2887
|
-
lexicon.
|
|
3388
|
+
lexicon.STRONG_REF_LEXICON_JSON,
|
|
2888
3389
|
lexicon.HYPERCERTS_DEFS_LEXICON_JSON,
|
|
2889
3390
|
lexicon.ACTIVITY_LEXICON_JSON,
|
|
2890
3391
|
lexicon.COLLECTION_LEXICON_JSON,
|
|
2891
|
-
lexicon.
|
|
3392
|
+
lexicon.CONTRIBUTION_DETAILS_LEXICON_JSON,
|
|
3393
|
+
lexicon.CONTRIBUTOR_INFORMATION_LEXICON_JSON,
|
|
2892
3394
|
lexicon.EVALUATION_LEXICON_JSON,
|
|
2893
3395
|
lexicon.EVIDENCE_LEXICON_JSON,
|
|
2894
3396
|
lexicon.MEASUREMENT_LEXICON_JSON,
|
|
2895
3397
|
lexicon.RIGHTS_LEXICON_JSON,
|
|
2896
|
-
lexicon.PROJECT_LEXICON_JSON,
|
|
2897
3398
|
lexicon.BADGE_AWARD_LEXICON_JSON,
|
|
2898
3399
|
lexicon.BADGE_DEFINITION_LEXICON_JSON,
|
|
2899
3400
|
lexicon.BADGE_RESPONSE_LEXICON_JSON,
|
|
2900
3401
|
lexicon.FUNDING_RECEIPT_LEXICON_JSON,
|
|
3402
|
+
lexicon.WORK_SCOPE_TAG_LEXICON_JSON,
|
|
2901
3403
|
];
|
|
2902
3404
|
/**
|
|
2903
3405
|
* Collection NSIDs (Namespaced Identifiers) for hypercert records.
|
|
@@ -2919,9 +3421,15 @@ const HYPERCERT_COLLECTIONS = {
|
|
|
2919
3421
|
*/
|
|
2920
3422
|
LOCATION: lexicon.LOCATION_NSID,
|
|
2921
3423
|
/**
|
|
2922
|
-
* Contribution record collection.
|
|
3424
|
+
* Contribution details record collection.
|
|
3425
|
+
* For storing details about a specific contribution (role, description, timeframe).
|
|
2923
3426
|
*/
|
|
2924
|
-
|
|
3427
|
+
CONTRIBUTION_DETAILS: lexicon.CONTRIBUTION_DETAILS_NSID,
|
|
3428
|
+
/**
|
|
3429
|
+
* Contributor information record collection.
|
|
3430
|
+
* For storing contributor profile information (identifier, displayName, image).
|
|
3431
|
+
*/
|
|
3432
|
+
CONTRIBUTOR_INFORMATION: lexicon.CONTRIBUTOR_INFORMATION_NSID,
|
|
2925
3433
|
/**
|
|
2926
3434
|
* Measurement record collection.
|
|
2927
3435
|
*/
|
|
@@ -2936,12 +3444,9 @@ const HYPERCERT_COLLECTIONS = {
|
|
|
2936
3444
|
EVIDENCE: lexicon.EVIDENCE_NSID,
|
|
2937
3445
|
/**
|
|
2938
3446
|
* Collection record collection (groups of hypercerts).
|
|
3447
|
+
* Projects are now collections with type='project'.
|
|
2939
3448
|
*/
|
|
2940
3449
|
COLLECTION: lexicon.COLLECTION_NSID,
|
|
2941
|
-
/**
|
|
2942
|
-
* Project record collection.
|
|
2943
|
-
*/
|
|
2944
|
-
PROJECT: lexicon.PROJECT_NSID,
|
|
2945
3450
|
/**
|
|
2946
3451
|
* Badge award record collection.
|
|
2947
3452
|
*/
|
|
@@ -2958,6 +3463,11 @@ const HYPERCERT_COLLECTIONS = {
|
|
|
2958
3463
|
* Funding receipt record collection.
|
|
2959
3464
|
*/
|
|
2960
3465
|
FUNDING_RECEIPT: lexicon.FUNDING_RECEIPT_NSID,
|
|
3466
|
+
/**
|
|
3467
|
+
* Work scope tag record collection.
|
|
3468
|
+
* For defining reusable work scope atoms.
|
|
3469
|
+
*/
|
|
3470
|
+
WORK_SCOPE_TAG: lexicon.WORK_SCOPE_TAG_NSID,
|
|
2961
3471
|
};
|
|
2962
3472
|
|
|
2963
3473
|
/**
|
|
@@ -3051,6 +3561,26 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3051
3561
|
}
|
|
3052
3562
|
}
|
|
3053
3563
|
}
|
|
3564
|
+
/**
|
|
3565
|
+
* Helper function to upload a blob to the repository, returns a blob reference
|
|
3566
|
+
*
|
|
3567
|
+
* @param content - Blob to upload
|
|
3568
|
+
* @param fallbackContentType | if content.type is empty,we use this
|
|
3569
|
+
* @returns BlobRef
|
|
3570
|
+
* @throws {@link NetworkError} if upload fails
|
|
3571
|
+
* @internal
|
|
3572
|
+
*/
|
|
3573
|
+
async handleBlobUpload(content, fallbackContentType) {
|
|
3574
|
+
const arrayBuffer = await content.arrayBuffer();
|
|
3575
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
3576
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
3577
|
+
encoding: content.type || fallbackContentType,
|
|
3578
|
+
});
|
|
3579
|
+
if (!uploadResult.success) {
|
|
3580
|
+
throw new NetworkError("Failed to upload blob");
|
|
3581
|
+
}
|
|
3582
|
+
return uploadResult.data.blob;
|
|
3583
|
+
}
|
|
3054
3584
|
/**
|
|
3055
3585
|
* Uploads an image blob and returns a blob reference.
|
|
3056
3586
|
*
|
|
@@ -3161,9 +3691,6 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3161
3691
|
if (imageBlobRef) {
|
|
3162
3692
|
hypercertRecord.image = imageBlobRef;
|
|
3163
3693
|
}
|
|
3164
|
-
if (params.evidence && params.evidence.length > 0) {
|
|
3165
|
-
hypercertRecord.evidence = params.evidence;
|
|
3166
|
-
}
|
|
3167
3694
|
const hypercertValidation = lexicon.validate(hypercertRecord, HYPERCERT_COLLECTIONS.CLAIM, "main", false);
|
|
3168
3695
|
if (!hypercertValidation.success) {
|
|
3169
3696
|
throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`);
|
|
@@ -3247,6 +3774,35 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3247
3774
|
throw error;
|
|
3248
3775
|
}
|
|
3249
3776
|
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Creates evidence records with progress tracking.
|
|
3779
|
+
*
|
|
3780
|
+
* @param hypercertUri - URI of the hypercert
|
|
3781
|
+
* @param evidenceItems - Array of evidence data (without subjectUri)
|
|
3782
|
+
* @param onProgress - Optional progress callback
|
|
3783
|
+
* @returns Promise resolving to array of evidence URIs
|
|
3784
|
+
* @internal
|
|
3785
|
+
*/
|
|
3786
|
+
async createEvidenceWithProgress(hypercertUri, evidenceItems, onProgress) {
|
|
3787
|
+
this.emitProgress(onProgress, { name: "addEvidence", status: "start" });
|
|
3788
|
+
try {
|
|
3789
|
+
const evidenceUris = await Promise.all(evidenceItems.map((evidence) => this.addEvidence({
|
|
3790
|
+
subjectUri: hypercertUri,
|
|
3791
|
+
...evidence,
|
|
3792
|
+
}).then((result) => result.uri)));
|
|
3793
|
+
this.emitProgress(onProgress, {
|
|
3794
|
+
name: "addEvidence",
|
|
3795
|
+
status: "success",
|
|
3796
|
+
data: { count: evidenceUris.length },
|
|
3797
|
+
});
|
|
3798
|
+
return evidenceUris;
|
|
3799
|
+
}
|
|
3800
|
+
catch (error) {
|
|
3801
|
+
this.emitProgress(onProgress, { name: "addEvidence", status: "error", error: error });
|
|
3802
|
+
this.logger?.warn(`Failed to create evidence: ${error instanceof Error ? error.message : "Unknown"}`);
|
|
3803
|
+
throw error;
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3250
3806
|
/**
|
|
3251
3807
|
* Creates a new hypercert with all related records.
|
|
3252
3808
|
*
|
|
@@ -3275,6 +3831,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3275
3831
|
* - `createHypercert`: Main hypercert record creation
|
|
3276
3832
|
* - `attachLocation`: Location record creation
|
|
3277
3833
|
* - `createContributions`: Contribution records creation
|
|
3834
|
+
* - `addEvidence`: Evidence records creation
|
|
3278
3835
|
*
|
|
3279
3836
|
* @example Minimal hypercert
|
|
3280
3837
|
* ```typescript
|
|
@@ -3350,6 +3907,15 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3350
3907
|
// Error already logged and progress emitted
|
|
3351
3908
|
}
|
|
3352
3909
|
}
|
|
3910
|
+
// Step 6: Add evidence records if provided
|
|
3911
|
+
if (params.evidence && params.evidence.length > 0) {
|
|
3912
|
+
try {
|
|
3913
|
+
result.evidenceUris = await this.createEvidenceWithProgress(hypercertUri, params.evidence, params.onProgress);
|
|
3914
|
+
}
|
|
3915
|
+
catch {
|
|
3916
|
+
// Error already logged and progress emitted
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3353
3919
|
return result;
|
|
3354
3920
|
}
|
|
3355
3921
|
catch (error) {
|
|
@@ -3500,11 +4066,6 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3500
4066
|
if (!result.success) {
|
|
3501
4067
|
throw new NetworkError("Failed to get hypercert");
|
|
3502
4068
|
}
|
|
3503
|
-
// Validate with lexicon (more lenient - doesn't require $type)
|
|
3504
|
-
const validation = lexicon.validate(result.data.value, HYPERCERT_COLLECTIONS.CLAIM, "main", false);
|
|
3505
|
-
if (!validation.success) {
|
|
3506
|
-
throw new ValidationError(`Invalid hypercert record format: ${validation.error?.message}`);
|
|
3507
|
-
}
|
|
3508
4069
|
return {
|
|
3509
4070
|
uri: result.data.uri,
|
|
3510
4071
|
cid: result.data.cid ?? "",
|
|
@@ -3637,50 +4198,18 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3637
4198
|
*/
|
|
3638
4199
|
async attachLocation(hypercertUri, location) {
|
|
3639
4200
|
try {
|
|
3640
|
-
// Validate required srs field
|
|
3641
4201
|
if (!location.srs) {
|
|
3642
4202
|
throw new ValidationError("srs (Spatial Reference System) is required. Example: 'EPSG:4326' for WGS84 coordinates, or 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' for CRS84.");
|
|
3643
4203
|
}
|
|
3644
4204
|
// Validate that hypercert exists (unused but confirms hypercert is valid)
|
|
3645
4205
|
await this.get(hypercertUri);
|
|
3646
4206
|
const createdAt = new Date().toISOString();
|
|
3647
|
-
|
|
3648
|
-
let locationData;
|
|
3649
|
-
let locationType;
|
|
3650
|
-
if (location.geojson) {
|
|
3651
|
-
// Upload GeoJSON as a blob
|
|
3652
|
-
const arrayBuffer = await location.geojson.arrayBuffer();
|
|
3653
|
-
const uint8Array = new Uint8Array(arrayBuffer);
|
|
3654
|
-
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
3655
|
-
encoding: location.geojson.type || "application/geo+json",
|
|
3656
|
-
});
|
|
3657
|
-
if (uploadResult.success) {
|
|
3658
|
-
locationData = {
|
|
3659
|
-
$type: "blob",
|
|
3660
|
-
ref: { $link: uploadResult.data.blob.ref.toString() },
|
|
3661
|
-
mimeType: uploadResult.data.blob.mimeType,
|
|
3662
|
-
size: uploadResult.data.blob.size,
|
|
3663
|
-
};
|
|
3664
|
-
locationType = "geojson-point";
|
|
3665
|
-
}
|
|
3666
|
-
else {
|
|
3667
|
-
throw new NetworkError("Failed to upload GeoJSON blob");
|
|
3668
|
-
}
|
|
3669
|
-
}
|
|
3670
|
-
else {
|
|
3671
|
-
// Use value as a URI reference
|
|
3672
|
-
locationData = {
|
|
3673
|
-
$type: "org.hypercerts.defs#uri",
|
|
3674
|
-
uri: location.value,
|
|
3675
|
-
};
|
|
3676
|
-
locationType = "coordinate-decimal";
|
|
3677
|
-
}
|
|
3678
|
-
// Build location record according to app.certified.location lexicon
|
|
4207
|
+
const locationData = await this.resolveUriOrBlob(location.location, "application/geo+json");
|
|
3679
4208
|
const locationRecord = {
|
|
3680
4209
|
$type: HYPERCERT_COLLECTIONS.LOCATION,
|
|
3681
|
-
lpVersion: "1.0",
|
|
4210
|
+
lpVersion: location.lpVersion || "1.0",
|
|
3682
4211
|
srs: location.srs,
|
|
3683
|
-
locationType,
|
|
4212
|
+
locationType: location.locationType,
|
|
3684
4213
|
location: locationData,
|
|
3685
4214
|
createdAt,
|
|
3686
4215
|
name: location.name,
|
|
@@ -3698,6 +4227,16 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3698
4227
|
if (!result.success) {
|
|
3699
4228
|
throw new NetworkError("Failed to attach location");
|
|
3700
4229
|
}
|
|
4230
|
+
await this.update({
|
|
4231
|
+
uri: hypercertUri,
|
|
4232
|
+
updates: {
|
|
4233
|
+
location: {
|
|
4234
|
+
$type: "com.atproto.repo.strongRef",
|
|
4235
|
+
uri: result.data.uri,
|
|
4236
|
+
cid: result.data.cid,
|
|
4237
|
+
},
|
|
4238
|
+
},
|
|
4239
|
+
});
|
|
3701
4240
|
this.emit("locationAttached", { uri: result.data.uri, cid: result.data.cid, hypercertUri });
|
|
3702
4241
|
return { uri: result.data.uri, cid: result.data.cid };
|
|
3703
4242
|
}
|
|
@@ -3708,36 +4247,75 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3708
4247
|
}
|
|
3709
4248
|
}
|
|
3710
4249
|
/**
|
|
3711
|
-
*
|
|
4250
|
+
* Generic helper to resolve string | Blob into a URI or blob reference.
|
|
4251
|
+
*
|
|
4252
|
+
* @param content - Either a URI string or a Blob to upload
|
|
4253
|
+
* @param fallbackMimeType - MIME type to use if Blob.type is empty
|
|
4254
|
+
* @returns Promise resolving to either a URI ref or blob ref union type
|
|
4255
|
+
* @internal
|
|
4256
|
+
*/
|
|
4257
|
+
async resolveUriOrBlob(content, fallbackMimeType) {
|
|
4258
|
+
if (typeof content === "string") {
|
|
4259
|
+
return {
|
|
4260
|
+
$type: "org.hypercerts.defs#uri",
|
|
4261
|
+
uri: content,
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
else {
|
|
4265
|
+
const uploadedBlob = await this.handleBlobUpload(content, fallbackMimeType);
|
|
4266
|
+
return {
|
|
4267
|
+
$type: "org.hypercerts.defs#smallBlob",
|
|
4268
|
+
blob: uploadedBlob,
|
|
4269
|
+
};
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* Adds evidence to any subject via the subject ref.
|
|
3712
4274
|
*
|
|
3713
|
-
* @param
|
|
3714
|
-
* @param evidence - Array of evidence items to add
|
|
4275
|
+
* @param evidence - HypercertEvidenceInput
|
|
3715
4276
|
* @returns Promise resolving to update result
|
|
3716
4277
|
* @throws {@link ValidationError} if validation fails
|
|
3717
4278
|
* @throws {@link NetworkError} if the operation fails
|
|
3718
4279
|
*
|
|
3719
|
-
* @remarks
|
|
3720
|
-
* Evidence is appended to existing evidence, not replaced.
|
|
3721
|
-
*
|
|
3722
4280
|
* @example
|
|
3723
4281
|
* ```typescript
|
|
3724
|
-
* await repo.hypercerts.addEvidence(
|
|
3725
|
-
*
|
|
3726
|
-
*
|
|
3727
|
-
*
|
|
4282
|
+
* await repo.hypercerts.addEvidence({
|
|
4283
|
+
* subjectUri: "at://did:plc:u7h3dstby64di67bxaotzxcz/org.hypercerts.claim.activity/3mbvv5d7ixh2g"
|
|
4284
|
+
* content: Blob,
|
|
4285
|
+
* title: "Meeting Notes",
|
|
4286
|
+
* shortDescription: "Meetings notes from the 3rd of December 2025",
|
|
4287
|
+
* description: "The meeting with the board of directors and audience on 2025 in regards to the ecological landscape",
|
|
4288
|
+
* relationType: "supports",
|
|
4289
|
+
* })
|
|
3728
4290
|
* ```
|
|
3729
4291
|
*/
|
|
3730
|
-
async addEvidence(
|
|
4292
|
+
async addEvidence(evidence) {
|
|
3731
4293
|
try {
|
|
3732
|
-
const
|
|
3733
|
-
const
|
|
3734
|
-
const
|
|
3735
|
-
const
|
|
3736
|
-
|
|
3737
|
-
|
|
4294
|
+
const { subjectUri, content, ...rest } = evidence;
|
|
4295
|
+
const subject = await this.get(subjectUri);
|
|
4296
|
+
const createdAt = new Date().toISOString();
|
|
4297
|
+
const evidenceContent = await this.resolveUriOrBlob(content, "application/octet-stream");
|
|
4298
|
+
const evidenceRecord = {
|
|
4299
|
+
...rest,
|
|
4300
|
+
$type: HYPERCERT_COLLECTIONS.EVIDENCE,
|
|
4301
|
+
createdAt,
|
|
4302
|
+
content: evidenceContent,
|
|
4303
|
+
subject: { uri: subject.uri, cid: subject.cid },
|
|
4304
|
+
};
|
|
4305
|
+
const validation = lexicon.validate(evidenceRecord, HYPERCERT_COLLECTIONS.EVIDENCE, "main", false);
|
|
4306
|
+
if (!validation.success) {
|
|
4307
|
+
throw new ValidationError(`Invalid evidence record: ${validation.error?.message}`);
|
|
4308
|
+
}
|
|
4309
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
4310
|
+
repo: this.repoDid,
|
|
4311
|
+
collection: HYPERCERT_COLLECTIONS.EVIDENCE,
|
|
4312
|
+
record: evidenceRecord,
|
|
3738
4313
|
});
|
|
3739
|
-
|
|
3740
|
-
|
|
4314
|
+
if (!result.success) {
|
|
4315
|
+
throw new NetworkError(`Failed to add evidence`);
|
|
4316
|
+
}
|
|
4317
|
+
this.emit("evidenceAdded", { uri: result.data.uri, cid: result.data.cid });
|
|
4318
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
3741
4319
|
}
|
|
3742
4320
|
catch (error) {
|
|
3743
4321
|
if (error instanceof ValidationError || error instanceof NetworkError)
|
|
@@ -3746,22 +4324,29 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3746
4324
|
}
|
|
3747
4325
|
}
|
|
3748
4326
|
/**
|
|
3749
|
-
* Creates a contribution record.
|
|
4327
|
+
* Creates a contribution details record.
|
|
4328
|
+
*
|
|
4329
|
+
* This creates a standalone contribution details record that can be referenced
|
|
4330
|
+
* from an activity's `contributors` array via a strong reference.
|
|
3750
4331
|
*
|
|
3751
4332
|
* @param params - Contribution parameters
|
|
3752
|
-
* @param params.hypercertUri - Optional hypercert
|
|
3753
|
-
* @param params.contributors - Array of contributor DIDs
|
|
3754
|
-
* @param params.role - Role of the
|
|
4333
|
+
* @param params.hypercertUri - Optional hypercert (unused, kept for backward compatibility)
|
|
4334
|
+
* @param params.contributors - Array of contributor DIDs (unused, kept for backward compatibility)
|
|
4335
|
+
* @param params.role - Role of the contributor (e.g., "coordinator", "implementer")
|
|
3755
4336
|
* @param params.description - Optional description of the contribution
|
|
3756
|
-
* @returns Promise resolving to contribution record URI and CID
|
|
4337
|
+
* @returns Promise resolving to contribution details record URI and CID
|
|
3757
4338
|
* @throws {@link ValidationError} if validation fails
|
|
3758
4339
|
* @throws {@link NetworkError} if the operation fails
|
|
3759
4340
|
*
|
|
4341
|
+
* @remarks
|
|
4342
|
+
* In the new lexicon structure, contributions are stored differently:
|
|
4343
|
+
* - Use `contributionDetails` for detailed contribution records (role, description, timeframe)
|
|
4344
|
+
* - Use `contributorInformation` for contributor profiles (identifier, displayName, image)
|
|
4345
|
+
* - Reference these from the activity's `contributors` array using strong refs
|
|
4346
|
+
*
|
|
3760
4347
|
* @example
|
|
3761
4348
|
* ```typescript
|
|
3762
4349
|
* await repo.hypercerts.addContribution({
|
|
3763
|
-
* hypercertUri: hypercertUri,
|
|
3764
|
-
* contributors: ["did:plc:alice", "did:plc:bob"],
|
|
3765
4350
|
* role: "implementer",
|
|
3766
4351
|
* description: "On-ground implementation team",
|
|
3767
4352
|
* });
|
|
@@ -3771,28 +4356,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3771
4356
|
try {
|
|
3772
4357
|
const createdAt = new Date().toISOString();
|
|
3773
4358
|
const contributionRecord = {
|
|
3774
|
-
$type: HYPERCERT_COLLECTIONS.
|
|
3775
|
-
contributors: params.contributors,
|
|
4359
|
+
$type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
|
|
3776
4360
|
role: params.role,
|
|
3777
4361
|
createdAt,
|
|
3778
|
-
|
|
3779
|
-
hypercert: { uri: "", cid: "" }, // Will be set below if hypercertUri provided
|
|
4362
|
+
contributionDescription: params.description,
|
|
3780
4363
|
};
|
|
3781
|
-
|
|
3782
|
-
const hypercert = await this.get(params.hypercertUri);
|
|
3783
|
-
contributionRecord.hypercert = { uri: hypercert.uri, cid: hypercert.cid };
|
|
3784
|
-
}
|
|
3785
|
-
const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION, "main", false);
|
|
4364
|
+
const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false);
|
|
3786
4365
|
if (!validation.success) {
|
|
3787
|
-
throw new ValidationError(`Invalid contribution record: ${validation.error?.message}`);
|
|
4366
|
+
throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`);
|
|
3788
4367
|
}
|
|
3789
4368
|
const result = await this.agent.com.atproto.repo.createRecord({
|
|
3790
4369
|
repo: this.repoDid,
|
|
3791
|
-
collection: HYPERCERT_COLLECTIONS.
|
|
4370
|
+
collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
|
|
3792
4371
|
record: contributionRecord,
|
|
3793
4372
|
});
|
|
3794
4373
|
if (!result.success) {
|
|
3795
|
-
throw new NetworkError("Failed to create contribution");
|
|
4374
|
+
throw new NetworkError("Failed to create contribution details");
|
|
3796
4375
|
}
|
|
3797
4376
|
this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid });
|
|
3798
4377
|
return { uri: result.data.uri, cid: result.data.cid };
|
|
@@ -3929,7 +4508,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3929
4508
|
* @param params.title - Collection title
|
|
3930
4509
|
* @param params.claims - Array of hypercert references with weights
|
|
3931
4510
|
* @param params.shortDescription - Optional short description
|
|
3932
|
-
* @param params.
|
|
4511
|
+
* @param params.banner - Optional cover image blob
|
|
3933
4512
|
* @returns Promise resolving to collection record URI and CID
|
|
3934
4513
|
* @throws {@link ValidationError} if validation fails
|
|
3935
4514
|
* @throws {@link NetworkError} if the operation fails
|
|
@@ -3944,22 +4523,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3944
4523
|
* { uri: hypercert2Uri, cid: hypercert2Cid, weight: "0.3" },
|
|
3945
4524
|
* { uri: hypercert3Uri, cid: hypercert3Cid, weight: "0.2" },
|
|
3946
4525
|
* ],
|
|
3947
|
-
*
|
|
4526
|
+
* banner: coverImageBlob,
|
|
3948
4527
|
* });
|
|
3949
4528
|
* ```
|
|
3950
4529
|
*/
|
|
3951
4530
|
async createCollection(params) {
|
|
3952
4531
|
try {
|
|
3953
4532
|
const createdAt = new Date().toISOString();
|
|
3954
|
-
let
|
|
3955
|
-
if (params.
|
|
3956
|
-
const arrayBuffer = await params.
|
|
4533
|
+
let bannerRef;
|
|
4534
|
+
if (params.banner) {
|
|
4535
|
+
const arrayBuffer = await params.banner.arrayBuffer();
|
|
3957
4536
|
const uint8Array = new Uint8Array(arrayBuffer);
|
|
3958
4537
|
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
3959
|
-
encoding: params.
|
|
4538
|
+
encoding: params.banner.type || "image/jpeg",
|
|
3960
4539
|
});
|
|
3961
4540
|
if (uploadResult.success) {
|
|
3962
|
-
|
|
4541
|
+
bannerRef = {
|
|
3963
4542
|
$type: "blob",
|
|
3964
4543
|
ref: { $link: uploadResult.data.blob.ref.toString() },
|
|
3965
4544
|
mimeType: uploadResult.data.blob.mimeType,
|
|
@@ -3976,8 +4555,8 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3976
4555
|
if (params.shortDescription) {
|
|
3977
4556
|
collectionRecord.shortDescription = params.shortDescription;
|
|
3978
4557
|
}
|
|
3979
|
-
if (
|
|
3980
|
-
collectionRecord.
|
|
4558
|
+
if (bannerRef) {
|
|
4559
|
+
collectionRecord.banner = bannerRef;
|
|
3981
4560
|
}
|
|
3982
4561
|
const validation = lexicon.validate(collectionRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
|
|
3983
4562
|
if (!validation.success) {
|
|
@@ -4088,154 +4667,570 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
4088
4667
|
throw new NetworkError(`Failed to list collections: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
4089
4668
|
}
|
|
4090
4669
|
}
|
|
4091
|
-
}
|
|
4092
|
-
|
|
4093
|
-
/**
|
|
4094
|
-
* CollaboratorOperationsImpl - SDS collaborator management operations.
|
|
4095
|
-
*
|
|
4096
|
-
* This module provides the implementation for managing collaborator
|
|
4097
|
-
* access on Shared Data Server (SDS) repositories.
|
|
4098
|
-
*
|
|
4099
|
-
* @packageDocumentation
|
|
4100
|
-
*/
|
|
4101
|
-
/**
|
|
4102
|
-
* Implementation of collaborator operations for SDS access control.
|
|
4103
|
-
*
|
|
4104
|
-
* This class manages access permissions for shared repositories on
|
|
4105
|
-
* Shared Data Servers (SDS). It provides role-based access control
|
|
4106
|
-
* with predefined permission sets.
|
|
4107
|
-
*
|
|
4108
|
-
* @remarks
|
|
4109
|
-
* This class is typically not instantiated directly. Access it through
|
|
4110
|
-
* {@link Repository.collaborators} on an SDS-connected repository.
|
|
4111
|
-
*
|
|
4112
|
-
* **Role Hierarchy**:
|
|
4113
|
-
* - `viewer`: Read-only access
|
|
4114
|
-
* - `editor`: Read + Create + Update
|
|
4115
|
-
* - `admin`: All permissions except ownership transfer
|
|
4116
|
-
* - `owner`: Full control including ownership management
|
|
4117
|
-
*
|
|
4118
|
-
* **SDS API Endpoints Used**:
|
|
4119
|
-
* - `com.sds.repo.grantAccess`: Grant access to a user
|
|
4120
|
-
* - `com.sds.repo.revokeAccess`: Revoke access from a user
|
|
4121
|
-
* - `com.sds.repo.listCollaborators`: List all collaborators
|
|
4122
|
-
* - `com.sds.repo.getPermissions`: Get current user's permissions
|
|
4123
|
-
* - `com.sds.repo.transferOwnership`: Transfer repository ownership
|
|
4124
|
-
*
|
|
4125
|
-
* @example
|
|
4126
|
-
* ```typescript
|
|
4127
|
-
* // Get SDS repository
|
|
4128
|
-
* const sdsRepo = sdk.repository(session, { server: "sds" });
|
|
4129
|
-
*
|
|
4130
|
-
* // Grant editor access
|
|
4131
|
-
* await sdsRepo.collaborators.grant({
|
|
4132
|
-
* userDid: "did:plc:new-user",
|
|
4133
|
-
* role: "editor",
|
|
4134
|
-
* });
|
|
4135
|
-
*
|
|
4136
|
-
* // List all collaborators
|
|
4137
|
-
* const collaborators = await sdsRepo.collaborators.list();
|
|
4138
|
-
*
|
|
4139
|
-
* // Check specific user
|
|
4140
|
-
* const hasAccess = await sdsRepo.collaborators.hasAccess("did:plc:someone");
|
|
4141
|
-
* const role = await sdsRepo.collaborators.getRole("did:plc:someone");
|
|
4142
|
-
* ```
|
|
4143
|
-
*
|
|
4144
|
-
* @internal
|
|
4145
|
-
*/
|
|
4146
|
-
class CollaboratorOperationsImpl {
|
|
4147
4670
|
/**
|
|
4148
|
-
* Creates a new
|
|
4671
|
+
* Creates a new project that organizes multiple hypercert activities.
|
|
4149
4672
|
*
|
|
4150
|
-
*
|
|
4151
|
-
* @param repoDid - DID of the repository to manage
|
|
4152
|
-
* @param serverUrl - SDS server URL
|
|
4673
|
+
* Projects are now implemented as collections with `type='project'`.
|
|
4153
4674
|
*
|
|
4154
|
-
* @
|
|
4155
|
-
|
|
4156
|
-
constructor(session, repoDid, serverUrl) {
|
|
4157
|
-
this.session = session;
|
|
4158
|
-
this.repoDid = repoDid;
|
|
4159
|
-
this.serverUrl = serverUrl;
|
|
4160
|
-
}
|
|
4161
|
-
/**
|
|
4162
|
-
* Converts a role to its corresponding permissions object.
|
|
4675
|
+
* @param params - Project creation parameters
|
|
4676
|
+
* @returns Promise resolving to created project URI and CID
|
|
4163
4677
|
*
|
|
4164
|
-
* @
|
|
4165
|
-
*
|
|
4166
|
-
*
|
|
4678
|
+
* @example
|
|
4679
|
+
* ```typescript
|
|
4680
|
+
* const result = await repo.hypercerts.createProject({
|
|
4681
|
+
* title: "Climate Impact 2024",
|
|
4682
|
+
* shortDescription: "Year-long climate initiative",
|
|
4683
|
+
* activities: [
|
|
4684
|
+
* { uri: activity1Uri, cid: activity1Cid, weight: "0.6" },
|
|
4685
|
+
* { uri: activity2Uri, cid: activity2Cid, weight: "0.4" }
|
|
4686
|
+
* ]
|
|
4687
|
+
* });
|
|
4688
|
+
* console.log(`Created project: ${result.uri}`);
|
|
4689
|
+
* ```
|
|
4167
4690
|
*/
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4691
|
+
async createProject(params) {
|
|
4692
|
+
try {
|
|
4693
|
+
const createdAt = new Date().toISOString();
|
|
4694
|
+
// Upload avatar blob if provided
|
|
4695
|
+
let avatarRef;
|
|
4696
|
+
if (params.avatar) {
|
|
4697
|
+
const arrayBuffer = await params.avatar.arrayBuffer();
|
|
4698
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
4699
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
4700
|
+
encoding: params.avatar.type || "image/jpeg",
|
|
4701
|
+
});
|
|
4702
|
+
if (uploadResult.success) {
|
|
4703
|
+
avatarRef = {
|
|
4704
|
+
$type: "blob",
|
|
4705
|
+
ref: { $link: uploadResult.data.blob.ref.toString() },
|
|
4706
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
4707
|
+
size: uploadResult.data.blob.size,
|
|
4708
|
+
};
|
|
4709
|
+
}
|
|
4710
|
+
else {
|
|
4711
|
+
throw new NetworkError("Failed to upload avatar image");
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4714
|
+
// Upload banner blob if provided
|
|
4715
|
+
let bannerRef;
|
|
4716
|
+
if (params.banner) {
|
|
4717
|
+
const arrayBuffer = await params.banner.arrayBuffer();
|
|
4718
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
4719
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
4720
|
+
encoding: params.banner.type || "image/jpeg",
|
|
4721
|
+
});
|
|
4722
|
+
if (uploadResult.success) {
|
|
4723
|
+
bannerRef = {
|
|
4724
|
+
$type: "blob",
|
|
4725
|
+
ref: { $link: uploadResult.data.blob.ref.toString() },
|
|
4726
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
4727
|
+
size: uploadResult.data.blob.size,
|
|
4728
|
+
};
|
|
4729
|
+
}
|
|
4730
|
+
else {
|
|
4731
|
+
throw new NetworkError("Failed to upload banner image");
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
// Build project record as a collection with type='project'
|
|
4735
|
+
// Collections require 'items' array, so we map activities to items
|
|
4736
|
+
const items = params.activities?.map((a) => ({
|
|
4737
|
+
itemIdentifier: { uri: a.uri, cid: a.cid },
|
|
4738
|
+
itemWeight: a.weight,
|
|
4739
|
+
})) || [];
|
|
4740
|
+
const projectRecord = {
|
|
4741
|
+
$type: HYPERCERT_COLLECTIONS.COLLECTION,
|
|
4742
|
+
type: "project",
|
|
4743
|
+
title: params.title,
|
|
4744
|
+
shortDescription: params.shortDescription,
|
|
4745
|
+
items,
|
|
4746
|
+
createdAt,
|
|
4747
|
+
};
|
|
4748
|
+
// Add optional fields
|
|
4749
|
+
if (params.description) {
|
|
4750
|
+
projectRecord.description = params.description;
|
|
4751
|
+
}
|
|
4752
|
+
if (avatarRef) {
|
|
4753
|
+
projectRecord.avatar = avatarRef;
|
|
4754
|
+
}
|
|
4755
|
+
if (bannerRef) {
|
|
4756
|
+
projectRecord.banner = bannerRef;
|
|
4757
|
+
}
|
|
4758
|
+
// Validate against collection lexicon
|
|
4759
|
+
const validation = lexicon.validate(projectRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
|
|
4760
|
+
if (!validation.success) {
|
|
4761
|
+
throw new ValidationError(`Invalid project record: ${validation.error?.message}`);
|
|
4762
|
+
}
|
|
4763
|
+
// Create record
|
|
4764
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
4765
|
+
repo: this.repoDid,
|
|
4766
|
+
collection: HYPERCERT_COLLECTIONS.COLLECTION,
|
|
4767
|
+
record: projectRecord,
|
|
4768
|
+
});
|
|
4769
|
+
if (!result.success) {
|
|
4770
|
+
throw new NetworkError("Failed to create project");
|
|
4771
|
+
}
|
|
4772
|
+
// Emit event
|
|
4773
|
+
this.emit("projectCreated", { uri: result.data.uri, cid: result.data.cid });
|
|
4774
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
4775
|
+
}
|
|
4776
|
+
catch (error) {
|
|
4777
|
+
if (error instanceof ValidationError || error instanceof NetworkError)
|
|
4778
|
+
throw error;
|
|
4779
|
+
throw new NetworkError(`Failed to create project: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
4178
4780
|
}
|
|
4179
4781
|
}
|
|
4180
4782
|
/**
|
|
4181
|
-
*
|
|
4783
|
+
* Gets a project by its AT-URI.
|
|
4182
4784
|
*
|
|
4183
|
-
*
|
|
4184
|
-
* @returns The highest role matching the permissions
|
|
4185
|
-
* @internal
|
|
4186
|
-
*/
|
|
4187
|
-
permissionsToRole(permissions) {
|
|
4188
|
-
if (permissions.owner)
|
|
4189
|
-
return "owner";
|
|
4190
|
-
if (permissions.admin)
|
|
4191
|
-
return "admin";
|
|
4192
|
-
if (permissions.create || permissions.update)
|
|
4193
|
-
return "editor";
|
|
4194
|
-
return "viewer";
|
|
4195
|
-
}
|
|
4196
|
-
/**
|
|
4197
|
-
* Normalizes permissions from SDS API format to SDK format.
|
|
4785
|
+
* Projects are collections with `type='project'`.
|
|
4198
4786
|
*
|
|
4199
|
-
*
|
|
4200
|
-
*
|
|
4201
|
-
* This method ensures all expected fields are present with default values.
|
|
4787
|
+
* @param uri - AT-URI of the project
|
|
4788
|
+
* @returns Promise resolving to project data (as collection)
|
|
4202
4789
|
*
|
|
4203
|
-
* @
|
|
4204
|
-
*
|
|
4205
|
-
*
|
|
4790
|
+
* @example
|
|
4791
|
+
* ```typescript
|
|
4792
|
+
* const { record } = await repo.hypercerts.getProject(projectUri);
|
|
4793
|
+
* console.log(`${record.title}: ${record.items?.length || 0} activities`);
|
|
4794
|
+
* ```
|
|
4206
4795
|
*/
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4796
|
+
async getProject(uri) {
|
|
4797
|
+
try {
|
|
4798
|
+
// Parse URI
|
|
4799
|
+
const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
4800
|
+
if (!uriMatch) {
|
|
4801
|
+
throw new ValidationError(`Invalid URI format: ${uri}`);
|
|
4802
|
+
}
|
|
4803
|
+
const [, , collection, rkey] = uriMatch;
|
|
4804
|
+
// Fetch record
|
|
4805
|
+
const result = await this.agent.com.atproto.repo.getRecord({
|
|
4806
|
+
repo: this.repoDid,
|
|
4807
|
+
collection,
|
|
4808
|
+
rkey,
|
|
4809
|
+
});
|
|
4810
|
+
if (!result.success) {
|
|
4811
|
+
throw new NetworkError("Failed to get project");
|
|
4812
|
+
}
|
|
4813
|
+
// Validate as collection
|
|
4814
|
+
const validation = lexicon.validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
|
|
4815
|
+
if (!validation.success) {
|
|
4816
|
+
throw new ValidationError(`Invalid project record format: ${validation.error?.message}`);
|
|
4817
|
+
}
|
|
4818
|
+
// Verify it's actually a project (collection with type='project')
|
|
4819
|
+
const record = result.data.value;
|
|
4820
|
+
if (record.type !== "project") {
|
|
4821
|
+
throw new ValidationError(`Record is not a project (type='${record.type}')`);
|
|
4822
|
+
}
|
|
4823
|
+
return {
|
|
4824
|
+
uri: result.data.uri,
|
|
4825
|
+
cid: result.data.cid ?? "",
|
|
4826
|
+
record,
|
|
4827
|
+
};
|
|
4828
|
+
}
|
|
4829
|
+
catch (error) {
|
|
4830
|
+
if (error instanceof ValidationError || error instanceof NetworkError)
|
|
4831
|
+
throw error;
|
|
4832
|
+
throw new NetworkError(`Failed to get project: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
4833
|
+
}
|
|
4216
4834
|
}
|
|
4217
4835
|
/**
|
|
4218
|
-
*
|
|
4836
|
+
* Lists all projects with optional pagination.
|
|
4219
4837
|
*
|
|
4220
|
-
*
|
|
4221
|
-
*
|
|
4222
|
-
* @param params.role - Role to assign (determines permissions)
|
|
4223
|
-
* @throws {@link NetworkError} if the grant operation fails
|
|
4838
|
+
* Projects are collections with `type='project'`. This method filters
|
|
4839
|
+
* collections to only return those with type='project'.
|
|
4224
4840
|
*
|
|
4225
|
-
* @
|
|
4226
|
-
*
|
|
4227
|
-
* to the new role.
|
|
4841
|
+
* @param params - Optional pagination parameters
|
|
4842
|
+
* @returns Promise resolving to paginated list of projects
|
|
4228
4843
|
*
|
|
4229
4844
|
* @example
|
|
4230
4845
|
* ```typescript
|
|
4231
|
-
*
|
|
4232
|
-
*
|
|
4233
|
-
*
|
|
4234
|
-
*
|
|
4235
|
-
*
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4846
|
+
* const { records } = await repo.hypercerts.listProjects();
|
|
4847
|
+
* for (const { record } of records) {
|
|
4848
|
+
* console.log(`${record.title}: ${record.shortDescription}`);
|
|
4849
|
+
* }
|
|
4850
|
+
* ```
|
|
4851
|
+
*/
|
|
4852
|
+
async listProjects(params) {
|
|
4853
|
+
try {
|
|
4854
|
+
const limit = params?.limit;
|
|
4855
|
+
let cursor = params?.cursor;
|
|
4856
|
+
const allRecords = [];
|
|
4857
|
+
// Loop-fetch until we have enough projects or no more cursor
|
|
4858
|
+
while (!cursor || allRecords.length < (limit ?? Infinity)) {
|
|
4859
|
+
const result = await this.agent.com.atproto.repo.listRecords({
|
|
4860
|
+
repo: this.repoDid,
|
|
4861
|
+
collection: HYPERCERT_COLLECTIONS.COLLECTION,
|
|
4862
|
+
limit: limit ?? 50,
|
|
4863
|
+
cursor,
|
|
4864
|
+
});
|
|
4865
|
+
if (!result.success) {
|
|
4866
|
+
throw new NetworkError("Failed to list projects");
|
|
4867
|
+
}
|
|
4868
|
+
// Filter and collect project records
|
|
4869
|
+
for (const r of result.data.records ?? []) {
|
|
4870
|
+
const record = r.value;
|
|
4871
|
+
if (record.type === "project") {
|
|
4872
|
+
allRecords.push({ uri: r.uri, cid: r.cid, record });
|
|
4873
|
+
}
|
|
4874
|
+
// Stop if we've collected enough
|
|
4875
|
+
if (limit && allRecords.length >= limit)
|
|
4876
|
+
break;
|
|
4877
|
+
}
|
|
4878
|
+
// Update cursor; break if no more pages
|
|
4879
|
+
cursor = result.data.cursor;
|
|
4880
|
+
if (!cursor)
|
|
4881
|
+
break;
|
|
4882
|
+
}
|
|
4883
|
+
return { records: allRecords, cursor };
|
|
4884
|
+
}
|
|
4885
|
+
catch (error) {
|
|
4886
|
+
if (error instanceof NetworkError)
|
|
4887
|
+
throw error;
|
|
4888
|
+
throw new NetworkError(`Failed to list projects: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
/**
|
|
4892
|
+
* Updates an existing project.
|
|
4893
|
+
*
|
|
4894
|
+
* Projects are collections with `type='project'`.
|
|
4895
|
+
*
|
|
4896
|
+
* @param uri - AT-URI of the project to update
|
|
4897
|
+
* @param updates - Fields to update
|
|
4898
|
+
* @returns Promise resolving to updated project URI and CID
|
|
4899
|
+
*
|
|
4900
|
+
* @example
|
|
4901
|
+
* ```typescript
|
|
4902
|
+
* const result = await repo.hypercerts.updateProject(projectUri, {
|
|
4903
|
+
* title: "Updated Project Title",
|
|
4904
|
+
* shortDescription: "New description"
|
|
4905
|
+
* });
|
|
4906
|
+
* ```
|
|
4907
|
+
*/
|
|
4908
|
+
async updateProject(uri, updates) {
|
|
4909
|
+
try {
|
|
4910
|
+
// Parse URI and fetch existing record
|
|
4911
|
+
const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
4912
|
+
if (!uriMatch) {
|
|
4913
|
+
throw new ValidationError(`Invalid URI format: ${uri}`);
|
|
4914
|
+
}
|
|
4915
|
+
const [, , collection, rkey] = uriMatch;
|
|
4916
|
+
const existing = await this.agent.com.atproto.repo.getRecord({
|
|
4917
|
+
repo: this.repoDid,
|
|
4918
|
+
collection,
|
|
4919
|
+
rkey,
|
|
4920
|
+
});
|
|
4921
|
+
if (!existing.success) {
|
|
4922
|
+
throw new NetworkError(`Project not found: ${uri}`);
|
|
4923
|
+
}
|
|
4924
|
+
const existingRecord = existing.data.value;
|
|
4925
|
+
// Verify it's actually a project
|
|
4926
|
+
if (existingRecord.type !== "project") {
|
|
4927
|
+
throw new ValidationError(`Record is not a project (type='${existingRecord.type}')`);
|
|
4928
|
+
}
|
|
4929
|
+
// Merge updates with existing record
|
|
4930
|
+
const recordForUpdate = {
|
|
4931
|
+
...existingRecord,
|
|
4932
|
+
// MUST preserve type, createdAt, and items structure
|
|
4933
|
+
type: "project",
|
|
4934
|
+
createdAt: existingRecord.createdAt,
|
|
4935
|
+
};
|
|
4936
|
+
// Apply simple field updates
|
|
4937
|
+
if (updates.title !== undefined)
|
|
4938
|
+
recordForUpdate.title = updates.title;
|
|
4939
|
+
if (updates.shortDescription !== undefined)
|
|
4940
|
+
recordForUpdate.shortDescription = updates.shortDescription;
|
|
4941
|
+
if (updates.description !== undefined)
|
|
4942
|
+
recordForUpdate.description = updates.description;
|
|
4943
|
+
// Handle avatar update with three-way logic
|
|
4944
|
+
delete recordForUpdate.avatar;
|
|
4945
|
+
if (updates.avatar !== undefined) {
|
|
4946
|
+
if (updates.avatar === null) {
|
|
4947
|
+
// Remove avatar
|
|
4948
|
+
}
|
|
4949
|
+
else {
|
|
4950
|
+
const arrayBuffer = await updates.avatar.arrayBuffer();
|
|
4951
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
4952
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
4953
|
+
encoding: updates.avatar.type || "image/jpeg",
|
|
4954
|
+
});
|
|
4955
|
+
if (uploadResult.success) {
|
|
4956
|
+
recordForUpdate.avatar = {
|
|
4957
|
+
$type: "blob",
|
|
4958
|
+
ref: uploadResult.data.blob.ref,
|
|
4959
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
4960
|
+
size: uploadResult.data.blob.size,
|
|
4961
|
+
};
|
|
4962
|
+
}
|
|
4963
|
+
else {
|
|
4964
|
+
throw new NetworkError("Failed to upload avatar image");
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4968
|
+
else if (existingRecord.avatar) {
|
|
4969
|
+
recordForUpdate.avatar = existingRecord.avatar;
|
|
4970
|
+
}
|
|
4971
|
+
// Handle banner update
|
|
4972
|
+
delete recordForUpdate.banner;
|
|
4973
|
+
if (updates.banner !== undefined) {
|
|
4974
|
+
if (updates.banner === null) {
|
|
4975
|
+
// Remove banner
|
|
4976
|
+
}
|
|
4977
|
+
else {
|
|
4978
|
+
const arrayBuffer = await updates.banner.arrayBuffer();
|
|
4979
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
4980
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
4981
|
+
encoding: updates.banner.type || "image/jpeg",
|
|
4982
|
+
});
|
|
4983
|
+
if (uploadResult.success) {
|
|
4984
|
+
recordForUpdate.banner = {
|
|
4985
|
+
$type: "blob",
|
|
4986
|
+
ref: uploadResult.data.blob.ref,
|
|
4987
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
4988
|
+
size: uploadResult.data.blob.size,
|
|
4989
|
+
};
|
|
4990
|
+
}
|
|
4991
|
+
else {
|
|
4992
|
+
throw new NetworkError("Failed to upload banner image");
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
}
|
|
4996
|
+
else if (existingRecord.banner) {
|
|
4997
|
+
recordForUpdate.banner = existingRecord.banner;
|
|
4998
|
+
}
|
|
4999
|
+
// Transform activities to items array
|
|
5000
|
+
if (updates.activities) {
|
|
5001
|
+
recordForUpdate.items = updates.activities.map((a) => ({
|
|
5002
|
+
itemIdentifier: { uri: a.uri, cid: a.cid },
|
|
5003
|
+
itemWeight: a.weight,
|
|
5004
|
+
}));
|
|
5005
|
+
}
|
|
5006
|
+
else {
|
|
5007
|
+
// Preserve existing items
|
|
5008
|
+
recordForUpdate.items = existingRecord.items;
|
|
5009
|
+
}
|
|
5010
|
+
// Validate merged record
|
|
5011
|
+
const validation = lexicon.validate(recordForUpdate, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
|
|
5012
|
+
if (!validation.success) {
|
|
5013
|
+
throw new ValidationError(`Invalid project record: ${validation.error?.message}`);
|
|
5014
|
+
}
|
|
5015
|
+
// Update record
|
|
5016
|
+
const result = await this.agent.com.atproto.repo.putRecord({
|
|
5017
|
+
repo: this.repoDid,
|
|
5018
|
+
collection,
|
|
5019
|
+
rkey,
|
|
5020
|
+
record: recordForUpdate,
|
|
5021
|
+
});
|
|
5022
|
+
if (!result.success) {
|
|
5023
|
+
throw new NetworkError("Failed to update project");
|
|
5024
|
+
}
|
|
5025
|
+
// Emit event
|
|
5026
|
+
this.emit("projectUpdated", { uri: result.data.uri, cid: result.data.cid });
|
|
5027
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
5028
|
+
}
|
|
5029
|
+
catch (error) {
|
|
5030
|
+
if (error instanceof ValidationError || error instanceof NetworkError)
|
|
5031
|
+
throw error;
|
|
5032
|
+
throw new NetworkError(`Failed to update project: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
5033
|
+
}
|
|
5034
|
+
}
|
|
5035
|
+
/**
|
|
5036
|
+
* Deletes a project.
|
|
5037
|
+
*
|
|
5038
|
+
* Projects are collections with `type='project'`.
|
|
5039
|
+
*
|
|
5040
|
+
* @param uri - AT-URI of the project to delete
|
|
5041
|
+
*
|
|
5042
|
+
* @example
|
|
5043
|
+
* ```typescript
|
|
5044
|
+
* await repo.hypercerts.deleteProject(projectUri);
|
|
5045
|
+
* console.log("Project deleted");
|
|
5046
|
+
* ```
|
|
5047
|
+
*/
|
|
5048
|
+
async deleteProject(uri) {
|
|
5049
|
+
try {
|
|
5050
|
+
// Parse URI
|
|
5051
|
+
const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
5052
|
+
if (!uriMatch) {
|
|
5053
|
+
throw new ValidationError(`Invalid URI format: ${uri}`);
|
|
5054
|
+
}
|
|
5055
|
+
const [, , collection, rkey] = uriMatch;
|
|
5056
|
+
// Verify it's actually a project before deleting
|
|
5057
|
+
const existing = await this.agent.com.atproto.repo.getRecord({
|
|
5058
|
+
repo: this.repoDid,
|
|
5059
|
+
collection,
|
|
5060
|
+
rkey,
|
|
5061
|
+
});
|
|
5062
|
+
if (existing.success) {
|
|
5063
|
+
const record = existing.data.value;
|
|
5064
|
+
if (record.type !== "project") {
|
|
5065
|
+
throw new ValidationError(`Record is not a project (type='${record.type}')`);
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
// Delete record
|
|
5069
|
+
const result = await this.agent.com.atproto.repo.deleteRecord({
|
|
5070
|
+
repo: this.repoDid,
|
|
5071
|
+
collection,
|
|
5072
|
+
rkey,
|
|
5073
|
+
});
|
|
5074
|
+
if (!result.success) {
|
|
5075
|
+
throw new NetworkError("Failed to delete project");
|
|
5076
|
+
}
|
|
5077
|
+
// Emit event
|
|
5078
|
+
this.emit("projectDeleted", { uri });
|
|
5079
|
+
}
|
|
5080
|
+
catch (error) {
|
|
5081
|
+
if (error instanceof ValidationError || error instanceof NetworkError)
|
|
5082
|
+
throw error;
|
|
5083
|
+
throw new NetworkError(`Failed to delete project: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
5084
|
+
}
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
/**
|
|
5089
|
+
* CollaboratorOperationsImpl - SDS collaborator management operations.
|
|
5090
|
+
*
|
|
5091
|
+
* This module provides the implementation for managing collaborator
|
|
5092
|
+
* access on Shared Data Server (SDS) repositories.
|
|
5093
|
+
*
|
|
5094
|
+
* @packageDocumentation
|
|
5095
|
+
*/
|
|
5096
|
+
/**
|
|
5097
|
+
* Implementation of collaborator operations for SDS access control.
|
|
5098
|
+
*
|
|
5099
|
+
* This class manages access permissions for shared repositories on
|
|
5100
|
+
* Shared Data Servers (SDS). It provides role-based access control
|
|
5101
|
+
* with predefined permission sets.
|
|
5102
|
+
*
|
|
5103
|
+
* @remarks
|
|
5104
|
+
* This class is typically not instantiated directly. Access it through
|
|
5105
|
+
* {@link Repository.collaborators} on an SDS-connected repository.
|
|
5106
|
+
*
|
|
5107
|
+
* **Role Hierarchy**:
|
|
5108
|
+
* - `viewer`: Read-only access
|
|
5109
|
+
* - `editor`: Read + Create + Update
|
|
5110
|
+
* - `admin`: All permissions except ownership transfer
|
|
5111
|
+
* - `owner`: Full control including ownership management
|
|
5112
|
+
*
|
|
5113
|
+
* **SDS API Endpoints Used**:
|
|
5114
|
+
* - `com.sds.repo.grantAccess`: Grant access to a user
|
|
5115
|
+
* - `com.sds.repo.revokeAccess`: Revoke access from a user
|
|
5116
|
+
* - `com.sds.repo.listCollaborators`: List all collaborators
|
|
5117
|
+
* - `com.sds.repo.getPermissions`: Get current user's permissions
|
|
5118
|
+
* - `com.sds.repo.transferOwnership`: Transfer repository ownership
|
|
5119
|
+
*
|
|
5120
|
+
* @example
|
|
5121
|
+
* ```typescript
|
|
5122
|
+
* // Get SDS repository
|
|
5123
|
+
* const sdsRepo = sdk.repository(session, { server: "sds" });
|
|
5124
|
+
*
|
|
5125
|
+
* // Grant editor access
|
|
5126
|
+
* await sdsRepo.collaborators.grant({
|
|
5127
|
+
* userDid: "did:plc:new-user",
|
|
5128
|
+
* role: "editor",
|
|
5129
|
+
* });
|
|
5130
|
+
*
|
|
5131
|
+
* // List all collaborators
|
|
5132
|
+
* const collaborators = await sdsRepo.collaborators.list();
|
|
5133
|
+
*
|
|
5134
|
+
* // Check specific user
|
|
5135
|
+
* const hasAccess = await sdsRepo.collaborators.hasAccess("did:plc:someone");
|
|
5136
|
+
* const role = await sdsRepo.collaborators.getRole("did:plc:someone");
|
|
5137
|
+
* ```
|
|
5138
|
+
*
|
|
5139
|
+
* @internal
|
|
5140
|
+
*/
|
|
5141
|
+
class CollaboratorOperationsImpl {
|
|
5142
|
+
/**
|
|
5143
|
+
* Creates a new CollaboratorOperationsImpl.
|
|
5144
|
+
*
|
|
5145
|
+
* @param session - Authenticated OAuth session with fetchHandler
|
|
5146
|
+
* @param repoDid - DID of the repository to manage
|
|
5147
|
+
* @param serverUrl - SDS server URL
|
|
5148
|
+
*
|
|
5149
|
+
* @internal
|
|
5150
|
+
*/
|
|
5151
|
+
constructor(session, repoDid, serverUrl) {
|
|
5152
|
+
this.session = session;
|
|
5153
|
+
this.repoDid = repoDid;
|
|
5154
|
+
this.serverUrl = serverUrl;
|
|
5155
|
+
}
|
|
5156
|
+
/**
|
|
5157
|
+
* Converts a role to its corresponding permissions object.
|
|
5158
|
+
*
|
|
5159
|
+
* @param role - The role to convert
|
|
5160
|
+
* @returns Permission flags for the role
|
|
5161
|
+
* @internal
|
|
5162
|
+
*/
|
|
5163
|
+
roleToPermissions(role) {
|
|
5164
|
+
switch (role) {
|
|
5165
|
+
case "viewer":
|
|
5166
|
+
return { read: true, create: false, update: false, delete: false, admin: false, owner: false };
|
|
5167
|
+
case "editor":
|
|
5168
|
+
return { read: true, create: true, update: true, delete: false, admin: false, owner: false };
|
|
5169
|
+
case "admin":
|
|
5170
|
+
return { read: true, create: true, update: true, delete: true, admin: true, owner: false };
|
|
5171
|
+
case "owner":
|
|
5172
|
+
return { read: true, create: true, update: true, delete: true, admin: true, owner: true };
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
5175
|
+
/**
|
|
5176
|
+
* Determines the role from a permissions object.
|
|
5177
|
+
*
|
|
5178
|
+
* @param permissions - The permissions to analyze
|
|
5179
|
+
* @returns The highest role matching the permissions
|
|
5180
|
+
* @internal
|
|
5181
|
+
*/
|
|
5182
|
+
permissionsToRole(permissions) {
|
|
5183
|
+
if (permissions.owner)
|
|
5184
|
+
return "owner";
|
|
5185
|
+
if (permissions.admin)
|
|
5186
|
+
return "admin";
|
|
5187
|
+
if (permissions.create || permissions.update)
|
|
5188
|
+
return "editor";
|
|
5189
|
+
return "viewer";
|
|
5190
|
+
}
|
|
5191
|
+
/**
|
|
5192
|
+
* Normalizes permissions from SDS API format to SDK format.
|
|
5193
|
+
*
|
|
5194
|
+
* The SDS API returns permissions as an object with boolean flags
|
|
5195
|
+
* (e.g., `{ read: true, create: true, update: false, ... }`).
|
|
5196
|
+
* This method ensures all expected fields are present with default values.
|
|
5197
|
+
*
|
|
5198
|
+
* @param permissions - Permissions object from SDS API
|
|
5199
|
+
* @returns Normalized permission flags object
|
|
5200
|
+
* @internal
|
|
5201
|
+
*/
|
|
5202
|
+
parsePermissions(permissions) {
|
|
5203
|
+
return {
|
|
5204
|
+
read: permissions.read ?? false,
|
|
5205
|
+
create: permissions.create ?? false,
|
|
5206
|
+
update: permissions.update ?? false,
|
|
5207
|
+
delete: permissions.delete ?? false,
|
|
5208
|
+
admin: permissions.admin ?? false,
|
|
5209
|
+
owner: permissions.owner ?? false,
|
|
5210
|
+
};
|
|
5211
|
+
}
|
|
5212
|
+
/**
|
|
5213
|
+
* Grants repository access to a user.
|
|
5214
|
+
*
|
|
5215
|
+
* @param params - Grant parameters
|
|
5216
|
+
* @param params.userDid - DID of the user to grant access to
|
|
5217
|
+
* @param params.role - Role to assign (determines permissions)
|
|
5218
|
+
* @throws {@link NetworkError} if the grant operation fails
|
|
5219
|
+
*
|
|
5220
|
+
* @remarks
|
|
5221
|
+
* If the user already has access, their permissions are updated
|
|
5222
|
+
* to the new role.
|
|
5223
|
+
*
|
|
5224
|
+
* @example
|
|
5225
|
+
* ```typescript
|
|
5226
|
+
* // Grant viewer access
|
|
5227
|
+
* await repo.collaborators.grant({
|
|
5228
|
+
* userDid: "did:plc:viewer-user",
|
|
5229
|
+
* role: "viewer",
|
|
5230
|
+
* });
|
|
5231
|
+
*
|
|
5232
|
+
* // Upgrade to editor
|
|
5233
|
+
* await repo.collaborators.grant({
|
|
4239
5234
|
* userDid: "did:plc:viewer-user",
|
|
4240
5235
|
* role: "editor",
|
|
4241
5236
|
* });
|
|
@@ -4600,6 +5595,12 @@ class OrganizationOperationsImpl {
|
|
|
4600
5595
|
if (!userDid) {
|
|
4601
5596
|
throw new NetworkError("No authenticated user found");
|
|
4602
5597
|
}
|
|
5598
|
+
if (!params.handlePrefix) {
|
|
5599
|
+
throw new ValidationError("Missing handlePrefix");
|
|
5600
|
+
}
|
|
5601
|
+
if (!params.name) {
|
|
5602
|
+
throw new ValidationError("Missing name");
|
|
5603
|
+
}
|
|
4603
5604
|
const response = await this.session.fetchHandler(`${this.serverUrl}/xrpc/com.sds.organization.create`, {
|
|
4604
5605
|
method: "POST",
|
|
4605
5606
|
headers: { "Content-Type": "application/json" },
|
|
@@ -4832,6 +5833,7 @@ class Repository {
|
|
|
4832
5833
|
* @param repoDid - DID of the repository to operate on
|
|
4833
5834
|
* @param isSDS - Whether this is a Shared Data Server
|
|
4834
5835
|
* @param logger - Optional logger for debugging
|
|
5836
|
+
* @param lexiconRegistry - Registry for custom lexicon management
|
|
4835
5837
|
*
|
|
4836
5838
|
* @remarks
|
|
4837
5839
|
* This constructor is typically not called directly. Use
|
|
@@ -4839,12 +5841,13 @@ class Repository {
|
|
|
4839
5841
|
*
|
|
4840
5842
|
* @internal
|
|
4841
5843
|
*/
|
|
4842
|
-
constructor(session, serverUrl, repoDid, isSDS, logger) {
|
|
5844
|
+
constructor(session, serverUrl, repoDid, isSDS, logger, lexiconRegistry) {
|
|
4843
5845
|
this.session = session;
|
|
4844
5846
|
this.serverUrl = serverUrl;
|
|
4845
5847
|
this.repoDid = repoDid;
|
|
4846
5848
|
this._isSDS = isSDS;
|
|
4847
5849
|
this.logger = logger;
|
|
5850
|
+
this.lexiconRegistry = lexiconRegistry || new LexiconRegistry();
|
|
4848
5851
|
// Create a ConfigurableAgent that routes requests to the specified server URL
|
|
4849
5852
|
// This allows routing to PDS, SDS, or any custom server while maintaining
|
|
4850
5853
|
// the OAuth session's authentication
|
|
@@ -4919,7 +5922,53 @@ class Repository {
|
|
|
4919
5922
|
* ```
|
|
4920
5923
|
*/
|
|
4921
5924
|
repo(did) {
|
|
4922
|
-
return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger);
|
|
5925
|
+
return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger, this.lexiconRegistry);
|
|
5926
|
+
}
|
|
5927
|
+
/**
|
|
5928
|
+
* Gets the LexiconRegistry instance for managing custom lexicons.
|
|
5929
|
+
*
|
|
5930
|
+
* The registry is shared across all operations in this repository and
|
|
5931
|
+
* enables validation of custom record types.
|
|
5932
|
+
*
|
|
5933
|
+
* @returns The {@link LexiconRegistry} instance
|
|
5934
|
+
*
|
|
5935
|
+
* @example
|
|
5936
|
+
* ```typescript
|
|
5937
|
+
* // Access the registry
|
|
5938
|
+
* const registry = repo.getLexiconRegistry();
|
|
5939
|
+
*
|
|
5940
|
+
* // Register a custom lexicon
|
|
5941
|
+
* registry.register({
|
|
5942
|
+
* lexicon: 1,
|
|
5943
|
+
* id: "org.myapp.evaluation",
|
|
5944
|
+
* defs: {
|
|
5945
|
+
* main: {
|
|
5946
|
+
* type: "record",
|
|
5947
|
+
* key: "tid",
|
|
5948
|
+
* record: {
|
|
5949
|
+
* type: "object",
|
|
5950
|
+
* required: ["$type", "score"],
|
|
5951
|
+
* properties: {
|
|
5952
|
+
* "$type": { type: "string", const: "org.myapp.evaluation" },
|
|
5953
|
+
* score: { type: "integer", minimum: 0, maximum: 100 }
|
|
5954
|
+
* }
|
|
5955
|
+
* }
|
|
5956
|
+
* }
|
|
5957
|
+
* }
|
|
5958
|
+
* });
|
|
5959
|
+
*
|
|
5960
|
+
* // Now create records using the custom lexicon
|
|
5961
|
+
* await repo.records.create({
|
|
5962
|
+
* collection: "org.myapp.evaluation",
|
|
5963
|
+
* record: {
|
|
5964
|
+
* $type: "org.myapp.evaluation",
|
|
5965
|
+
* score: 85
|
|
5966
|
+
* }
|
|
5967
|
+
* });
|
|
5968
|
+
* ```
|
|
5969
|
+
*/
|
|
5970
|
+
getLexiconRegistry() {
|
|
5971
|
+
return this.lexiconRegistry;
|
|
4923
5972
|
}
|
|
4924
5973
|
/**
|
|
4925
5974
|
* Low-level record operations for CRUD on any AT Protocol record type.
|
|
@@ -4952,7 +6001,7 @@ class Repository {
|
|
|
4952
6001
|
*/
|
|
4953
6002
|
get records() {
|
|
4954
6003
|
if (!this._records) {
|
|
4955
|
-
this._records = new RecordOperationsImpl(this.agent, this.repoDid);
|
|
6004
|
+
this._records = new RecordOperationsImpl(this.agent, this.repoDid, this.lexiconRegistry);
|
|
4956
6005
|
}
|
|
4957
6006
|
return this._records;
|
|
4958
6007
|
}
|
|
@@ -5129,26 +6178,67 @@ class Repository {
|
|
|
5129
6178
|
}
|
|
5130
6179
|
}
|
|
5131
6180
|
|
|
6181
|
+
/**
|
|
6182
|
+
* Custom URL validator that allows HTTP loopback addresses for development.
|
|
6183
|
+
*
|
|
6184
|
+
* Accepts:
|
|
6185
|
+
* - Any HTTPS URL (production)
|
|
6186
|
+
* - http://localhost (with optional port and path)
|
|
6187
|
+
* - http://127.0.0.1 (with optional port and path)
|
|
6188
|
+
* - http://[::1] (with optional port and path) - IPv6 loopback
|
|
6189
|
+
*
|
|
6190
|
+
* Rejects:
|
|
6191
|
+
* - Other HTTP URLs (e.g., http://example.com)
|
|
6192
|
+
* - Invalid URLs
|
|
6193
|
+
*
|
|
6194
|
+
* @internal
|
|
6195
|
+
*/
|
|
6196
|
+
const urlOrLoopback = zod.z.string().refine((value) => {
|
|
6197
|
+
try {
|
|
6198
|
+
const url = new URL(value);
|
|
6199
|
+
// Always allow HTTPS
|
|
6200
|
+
if (url.protocol === "https:") {
|
|
6201
|
+
return true;
|
|
6202
|
+
}
|
|
6203
|
+
// For HTTP, only allow loopback addresses
|
|
6204
|
+
if (url.protocol === "http:") {
|
|
6205
|
+
const hostname = url.hostname.toLowerCase();
|
|
6206
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
6207
|
+
}
|
|
6208
|
+
return false;
|
|
6209
|
+
}
|
|
6210
|
+
catch {
|
|
6211
|
+
return false;
|
|
6212
|
+
}
|
|
6213
|
+
}, {
|
|
6214
|
+
message: "Must be a valid HTTPS URL or HTTP loopback URL (localhost, 127.0.0.1, [::1])",
|
|
6215
|
+
});
|
|
5132
6216
|
/**
|
|
5133
6217
|
* Zod schema for OAuth configuration validation.
|
|
5134
6218
|
*
|
|
5135
6219
|
* @remarks
|
|
5136
|
-
* All URLs must be valid and use HTTPS in production.
|
|
5137
|
-
*
|
|
6220
|
+
* All URLs must be valid and use HTTPS in production. For local development,
|
|
6221
|
+
* HTTP loopback URLs (localhost, 127.0.0.1, [::1]) are allowed.
|
|
6222
|
+
* The `jwkPrivate` field should contain the private key in JWK (JSON Web Key) format as a string.
|
|
5138
6223
|
*/
|
|
5139
6224
|
const OAuthConfigSchema = zod.z.object({
|
|
5140
6225
|
/**
|
|
5141
6226
|
* URL to the OAuth client metadata JSON document.
|
|
5142
6227
|
* This document describes your application to the authorization server.
|
|
5143
6228
|
*
|
|
6229
|
+
* For local development, you can use `http://localhost/` as a loopback client.
|
|
6230
|
+
*
|
|
5144
6231
|
* @see https://atproto.com/specs/oauth#client-metadata
|
|
5145
6232
|
*/
|
|
5146
|
-
clientId:
|
|
6233
|
+
clientId: urlOrLoopback,
|
|
5147
6234
|
/**
|
|
5148
6235
|
* URL where users are redirected after authentication.
|
|
5149
6236
|
* Must match one of the redirect URIs in your client metadata.
|
|
6237
|
+
*
|
|
6238
|
+
* For local development, you can use HTTP loopback URLs like
|
|
6239
|
+
* `http://127.0.0.1:3000/callback` or `http://localhost:3000/callback`.
|
|
5150
6240
|
*/
|
|
5151
|
-
redirectUri:
|
|
6241
|
+
redirectUri: urlOrLoopback,
|
|
5152
6242
|
/**
|
|
5153
6243
|
* OAuth scopes to request, space-separated.
|
|
5154
6244
|
*
|
|
@@ -5182,8 +6272,11 @@ const OAuthConfigSchema = zod.z.object({
|
|
|
5182
6272
|
/**
|
|
5183
6273
|
* URL to your public JWKS (JSON Web Key Set) endpoint.
|
|
5184
6274
|
* Used by the authorization server to verify your client's signatures.
|
|
6275
|
+
*
|
|
6276
|
+
* For local development, you can serve JWKS from a loopback URL like
|
|
6277
|
+
* `http://127.0.0.1:3000/.well-known/jwks.json`.
|
|
5185
6278
|
*/
|
|
5186
|
-
jwksUri:
|
|
6279
|
+
jwksUri: urlOrLoopback,
|
|
5187
6280
|
/**
|
|
5188
6281
|
* Private JWK (JSON Web Key) as a JSON string.
|
|
5189
6282
|
* Used for signing DPoP proofs and client assertions.
|
|
@@ -5193,28 +6286,64 @@ const OAuthConfigSchema = zod.z.object({
|
|
|
5193
6286
|
* Typically loaded from environment variables or a secrets manager.
|
|
5194
6287
|
*/
|
|
5195
6288
|
jwkPrivate: zod.z.string(),
|
|
6289
|
+
/**
|
|
6290
|
+
* Enable development mode features (optional).
|
|
6291
|
+
*
|
|
6292
|
+
* When true, suppresses warnings about using HTTP loopback URLs.
|
|
6293
|
+
* Should be set to true for local development to reduce console noise.
|
|
6294
|
+
*
|
|
6295
|
+
* @default false
|
|
6296
|
+
*
|
|
6297
|
+
* @example
|
|
6298
|
+
* ```typescript
|
|
6299
|
+
* oauth: {
|
|
6300
|
+
* clientId: "http://localhost/",
|
|
6301
|
+
* redirectUri: "http://127.0.0.1:3000/callback",
|
|
6302
|
+
* // ... other config
|
|
6303
|
+
* developmentMode: true, // Suppress loopback warnings
|
|
6304
|
+
* }
|
|
6305
|
+
* ```
|
|
6306
|
+
*/
|
|
6307
|
+
developmentMode: zod.z.boolean().optional(),
|
|
5196
6308
|
});
|
|
5197
6309
|
/**
|
|
5198
6310
|
* Zod schema for server URL configuration.
|
|
5199
6311
|
*
|
|
5200
6312
|
* @remarks
|
|
5201
6313
|
* At least one server (PDS or SDS) should be configured for the SDK to be useful.
|
|
6314
|
+
* For local development, HTTP loopback URLs are allowed.
|
|
5202
6315
|
*/
|
|
5203
6316
|
const ServerConfigSchema = zod.z.object({
|
|
5204
6317
|
/**
|
|
5205
6318
|
* Personal Data Server URL - the user's own AT Protocol server.
|
|
5206
6319
|
* This is the primary server for user data operations.
|
|
5207
6320
|
*
|
|
5208
|
-
* @example
|
|
6321
|
+
* @example Production
|
|
6322
|
+
* ```typescript
|
|
6323
|
+
* pds: "https://bsky.social"
|
|
6324
|
+
* ```
|
|
6325
|
+
*
|
|
6326
|
+
* @example Local development
|
|
6327
|
+
* ```typescript
|
|
6328
|
+
* pds: "http://localhost:2583"
|
|
6329
|
+
* ```
|
|
5209
6330
|
*/
|
|
5210
|
-
pds:
|
|
6331
|
+
pds: urlOrLoopback.optional(),
|
|
5211
6332
|
/**
|
|
5212
6333
|
* Shared Data Server URL - for collaborative data storage.
|
|
5213
6334
|
* Required for collaborator and organization operations.
|
|
5214
6335
|
*
|
|
5215
|
-
* @example
|
|
6336
|
+
* @example Production
|
|
6337
|
+
* ```typescript
|
|
6338
|
+
* sds: "https://sds.hypercerts.org"
|
|
6339
|
+
* ```
|
|
6340
|
+
*
|
|
6341
|
+
* @example Local development
|
|
6342
|
+
* ```typescript
|
|
6343
|
+
* sds: "http://127.0.0.1:2584"
|
|
6344
|
+
* ```
|
|
5216
6345
|
*/
|
|
5217
|
-
sds:
|
|
6346
|
+
sds: urlOrLoopback.optional(),
|
|
5218
6347
|
});
|
|
5219
6348
|
/**
|
|
5220
6349
|
* Zod schema for timeout configuration.
|
|
@@ -5341,6 +6470,10 @@ class ATProtoSDK {
|
|
|
5341
6470
|
};
|
|
5342
6471
|
this.config = configWithDefaults;
|
|
5343
6472
|
this.logger = config.logger;
|
|
6473
|
+
// Initialize lexicon registry with hypercert lexicons
|
|
6474
|
+
// Filter out undefined lexicons (some may not be exported from lexicon package yet)
|
|
6475
|
+
const validLexicons = HYPERCERT_LEXICONS.filter((lex) => lex !== undefined);
|
|
6476
|
+
this.lexiconRegistry = new LexiconRegistry(validLexicons);
|
|
5344
6477
|
// Initialize OAuth client
|
|
5345
6478
|
this.oauthClient = new OAuthClient(configWithDefaults);
|
|
5346
6479
|
this.logger?.info("ATProto SDK initialized");
|
|
@@ -5624,45 +6757,1160 @@ class ATProtoSDK {
|
|
|
5624
6757
|
}
|
|
5625
6758
|
// Get repository DID (default to session DID)
|
|
5626
6759
|
const repoDid = session.did || session.sub;
|
|
5627
|
-
return new Repository(session, serverUrl, repoDid, isSDS, this.logger);
|
|
6760
|
+
return new Repository(session, serverUrl, repoDid, isSDS, this.logger, this.lexiconRegistry);
|
|
6761
|
+
}
|
|
6762
|
+
/**
|
|
6763
|
+
* Gets the LexiconRegistry instance for managing custom lexicons.
|
|
6764
|
+
*
|
|
6765
|
+
* The registry allows you to register custom lexicon schemas and validate
|
|
6766
|
+
* records against them. All registered lexicons will be automatically
|
|
6767
|
+
* validated during record creation operations.
|
|
6768
|
+
*
|
|
6769
|
+
* @returns The {@link LexiconRegistry} instance
|
|
6770
|
+
*
|
|
6771
|
+
* @example
|
|
6772
|
+
* ```typescript
|
|
6773
|
+
* // Register a custom lexicon
|
|
6774
|
+
* const registry = sdk.getLexiconRegistry();
|
|
6775
|
+
* registry.register({
|
|
6776
|
+
* lexicon: 1,
|
|
6777
|
+
* id: "org.myapp.customRecord",
|
|
6778
|
+
* defs: { ... }
|
|
6779
|
+
* });
|
|
6780
|
+
*
|
|
6781
|
+
* // Check if lexicon is registered
|
|
6782
|
+
* if (registry.isRegistered("org.myapp.customRecord")) {
|
|
6783
|
+
* console.log("Custom lexicon is available");
|
|
6784
|
+
* }
|
|
6785
|
+
* ```
|
|
6786
|
+
*/
|
|
6787
|
+
getLexiconRegistry() {
|
|
6788
|
+
return this.lexiconRegistry;
|
|
6789
|
+
}
|
|
6790
|
+
/**
|
|
6791
|
+
* The configured PDS (Personal Data Server) URL.
|
|
6792
|
+
*
|
|
6793
|
+
* @returns The PDS URL if configured, otherwise `undefined`
|
|
6794
|
+
*/
|
|
6795
|
+
get pdsUrl() {
|
|
6796
|
+
return this.config.servers?.pds;
|
|
6797
|
+
}
|
|
6798
|
+
/**
|
|
6799
|
+
* The configured SDS (Shared Data Server) URL.
|
|
6800
|
+
*
|
|
6801
|
+
* @returns The SDS URL if configured, otherwise `undefined`
|
|
6802
|
+
*/
|
|
6803
|
+
get sdsUrl() {
|
|
6804
|
+
return this.config.servers?.sds;
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
/**
|
|
6808
|
+
* Factory function to create an ATProto SDK instance.
|
|
6809
|
+
*
|
|
6810
|
+
* This is a convenience function equivalent to `new ATProtoSDK(config)`.
|
|
6811
|
+
*
|
|
6812
|
+
* @param config - SDK configuration
|
|
6813
|
+
* @returns A new {@link ATProtoSDK} instance
|
|
6814
|
+
*
|
|
6815
|
+
* @example
|
|
6816
|
+
* ```typescript
|
|
6817
|
+
* import { createATProtoSDK } from "@hypercerts-org/sdk";
|
|
6818
|
+
*
|
|
6819
|
+
* const sdk = createATProtoSDK({
|
|
6820
|
+
* oauth: { ... },
|
|
6821
|
+
* servers: { pds: "https://bsky.social" },
|
|
6822
|
+
* });
|
|
6823
|
+
* ```
|
|
6824
|
+
*/
|
|
6825
|
+
function createATProtoSDK(config) {
|
|
6826
|
+
return new ATProtoSDK(config);
|
|
6827
|
+
}
|
|
6828
|
+
|
|
6829
|
+
/**
|
|
6830
|
+
* BaseOperations - Abstract base class for custom lexicon operations.
|
|
6831
|
+
*
|
|
6832
|
+
* This module provides a foundation for building domain-specific operation
|
|
6833
|
+
* classes that work with custom lexicons. It handles validation, record
|
|
6834
|
+
* creation, and provides utilities for working with AT Protocol records.
|
|
6835
|
+
*
|
|
6836
|
+
* @packageDocumentation
|
|
6837
|
+
*/
|
|
6838
|
+
/**
|
|
6839
|
+
* Abstract base class for creating custom lexicon operation classes.
|
|
6840
|
+
*
|
|
6841
|
+
* Extend this class to build domain-specific operations for your custom
|
|
6842
|
+
* lexicons. The base class provides:
|
|
6843
|
+
*
|
|
6844
|
+
* - Automatic validation against registered lexicon schemas
|
|
6845
|
+
* - Helper methods for creating and updating records
|
|
6846
|
+
* - Utilities for building strongRefs and AT-URIs
|
|
6847
|
+
* - Error handling and logging support
|
|
6848
|
+
*
|
|
6849
|
+
* @typeParam TParams - Type of parameters accepted by the create() method
|
|
6850
|
+
* @typeParam TResult - Type of result returned by the create() method
|
|
6851
|
+
*
|
|
6852
|
+
* @remarks
|
|
6853
|
+
* This class is designed to be extended by developers creating custom
|
|
6854
|
+
* operation classes for their own lexicons. It follows the same patterns
|
|
6855
|
+
* as the built-in hypercert operations.
|
|
6856
|
+
*
|
|
6857
|
+
* @example Basic usage
|
|
6858
|
+
* ```typescript
|
|
6859
|
+
* import { BaseOperations } from "@hypercerts-org/sdk-core";
|
|
6860
|
+
*
|
|
6861
|
+
* interface EvaluationParams {
|
|
6862
|
+
* subjectUri: string;
|
|
6863
|
+
* subjectCid: string;
|
|
6864
|
+
* score: number;
|
|
6865
|
+
* methodology?: string;
|
|
6866
|
+
* }
|
|
6867
|
+
*
|
|
6868
|
+
* interface EvaluationResult {
|
|
6869
|
+
* uri: string;
|
|
6870
|
+
* cid: string;
|
|
6871
|
+
* record: MyEvaluation;
|
|
6872
|
+
* }
|
|
6873
|
+
*
|
|
6874
|
+
* class EvaluationOperations extends BaseOperations<EvaluationParams, EvaluationResult> {
|
|
6875
|
+
* async create(params: EvaluationParams): Promise<EvaluationResult> {
|
|
6876
|
+
* const record = {
|
|
6877
|
+
* $type: "org.myapp.evaluation",
|
|
6878
|
+
* subject: this.createStrongRef(params.subjectUri, params.subjectCid),
|
|
6879
|
+
* score: params.score,
|
|
6880
|
+
* methodology: params.methodology,
|
|
6881
|
+
* createdAt: new Date().toISOString(),
|
|
6882
|
+
* };
|
|
6883
|
+
*
|
|
6884
|
+
* const { uri, cid } = await this.validateAndCreate("org.myapp.evaluation", record);
|
|
6885
|
+
* return { uri, cid, record };
|
|
6886
|
+
* }
|
|
6887
|
+
* }
|
|
6888
|
+
* ```
|
|
6889
|
+
*
|
|
6890
|
+
* @example With validation and error handling
|
|
6891
|
+
* ```typescript
|
|
6892
|
+
* class ProjectOperations extends BaseOperations<CreateProjectParams, ProjectResult> {
|
|
6893
|
+
* async create(params: CreateProjectParams): Promise<ProjectResult> {
|
|
6894
|
+
* // Validate input parameters
|
|
6895
|
+
* if (!params.title || params.title.trim().length === 0) {
|
|
6896
|
+
* throw new ValidationError("Project title cannot be empty");
|
|
6897
|
+
* }
|
|
6898
|
+
*
|
|
6899
|
+
* const record = {
|
|
6900
|
+
* $type: "org.myapp.project",
|
|
6901
|
+
* title: params.title,
|
|
6902
|
+
* description: params.description,
|
|
6903
|
+
* createdAt: new Date().toISOString(),
|
|
6904
|
+
* };
|
|
6905
|
+
*
|
|
6906
|
+
* try {
|
|
6907
|
+
* const { uri, cid } = await this.validateAndCreate("org.myapp.project", record);
|
|
6908
|
+
* this.logger?.info(`Created project: ${uri}`);
|
|
6909
|
+
* return { uri, cid, record };
|
|
6910
|
+
* } catch (error) {
|
|
6911
|
+
* this.logger?.error(`Failed to create project: ${error}`);
|
|
6912
|
+
* throw error;
|
|
6913
|
+
* }
|
|
6914
|
+
* }
|
|
6915
|
+
* }
|
|
6916
|
+
* ```
|
|
6917
|
+
*/
|
|
6918
|
+
class BaseOperations {
|
|
6919
|
+
/**
|
|
6920
|
+
* Creates a new BaseOperations instance.
|
|
6921
|
+
*
|
|
6922
|
+
* @param agent - AT Protocol Agent for making API calls
|
|
6923
|
+
* @param repoDid - DID of the repository to operate on
|
|
6924
|
+
* @param lexiconRegistry - Registry for validating records against lexicon schemas
|
|
6925
|
+
* @param logger - Optional logger for debugging and monitoring
|
|
6926
|
+
*
|
|
6927
|
+
* @internal
|
|
6928
|
+
*/
|
|
6929
|
+
constructor(agent, repoDid, lexiconRegistry, logger) {
|
|
6930
|
+
this.agent = agent;
|
|
6931
|
+
this.repoDid = repoDid;
|
|
6932
|
+
this.lexiconRegistry = lexiconRegistry;
|
|
6933
|
+
this.logger = logger;
|
|
6934
|
+
}
|
|
6935
|
+
/**
|
|
6936
|
+
* Validates a record against its lexicon schema and creates it in the repository.
|
|
6937
|
+
*
|
|
6938
|
+
* This method performs the following steps:
|
|
6939
|
+
* 1. Validates the record against the registered lexicon schema
|
|
6940
|
+
* 2. Throws ValidationError if validation fails
|
|
6941
|
+
* 3. Creates the record using the AT Protocol Agent
|
|
6942
|
+
* 4. Returns the created record's URI and CID
|
|
6943
|
+
*
|
|
6944
|
+
* @param collection - NSID of the collection (e.g., "org.myapp.customRecord")
|
|
6945
|
+
* @param record - Record data conforming to the collection's lexicon schema
|
|
6946
|
+
* @param rkey - Optional record key. If not provided, a TID is auto-generated
|
|
6947
|
+
* @returns Promise resolving to the created record's URI and CID
|
|
6948
|
+
* @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
|
|
6949
|
+
* @throws {@link NetworkError} if the API request fails
|
|
6950
|
+
*
|
|
6951
|
+
* @example
|
|
6952
|
+
* ```typescript
|
|
6953
|
+
* const record = {
|
|
6954
|
+
* $type: "org.myapp.evaluation",
|
|
6955
|
+
* subject: { uri: "at://...", cid: "bafyrei..." },
|
|
6956
|
+
* score: 85,
|
|
6957
|
+
* createdAt: new Date().toISOString(),
|
|
6958
|
+
* };
|
|
6959
|
+
*
|
|
6960
|
+
* const { uri, cid } = await this.validateAndCreate("org.myapp.evaluation", record);
|
|
6961
|
+
* ```
|
|
6962
|
+
*/
|
|
6963
|
+
async validateAndCreate(collection, record, rkey) {
|
|
6964
|
+
// Validate record against registered lexicon
|
|
6965
|
+
if (this.lexiconRegistry.isRegistered(collection)) {
|
|
6966
|
+
const validation = this.lexiconRegistry.validate(collection, record);
|
|
6967
|
+
if (!validation.valid) {
|
|
6968
|
+
throw new ValidationError(`Invalid ${collection}: ${validation.error}`);
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
try {
|
|
6972
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
6973
|
+
repo: this.repoDid,
|
|
6974
|
+
collection,
|
|
6975
|
+
record: record,
|
|
6976
|
+
rkey,
|
|
6977
|
+
});
|
|
6978
|
+
if (!result.success) {
|
|
6979
|
+
throw new NetworkError("Failed to create record");
|
|
6980
|
+
}
|
|
6981
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
6982
|
+
}
|
|
6983
|
+
catch (error) {
|
|
6984
|
+
if (error instanceof ValidationError || error instanceof NetworkError) {
|
|
6985
|
+
throw error;
|
|
6986
|
+
}
|
|
6987
|
+
throw new NetworkError(`Failed to create ${collection}: ${error instanceof Error ? error.message : "Unknown error"}`, error);
|
|
6988
|
+
}
|
|
6989
|
+
}
|
|
6990
|
+
/**
|
|
6991
|
+
* Validates a record against its lexicon schema and updates it in the repository.
|
|
6992
|
+
*
|
|
6993
|
+
* This method performs the following steps:
|
|
6994
|
+
* 1. Validates the record against the registered lexicon schema
|
|
6995
|
+
* 2. Throws ValidationError if validation fails
|
|
6996
|
+
* 3. Updates the record using the AT Protocol Agent
|
|
6997
|
+
* 4. Returns the updated record's URI and new CID
|
|
6998
|
+
*
|
|
6999
|
+
* @param collection - NSID of the collection
|
|
7000
|
+
* @param rkey - Record key (the last segment of the AT-URI)
|
|
7001
|
+
* @param record - New record data (completely replaces existing record)
|
|
7002
|
+
* @returns Promise resolving to the updated record's URI and new CID
|
|
7003
|
+
* @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
|
|
7004
|
+
* @throws {@link NetworkError} if the API request fails
|
|
7005
|
+
*
|
|
7006
|
+
* @remarks
|
|
7007
|
+
* This is a full replacement operation, not a partial update.
|
|
7008
|
+
*
|
|
7009
|
+
* @example
|
|
7010
|
+
* ```typescript
|
|
7011
|
+
* const updatedRecord = {
|
|
7012
|
+
* $type: "org.myapp.evaluation",
|
|
7013
|
+
* subject: { uri: "at://...", cid: "bafyrei..." },
|
|
7014
|
+
* score: 90, // Updated score
|
|
7015
|
+
* createdAt: existingRecord.createdAt,
|
|
7016
|
+
* };
|
|
7017
|
+
*
|
|
7018
|
+
* const { uri, cid } = await this.validateAndUpdate(
|
|
7019
|
+
* "org.myapp.evaluation",
|
|
7020
|
+
* "abc123",
|
|
7021
|
+
* updatedRecord
|
|
7022
|
+
* );
|
|
7023
|
+
* ```
|
|
7024
|
+
*/
|
|
7025
|
+
async validateAndUpdate(collection, rkey, record) {
|
|
7026
|
+
// Validate record against registered lexicon
|
|
7027
|
+
if (this.lexiconRegistry.isRegistered(collection)) {
|
|
7028
|
+
const validation = this.lexiconRegistry.validate(collection, record);
|
|
7029
|
+
if (!validation.valid) {
|
|
7030
|
+
throw new ValidationError(`Invalid ${collection}: ${validation.error}`);
|
|
7031
|
+
}
|
|
7032
|
+
}
|
|
7033
|
+
try {
|
|
7034
|
+
const result = await this.agent.com.atproto.repo.putRecord({
|
|
7035
|
+
repo: this.repoDid,
|
|
7036
|
+
collection,
|
|
7037
|
+
rkey,
|
|
7038
|
+
record: record,
|
|
7039
|
+
});
|
|
7040
|
+
if (!result.success) {
|
|
7041
|
+
throw new NetworkError("Failed to update record");
|
|
7042
|
+
}
|
|
7043
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
7044
|
+
}
|
|
7045
|
+
catch (error) {
|
|
7046
|
+
if (error instanceof ValidationError || error instanceof NetworkError) {
|
|
7047
|
+
throw error;
|
|
7048
|
+
}
|
|
7049
|
+
throw new NetworkError(`Failed to update ${collection}: ${error instanceof Error ? error.message : "Unknown error"}`, error);
|
|
7050
|
+
}
|
|
7051
|
+
}
|
|
7052
|
+
/**
|
|
7053
|
+
* Creates a strongRef object from a URI and CID.
|
|
7054
|
+
*
|
|
7055
|
+
* StrongRefs are used in AT Protocol to reference specific versions
|
|
7056
|
+
* of records. They ensure that references point to an exact record
|
|
7057
|
+
* version, not just the latest version.
|
|
7058
|
+
*
|
|
7059
|
+
* @param uri - AT-URI of the record (e.g., "at://did:plc:abc/collection/rkey")
|
|
7060
|
+
* @param cid - Content Identifier (CID) of the record
|
|
7061
|
+
* @returns StrongRef object with uri and cid properties
|
|
7062
|
+
*
|
|
7063
|
+
* @example
|
|
7064
|
+
* ```typescript
|
|
7065
|
+
* const hypercertRef = this.createStrongRef(
|
|
7066
|
+
* "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789",
|
|
7067
|
+
* "bafyreiabc123..."
|
|
7068
|
+
* );
|
|
7069
|
+
*
|
|
7070
|
+
* const evaluation = {
|
|
7071
|
+
* $type: "org.myapp.evaluation",
|
|
7072
|
+
* subject: hypercertRef, // Reference to specific hypercert version
|
|
7073
|
+
* score: 85,
|
|
7074
|
+
* createdAt: new Date().toISOString(),
|
|
7075
|
+
* };
|
|
7076
|
+
* ```
|
|
7077
|
+
*/
|
|
7078
|
+
createStrongRef(uri, cid) {
|
|
7079
|
+
return { uri, cid };
|
|
7080
|
+
}
|
|
7081
|
+
/**
|
|
7082
|
+
* Creates a strongRef from a CreateResult or UpdateResult.
|
|
7083
|
+
*
|
|
7084
|
+
* This is a convenience method for creating strongRefs from the
|
|
7085
|
+
* results of create or update operations.
|
|
7086
|
+
*
|
|
7087
|
+
* @param result - Result from a create or update operation
|
|
7088
|
+
* @returns StrongRef object with uri and cid properties
|
|
7089
|
+
*
|
|
7090
|
+
* @example
|
|
7091
|
+
* ```typescript
|
|
7092
|
+
* // Create a project
|
|
7093
|
+
* const projectResult = await this.validateAndCreate("org.myapp.project", projectRecord);
|
|
7094
|
+
*
|
|
7095
|
+
* // Create a task that references the project
|
|
7096
|
+
* const taskRecord = {
|
|
7097
|
+
* $type: "org.myapp.task",
|
|
7098
|
+
* project: this.createStrongRefFromResult(projectResult),
|
|
7099
|
+
* title: "Implement feature",
|
|
7100
|
+
* createdAt: new Date().toISOString(),
|
|
7101
|
+
* };
|
|
7102
|
+
* ```
|
|
7103
|
+
*/
|
|
7104
|
+
createStrongRefFromResult(result) {
|
|
7105
|
+
return { uri: result.uri, cid: result.cid };
|
|
7106
|
+
}
|
|
7107
|
+
/**
|
|
7108
|
+
* Parses an AT-URI to extract its components.
|
|
7109
|
+
*
|
|
7110
|
+
* AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
|
|
7111
|
+
*
|
|
7112
|
+
* @param uri - AT-URI to parse
|
|
7113
|
+
* @returns Object containing did, collection, and rkey
|
|
7114
|
+
* @throws Error if the URI format is invalid
|
|
7115
|
+
*
|
|
7116
|
+
* @example
|
|
7117
|
+
* ```typescript
|
|
7118
|
+
* const { did, collection, rkey } = this.parseAtUri(
|
|
7119
|
+
* "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789"
|
|
7120
|
+
* );
|
|
7121
|
+
* // did: "did:plc:abc123"
|
|
7122
|
+
* // collection: "org.hypercerts.claim.activity"
|
|
7123
|
+
* // rkey: "xyz789"
|
|
7124
|
+
* ```
|
|
7125
|
+
*/
|
|
7126
|
+
parseAtUri(uri) {
|
|
7127
|
+
if (!uri.startsWith("at://")) {
|
|
7128
|
+
throw new Error(`Invalid AT-URI format: ${uri}`);
|
|
7129
|
+
}
|
|
7130
|
+
const parts = uri.slice(5).split("/"); // Remove "at://" and split
|
|
7131
|
+
if (parts.length !== 3) {
|
|
7132
|
+
throw new Error(`Invalid AT-URI format: ${uri}`);
|
|
7133
|
+
}
|
|
7134
|
+
return {
|
|
7135
|
+
did: parts[0],
|
|
7136
|
+
collection: parts[1],
|
|
7137
|
+
rkey: parts[2],
|
|
7138
|
+
};
|
|
7139
|
+
}
|
|
7140
|
+
/**
|
|
7141
|
+
* Builds an AT-URI from its components.
|
|
7142
|
+
*
|
|
7143
|
+
* @param did - DID of the repository
|
|
7144
|
+
* @param collection - NSID of the collection
|
|
7145
|
+
* @param rkey - Record key (typically a TID)
|
|
7146
|
+
* @returns Complete AT-URI string
|
|
7147
|
+
*
|
|
7148
|
+
* @example
|
|
7149
|
+
* ```typescript
|
|
7150
|
+
* const uri = this.buildAtUri(
|
|
7151
|
+
* "did:plc:abc123",
|
|
7152
|
+
* "org.myapp.evaluation",
|
|
7153
|
+
* "xyz789"
|
|
7154
|
+
* );
|
|
7155
|
+
* // Returns: "at://did:plc:abc123/org.myapp.evaluation/xyz789"
|
|
7156
|
+
* ```
|
|
7157
|
+
*/
|
|
7158
|
+
buildAtUri(did, collection, rkey) {
|
|
7159
|
+
return `at://${did}/${collection}/${rkey}`;
|
|
7160
|
+
}
|
|
7161
|
+
}
|
|
7162
|
+
|
|
7163
|
+
/**
|
|
7164
|
+
* Lexicon Development Utilities - AT-URI and StrongRef Helpers
|
|
7165
|
+
*
|
|
7166
|
+
* This module provides utilities for working with AT Protocol URIs and strongRefs
|
|
7167
|
+
* when building custom lexicons. These tools help developers create type-safe
|
|
7168
|
+
* references between records.
|
|
7169
|
+
*
|
|
7170
|
+
* @packageDocumentation
|
|
7171
|
+
*/
|
|
7172
|
+
/**
|
|
7173
|
+
* Parse an AT-URI into its component parts.
|
|
7174
|
+
*
|
|
7175
|
+
* Extracts the DID, collection NSID, and record key from an AT-URI string.
|
|
7176
|
+
* AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
|
|
7177
|
+
*
|
|
7178
|
+
* @param uri - The AT-URI to parse
|
|
7179
|
+
* @returns The components of the URI
|
|
7180
|
+
* @throws {Error} If the URI format is invalid
|
|
7181
|
+
*
|
|
7182
|
+
* @example
|
|
7183
|
+
* ```typescript
|
|
7184
|
+
* const components = parseAtUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
|
|
7185
|
+
* console.log(components);
|
|
7186
|
+
* // {
|
|
7187
|
+
* // did: "did:plc:abc123",
|
|
7188
|
+
* // collection: "org.hypercerts.claim.activity",
|
|
7189
|
+
* // rkey: "3km2vj4kfqp2a"
|
|
7190
|
+
* // }
|
|
7191
|
+
* ```
|
|
7192
|
+
*/
|
|
7193
|
+
function parseAtUri(uri) {
|
|
7194
|
+
if (!uri.startsWith("at://")) {
|
|
7195
|
+
throw new Error(`Invalid AT-URI format: must start with "at://", got "${uri}"`);
|
|
7196
|
+
}
|
|
7197
|
+
const withoutProtocol = uri.slice(5); // Remove "at://"
|
|
7198
|
+
const parts = withoutProtocol.split("/");
|
|
7199
|
+
if (parts.length !== 3) {
|
|
7200
|
+
throw new Error(`Invalid AT-URI format: expected "at://{did}/{collection}/{rkey}", got "${uri}"`);
|
|
7201
|
+
}
|
|
7202
|
+
const [did, collection, rkey] = parts;
|
|
7203
|
+
if (!did || !collection || !rkey) {
|
|
7204
|
+
throw new Error(`Invalid AT-URI format: all components must be non-empty, got "${uri}"`);
|
|
7205
|
+
}
|
|
7206
|
+
return { did, collection, rkey };
|
|
7207
|
+
}
|
|
7208
|
+
/**
|
|
7209
|
+
* Build an AT-URI from its component parts.
|
|
7210
|
+
*
|
|
7211
|
+
* Constructs a valid AT-URI string from a DID, collection NSID, and record key.
|
|
7212
|
+
* The resulting URI follows the format: `at://{did}/{collection}/{rkey}`
|
|
7213
|
+
*
|
|
7214
|
+
* @param did - The repository owner's DID
|
|
7215
|
+
* @param collection - The collection NSID (lexicon identifier)
|
|
7216
|
+
* @param rkey - The record key (TID or custom string)
|
|
7217
|
+
* @returns The complete AT-URI
|
|
7218
|
+
*
|
|
7219
|
+
* @example
|
|
7220
|
+
* ```typescript
|
|
7221
|
+
* const uri = buildAtUri(
|
|
7222
|
+
* "did:plc:abc123",
|
|
7223
|
+
* "org.hypercerts.claim.activity",
|
|
7224
|
+
* "3km2vj4kfqp2a"
|
|
7225
|
+
* );
|
|
7226
|
+
* console.log(uri); // "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a"
|
|
7227
|
+
* ```
|
|
7228
|
+
*/
|
|
7229
|
+
function buildAtUri(did, collection, rkey) {
|
|
7230
|
+
if (!did || !collection || !rkey) {
|
|
7231
|
+
throw new Error("All AT-URI components (did, collection, rkey) must be non-empty");
|
|
7232
|
+
}
|
|
7233
|
+
return `at://${did}/${collection}/${rkey}`;
|
|
7234
|
+
}
|
|
7235
|
+
/**
|
|
7236
|
+
* Extract the record key (TID or custom key) from an AT-URI.
|
|
7237
|
+
*
|
|
7238
|
+
* Returns the last component of the AT-URI, which is the record key.
|
|
7239
|
+
* This is equivalent to `parseAtUri(uri).rkey` but more efficient.
|
|
7240
|
+
*
|
|
7241
|
+
* @param uri - The AT-URI to extract from
|
|
7242
|
+
* @returns The record key (TID or custom string)
|
|
7243
|
+
* @throws {Error} If the URI format is invalid
|
|
7244
|
+
*
|
|
7245
|
+
* @example
|
|
7246
|
+
* ```typescript
|
|
7247
|
+
* const rkey = extractRkeyFromUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
|
|
7248
|
+
* console.log(rkey); // "3km2vj4kfqp2a"
|
|
7249
|
+
* ```
|
|
7250
|
+
*/
|
|
7251
|
+
function extractRkeyFromUri(uri) {
|
|
7252
|
+
const { rkey } = parseAtUri(uri);
|
|
7253
|
+
return rkey;
|
|
7254
|
+
}
|
|
7255
|
+
/**
|
|
7256
|
+
* Check if a string is a valid AT-URI format.
|
|
7257
|
+
*
|
|
7258
|
+
* Validates that the string follows the AT-URI format without throwing errors.
|
|
7259
|
+
* This is useful for input validation before parsing.
|
|
7260
|
+
*
|
|
7261
|
+
* @param uri - The string to validate
|
|
7262
|
+
* @returns True if the string is a valid AT-URI, false otherwise
|
|
7263
|
+
*
|
|
7264
|
+
* @example
|
|
7265
|
+
* ```typescript
|
|
7266
|
+
* if (isValidAtUri(userInput)) {
|
|
7267
|
+
* const components = parseAtUri(userInput);
|
|
7268
|
+
* // ... use components
|
|
7269
|
+
* } else {
|
|
7270
|
+
* console.error("Invalid AT-URI");
|
|
7271
|
+
* }
|
|
7272
|
+
* ```
|
|
7273
|
+
*/
|
|
7274
|
+
function isValidAtUri(uri) {
|
|
7275
|
+
try {
|
|
7276
|
+
parseAtUri(uri);
|
|
7277
|
+
return true;
|
|
7278
|
+
}
|
|
7279
|
+
catch {
|
|
7280
|
+
return false;
|
|
5628
7281
|
}
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
7282
|
+
}
|
|
7283
|
+
/**
|
|
7284
|
+
* Create a strongRef from a URI and CID.
|
|
7285
|
+
*
|
|
7286
|
+
* StrongRefs are the canonical way to reference specific versions of records
|
|
7287
|
+
* in AT Protocol. They combine an AT-URI (which identifies the record) with
|
|
7288
|
+
* a CID (which identifies the specific version).
|
|
7289
|
+
*
|
|
7290
|
+
* @param uri - The AT-URI of the record
|
|
7291
|
+
* @param cid - The CID (Content Identifier) of the record version
|
|
7292
|
+
* @returns A strongRef object
|
|
7293
|
+
*
|
|
7294
|
+
* @example
|
|
7295
|
+
* ```typescript
|
|
7296
|
+
* const ref = createStrongRef(
|
|
7297
|
+
* "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
|
|
7298
|
+
* "bafyreiabc123..."
|
|
7299
|
+
* );
|
|
7300
|
+
* console.log(ref);
|
|
7301
|
+
* // {
|
|
7302
|
+
* // uri: "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
|
|
7303
|
+
* // cid: "bafyreiabc123..."
|
|
7304
|
+
* // }
|
|
7305
|
+
* ```
|
|
7306
|
+
*/
|
|
7307
|
+
function createStrongRef(uri, cid) {
|
|
7308
|
+
if (!uri || !cid) {
|
|
7309
|
+
throw new Error("Both uri and cid are required to create a strongRef");
|
|
5636
7310
|
}
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
7311
|
+
return { uri, cid };
|
|
7312
|
+
}
|
|
7313
|
+
/**
|
|
7314
|
+
* Create a strongRef from a CreateResult or UpdateResult.
|
|
7315
|
+
*
|
|
7316
|
+
* This is a convenience function that extracts the URI and CID from
|
|
7317
|
+
* the result of a record creation or update operation.
|
|
7318
|
+
*
|
|
7319
|
+
* @param result - The result from creating or updating a record
|
|
7320
|
+
* @returns A strongRef object
|
|
7321
|
+
*
|
|
7322
|
+
* @example
|
|
7323
|
+
* ```typescript
|
|
7324
|
+
* const hypercert = await repo.hypercerts.create({
|
|
7325
|
+
* title: "Climate Research",
|
|
7326
|
+
* // ... other params
|
|
7327
|
+
* });
|
|
7328
|
+
*
|
|
7329
|
+
* const ref = createStrongRefFromResult(hypercert);
|
|
7330
|
+
* // Now use ref in another record to reference this hypercert
|
|
7331
|
+
* ```
|
|
7332
|
+
*/
|
|
7333
|
+
function createStrongRefFromResult(result) {
|
|
7334
|
+
return createStrongRef(result.uri, result.cid);
|
|
7335
|
+
}
|
|
7336
|
+
/**
|
|
7337
|
+
* Validate that an object is a valid strongRef.
|
|
7338
|
+
*
|
|
7339
|
+
* Checks that the object has the required `uri` and `cid` properties
|
|
7340
|
+
* and that they are non-empty strings.
|
|
7341
|
+
*
|
|
7342
|
+
* @param ref - The object to validate
|
|
7343
|
+
* @returns True if the object is a valid strongRef, false otherwise
|
|
7344
|
+
*
|
|
7345
|
+
* @example
|
|
7346
|
+
* ```typescript
|
|
7347
|
+
* const maybeRef = { uri: "at://...", cid: "bafyrei..." };
|
|
7348
|
+
* if (validateStrongRef(maybeRef)) {
|
|
7349
|
+
* // Safe to use as strongRef
|
|
7350
|
+
* record.subject = maybeRef;
|
|
7351
|
+
* }
|
|
7352
|
+
* ```
|
|
7353
|
+
*/
|
|
7354
|
+
function validateStrongRef(ref) {
|
|
7355
|
+
if (!ref || typeof ref !== "object") {
|
|
7356
|
+
return false;
|
|
5644
7357
|
}
|
|
7358
|
+
const obj = ref;
|
|
7359
|
+
return typeof obj.uri === "string" && obj.uri.length > 0 && typeof obj.cid === "string" && obj.cid.length > 0;
|
|
5645
7360
|
}
|
|
5646
7361
|
/**
|
|
5647
|
-
*
|
|
7362
|
+
* Type guard to check if a value is a strongRef.
|
|
5648
7363
|
*
|
|
5649
|
-
* This is
|
|
7364
|
+
* This is an alias for `validateStrongRef` that provides better semantics
|
|
7365
|
+
* for type narrowing in TypeScript.
|
|
5650
7366
|
*
|
|
5651
|
-
* @param
|
|
5652
|
-
* @returns
|
|
7367
|
+
* @param value - The value to check
|
|
7368
|
+
* @returns True if the value is a strongRef, false otherwise
|
|
5653
7369
|
*
|
|
5654
7370
|
* @example
|
|
5655
7371
|
* ```typescript
|
|
5656
|
-
*
|
|
7372
|
+
* function processReference(ref: unknown) {
|
|
7373
|
+
* if (isStrongRef(ref)) {
|
|
7374
|
+
* // TypeScript knows ref is StrongRef here
|
|
7375
|
+
* console.log(ref.uri);
|
|
7376
|
+
* }
|
|
7377
|
+
* }
|
|
7378
|
+
* ```
|
|
7379
|
+
*/
|
|
7380
|
+
function isStrongRef(value) {
|
|
7381
|
+
return validateStrongRef(value);
|
|
7382
|
+
}
|
|
7383
|
+
|
|
7384
|
+
/**
|
|
7385
|
+
* Lexicon Builder Utilities
|
|
5657
7386
|
*
|
|
5658
|
-
*
|
|
5659
|
-
*
|
|
5660
|
-
*
|
|
7387
|
+
* This module provides utilities for constructing lexicon JSON schemas programmatically.
|
|
7388
|
+
* These builders help developers create valid AT Protocol lexicons with proper structure
|
|
7389
|
+
* and type-safe field definitions.
|
|
7390
|
+
*
|
|
7391
|
+
* @packageDocumentation
|
|
7392
|
+
*/
|
|
7393
|
+
/**
|
|
7394
|
+
* Create a string field definition.
|
|
7395
|
+
*
|
|
7396
|
+
* @param options - String field options
|
|
7397
|
+
* @returns A lexicon string field definition
|
|
7398
|
+
*
|
|
7399
|
+
* @example
|
|
7400
|
+
* ```typescript
|
|
7401
|
+
* const titleField = createStringField({
|
|
7402
|
+
* description: "Title of the item",
|
|
7403
|
+
* minLength: 1,
|
|
7404
|
+
* maxLength: 200,
|
|
5661
7405
|
* });
|
|
5662
7406
|
* ```
|
|
5663
7407
|
*/
|
|
5664
|
-
function
|
|
5665
|
-
return
|
|
7408
|
+
function createStringField(options = {}) {
|
|
7409
|
+
return { type: "string", ...options };
|
|
7410
|
+
}
|
|
7411
|
+
/**
|
|
7412
|
+
* Create an integer field definition.
|
|
7413
|
+
*
|
|
7414
|
+
* @param options - Integer field options
|
|
7415
|
+
* @returns A lexicon integer field definition
|
|
7416
|
+
*
|
|
7417
|
+
* @example
|
|
7418
|
+
* ```typescript
|
|
7419
|
+
* const scoreField = createIntegerField({
|
|
7420
|
+
* description: "Score from 0 to 100",
|
|
7421
|
+
* minimum: 0,
|
|
7422
|
+
* maximum: 100,
|
|
7423
|
+
* });
|
|
7424
|
+
* ```
|
|
7425
|
+
*/
|
|
7426
|
+
function createIntegerField(options = {}) {
|
|
7427
|
+
return { type: "integer", ...options };
|
|
7428
|
+
}
|
|
7429
|
+
/**
|
|
7430
|
+
* Create a number field definition.
|
|
7431
|
+
*
|
|
7432
|
+
* @param options - Number field options
|
|
7433
|
+
* @returns A lexicon number field definition
|
|
7434
|
+
*
|
|
7435
|
+
* @example
|
|
7436
|
+
* ```typescript
|
|
7437
|
+
* const weightField = createNumberField({
|
|
7438
|
+
* description: "Weight as decimal",
|
|
7439
|
+
* minimum: 0,
|
|
7440
|
+
* maximum: 1,
|
|
7441
|
+
* });
|
|
7442
|
+
* ```
|
|
7443
|
+
*/
|
|
7444
|
+
function createNumberField(options = {}) {
|
|
7445
|
+
return { type: "number", ...options };
|
|
7446
|
+
}
|
|
7447
|
+
/**
|
|
7448
|
+
* Create a boolean field definition.
|
|
7449
|
+
*
|
|
7450
|
+
* @param options - Boolean field options
|
|
7451
|
+
* @returns A lexicon boolean field definition
|
|
7452
|
+
*
|
|
7453
|
+
* @example
|
|
7454
|
+
* ```typescript
|
|
7455
|
+
* const verifiedField = createBooleanField({
|
|
7456
|
+
* description: "Whether the item is verified",
|
|
7457
|
+
* default: false,
|
|
7458
|
+
* });
|
|
7459
|
+
* ```
|
|
7460
|
+
*/
|
|
7461
|
+
function createBooleanField(options = {}) {
|
|
7462
|
+
return { type: "boolean", ...options };
|
|
7463
|
+
}
|
|
7464
|
+
/**
|
|
7465
|
+
* Create a strongRef field definition.
|
|
7466
|
+
*
|
|
7467
|
+
* StrongRefs are the standard way to reference other records in AT Protocol.
|
|
7468
|
+
* They contain both the AT-URI and CID of the referenced record.
|
|
7469
|
+
*
|
|
7470
|
+
* @param options - Reference field options
|
|
7471
|
+
* @returns A lexicon reference field definition
|
|
7472
|
+
*
|
|
7473
|
+
* @example
|
|
7474
|
+
* ```typescript
|
|
7475
|
+
* const subjectField = createStrongRefField({
|
|
7476
|
+
* description: "The hypercert being evaluated",
|
|
7477
|
+
* });
|
|
7478
|
+
* ```
|
|
7479
|
+
*/
|
|
7480
|
+
function createStrongRefField(options = {}) {
|
|
7481
|
+
return {
|
|
7482
|
+
type: "ref",
|
|
7483
|
+
ref: options.ref || "com.atproto.repo.strongRef",
|
|
7484
|
+
description: options.description,
|
|
7485
|
+
};
|
|
7486
|
+
}
|
|
7487
|
+
/**
|
|
7488
|
+
* Create an array field definition.
|
|
7489
|
+
*
|
|
7490
|
+
* @param itemType - The type of items in the array
|
|
7491
|
+
* @param options - Array field options
|
|
7492
|
+
* @returns A lexicon array field definition
|
|
7493
|
+
*
|
|
7494
|
+
* @example
|
|
7495
|
+
* ```typescript
|
|
7496
|
+
* const tagsField = createArrayField(
|
|
7497
|
+
* createStringField({ maxLength: 50 }),
|
|
7498
|
+
* {
|
|
7499
|
+
* description: "List of tags",
|
|
7500
|
+
* minLength: 1,
|
|
7501
|
+
* maxLength: 10,
|
|
7502
|
+
* }
|
|
7503
|
+
* );
|
|
7504
|
+
* ```
|
|
7505
|
+
*/
|
|
7506
|
+
function createArrayField(itemType, options = {}) {
|
|
7507
|
+
return {
|
|
7508
|
+
type: "array",
|
|
7509
|
+
items: itemType,
|
|
7510
|
+
...options,
|
|
7511
|
+
};
|
|
7512
|
+
}
|
|
7513
|
+
/**
|
|
7514
|
+
* Create an object field definition.
|
|
7515
|
+
*
|
|
7516
|
+
* @param options - Object field options
|
|
7517
|
+
* @returns A lexicon object field definition
|
|
7518
|
+
*
|
|
7519
|
+
* @example
|
|
7520
|
+
* ```typescript
|
|
7521
|
+
* const metadataField = createObjectField({
|
|
7522
|
+
* description: "Additional metadata",
|
|
7523
|
+
* properties: {
|
|
7524
|
+
* author: createStringField(),
|
|
7525
|
+
* version: createIntegerField(),
|
|
7526
|
+
* },
|
|
7527
|
+
* required: ["author"],
|
|
7528
|
+
* });
|
|
7529
|
+
* ```
|
|
7530
|
+
*/
|
|
7531
|
+
function createObjectField(options = {}) {
|
|
7532
|
+
return { type: "object", ...options };
|
|
7533
|
+
}
|
|
7534
|
+
/**
|
|
7535
|
+
* Create a blob field definition.
|
|
7536
|
+
*
|
|
7537
|
+
* @param options - Blob field options
|
|
7538
|
+
* @returns A lexicon blob field definition
|
|
7539
|
+
*
|
|
7540
|
+
* @example
|
|
7541
|
+
* ```typescript
|
|
7542
|
+
* const imageField = createBlobField({
|
|
7543
|
+
* description: "Profile image",
|
|
7544
|
+
* accept: ["image/png", "image/jpeg"],
|
|
7545
|
+
* maxSize: 1000000, // 1MB
|
|
7546
|
+
* });
|
|
7547
|
+
* ```
|
|
7548
|
+
*/
|
|
7549
|
+
function createBlobField(options = {}) {
|
|
7550
|
+
return { type: "blob", ...options };
|
|
7551
|
+
}
|
|
7552
|
+
/**
|
|
7553
|
+
* Create a datetime string field.
|
|
7554
|
+
*
|
|
7555
|
+
* This is a convenience function for creating string fields with datetime format.
|
|
7556
|
+
*
|
|
7557
|
+
* @param options - String field options
|
|
7558
|
+
* @returns A lexicon string field with datetime format
|
|
7559
|
+
*
|
|
7560
|
+
* @example
|
|
7561
|
+
* ```typescript
|
|
7562
|
+
* const createdAtField = createDatetimeField({
|
|
7563
|
+
* description: "When the record was created",
|
|
7564
|
+
* });
|
|
7565
|
+
* ```
|
|
7566
|
+
*/
|
|
7567
|
+
function createDatetimeField(options = {}) {
|
|
7568
|
+
return {
|
|
7569
|
+
type: "string",
|
|
7570
|
+
format: "datetime",
|
|
7571
|
+
...options,
|
|
7572
|
+
};
|
|
7573
|
+
}
|
|
7574
|
+
/**
|
|
7575
|
+
* Create a record definition.
|
|
7576
|
+
*
|
|
7577
|
+
* This defines the structure of records in your lexicon.
|
|
7578
|
+
*
|
|
7579
|
+
* @param properties - The record's properties (fields)
|
|
7580
|
+
* @param required - Array of required field names
|
|
7581
|
+
* @param keyType - The type of record key ("tid" for server-generated, "any" for custom)
|
|
7582
|
+
* @returns A lexicon record definition
|
|
7583
|
+
*
|
|
7584
|
+
* @example
|
|
7585
|
+
* ```typescript
|
|
7586
|
+
* const recordDef = createRecordDef(
|
|
7587
|
+
* {
|
|
7588
|
+
* $type: createStringField({ const: "org.myapp.evaluation" }),
|
|
7589
|
+
* subject: createStrongRefField({ description: "The evaluated item" }),
|
|
7590
|
+
* score: createIntegerField({ minimum: 0, maximum: 100 }),
|
|
7591
|
+
* createdAt: createDatetimeField(),
|
|
7592
|
+
* },
|
|
7593
|
+
* ["$type", "subject", "score", "createdAt"],
|
|
7594
|
+
* "tid"
|
|
7595
|
+
* );
|
|
7596
|
+
* ```
|
|
7597
|
+
*/
|
|
7598
|
+
function createRecordDef(properties, required, keyType = "tid") {
|
|
7599
|
+
return {
|
|
7600
|
+
type: "record",
|
|
7601
|
+
key: keyType,
|
|
7602
|
+
record: {
|
|
7603
|
+
type: "object",
|
|
7604
|
+
required,
|
|
7605
|
+
properties,
|
|
7606
|
+
},
|
|
7607
|
+
};
|
|
7608
|
+
}
|
|
7609
|
+
/**
|
|
7610
|
+
* Create a complete lexicon document.
|
|
7611
|
+
*
|
|
7612
|
+
* This creates a full lexicon JSON structure that can be registered with the SDK.
|
|
7613
|
+
*
|
|
7614
|
+
* @param nsid - The NSID (Namespaced Identifier) for this lexicon
|
|
7615
|
+
* @param properties - The record's properties (fields)
|
|
7616
|
+
* @param required - Array of required field names
|
|
7617
|
+
* @param keyType - The type of record key ("tid" for server-generated, "any" for custom)
|
|
7618
|
+
* @returns A complete lexicon document
|
|
7619
|
+
*
|
|
7620
|
+
* @example
|
|
7621
|
+
* ```typescript
|
|
7622
|
+
* const lexicon = createLexiconDoc(
|
|
7623
|
+
* "org.myapp.evaluation",
|
|
7624
|
+
* {
|
|
7625
|
+
* $type: createStringField({ const: "org.myapp.evaluation" }),
|
|
7626
|
+
* subject: createStrongRefField({ description: "The evaluated item" }),
|
|
7627
|
+
* score: createIntegerField({ minimum: 0, maximum: 100 }),
|
|
7628
|
+
* methodology: createStringField({ maxLength: 500 }),
|
|
7629
|
+
* createdAt: createDatetimeField(),
|
|
7630
|
+
* },
|
|
7631
|
+
* ["$type", "subject", "score", "createdAt"],
|
|
7632
|
+
* "tid"
|
|
7633
|
+
* );
|
|
7634
|
+
*
|
|
7635
|
+
* // Register with SDK
|
|
7636
|
+
* sdk.getLexiconRegistry().registerFromJSON(lexicon);
|
|
7637
|
+
* ```
|
|
7638
|
+
*/
|
|
7639
|
+
function createLexiconDoc(nsid, properties, required, keyType = "tid") {
|
|
7640
|
+
return {
|
|
7641
|
+
lexicon: 1,
|
|
7642
|
+
id: nsid,
|
|
7643
|
+
defs: {
|
|
7644
|
+
main: createRecordDef(properties, required, keyType),
|
|
7645
|
+
},
|
|
7646
|
+
};
|
|
7647
|
+
}
|
|
7648
|
+
/**
|
|
7649
|
+
* Validate a lexicon document structure.
|
|
7650
|
+
*
|
|
7651
|
+
* Performs basic validation to ensure the lexicon follows AT Protocol conventions.
|
|
7652
|
+
* This does NOT perform full JSON schema validation.
|
|
7653
|
+
*
|
|
7654
|
+
* @param lexicon - The lexicon document to validate
|
|
7655
|
+
* @returns True if valid, false otherwise
|
|
7656
|
+
*
|
|
7657
|
+
* @example
|
|
7658
|
+
* ```typescript
|
|
7659
|
+
* const lexicon = createLexiconDoc(...);
|
|
7660
|
+
* if (validateLexiconStructure(lexicon)) {
|
|
7661
|
+
* sdk.getLexiconRegistry().registerFromJSON(lexicon);
|
|
7662
|
+
* } else {
|
|
7663
|
+
* console.error("Invalid lexicon structure");
|
|
7664
|
+
* }
|
|
7665
|
+
* ```
|
|
7666
|
+
*/
|
|
7667
|
+
function validateLexiconStructure(lexicon) {
|
|
7668
|
+
if (!lexicon || typeof lexicon !== "object") {
|
|
7669
|
+
return false;
|
|
7670
|
+
}
|
|
7671
|
+
const doc = lexicon;
|
|
7672
|
+
// Check required top-level fields
|
|
7673
|
+
if (doc.lexicon !== 1)
|
|
7674
|
+
return false;
|
|
7675
|
+
if (typeof doc.id !== "string" || !doc.id)
|
|
7676
|
+
return false;
|
|
7677
|
+
if (!doc.defs || typeof doc.defs !== "object")
|
|
7678
|
+
return false;
|
|
7679
|
+
const defs = doc.defs;
|
|
7680
|
+
if (!defs.main || typeof defs.main !== "object")
|
|
7681
|
+
return false;
|
|
7682
|
+
const main = defs.main;
|
|
7683
|
+
if (main.type !== "record")
|
|
7684
|
+
return false;
|
|
7685
|
+
if (!main.record || typeof main.record !== "object")
|
|
7686
|
+
return false;
|
|
7687
|
+
const record = main.record;
|
|
7688
|
+
if (record.type !== "object")
|
|
7689
|
+
return false;
|
|
7690
|
+
if (!Array.isArray(record.required))
|
|
7691
|
+
return false;
|
|
7692
|
+
if (!record.properties || typeof record.properties !== "object")
|
|
7693
|
+
return false;
|
|
7694
|
+
return true;
|
|
7695
|
+
}
|
|
7696
|
+
|
|
7697
|
+
/**
|
|
7698
|
+
* Sidecar Pattern Utilities
|
|
7699
|
+
*
|
|
7700
|
+
* This module provides utilities for implementing the AT Protocol "sidecar pattern"
|
|
7701
|
+
* where additional records are created that reference a main record via StrongRef.
|
|
7702
|
+
*
|
|
7703
|
+
* ## The Sidecar Pattern
|
|
7704
|
+
*
|
|
7705
|
+
* In AT Protocol, the sidecar pattern uses **unidirectional references**:
|
|
7706
|
+
* - Sidecar records contain a StrongRef (uri + cid) pointing to the main record
|
|
7707
|
+
* - Main records do NOT maintain back-references to sidecars
|
|
7708
|
+
* - Sidecars are discovered by querying records that reference the main record
|
|
7709
|
+
* - Optionally, sidecars can use the same rkey as the main record (in different collections)
|
|
7710
|
+
*
|
|
7711
|
+
* ## Example Use Cases
|
|
7712
|
+
*
|
|
7713
|
+
* - Evaluations that reference hypercerts
|
|
7714
|
+
* - Comments that reference posts
|
|
7715
|
+
* - Metadata records that reference primary entities
|
|
7716
|
+
*
|
|
7717
|
+
* @see https://atproto.com/specs/record-key
|
|
7718
|
+
* @see https://atproto.com/specs/data-model
|
|
7719
|
+
*
|
|
7720
|
+
* @packageDocumentation
|
|
7721
|
+
*/
|
|
7722
|
+
/**
|
|
7723
|
+
* Create a sidecar record that references an existing record.
|
|
7724
|
+
*
|
|
7725
|
+
* This is a low-level utility that creates a single sidecar record. The sidecar
|
|
7726
|
+
* record should include a strongRef field that points to the main record.
|
|
7727
|
+
*
|
|
7728
|
+
* @param repo - The repository instance
|
|
7729
|
+
* @param collection - The collection NSID for the sidecar
|
|
7730
|
+
* @param record - The sidecar record data (should include a strongRef to the main record)
|
|
7731
|
+
* @param options - Optional creation options
|
|
7732
|
+
* @returns The created sidecar record
|
|
7733
|
+
*
|
|
7734
|
+
* @example
|
|
7735
|
+
* ```typescript
|
|
7736
|
+
* const hypercert = await repo.hypercerts.create({...});
|
|
7737
|
+
*
|
|
7738
|
+
* const evaluation = await createSidecarRecord(
|
|
7739
|
+
* repo,
|
|
7740
|
+
* "org.myapp.evaluation",
|
|
7741
|
+
* {
|
|
7742
|
+
* $type: "org.myapp.evaluation",
|
|
7743
|
+
* subject: { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid },
|
|
7744
|
+
* score: 85,
|
|
7745
|
+
* createdAt: new Date().toISOString(),
|
|
7746
|
+
* }
|
|
7747
|
+
* );
|
|
7748
|
+
* ```
|
|
7749
|
+
*/
|
|
7750
|
+
async function createSidecarRecord(repo, collection, record, options = {}) {
|
|
7751
|
+
return await repo.records.create({
|
|
7752
|
+
collection,
|
|
7753
|
+
record,
|
|
7754
|
+
rkey: options.rkey,
|
|
7755
|
+
});
|
|
7756
|
+
}
|
|
7757
|
+
/**
|
|
7758
|
+
* Attach a sidecar record to an existing main record.
|
|
7759
|
+
*
|
|
7760
|
+
* This creates a new record that references an existing record via strongRef.
|
|
7761
|
+
* It's a higher-level convenience function that wraps `createSidecarRecord`.
|
|
7762
|
+
*
|
|
7763
|
+
* @param repo - The repository instance
|
|
7764
|
+
* @param params - Parameters including the main record reference and sidecar definition
|
|
7765
|
+
* @returns Both the main record reference and the created sidecar
|
|
7766
|
+
*
|
|
7767
|
+
* @example
|
|
7768
|
+
* ```typescript
|
|
7769
|
+
* const hypercert = await repo.hypercerts.create({...});
|
|
7770
|
+
*
|
|
7771
|
+
* const result = await attachSidecar(repo, {
|
|
7772
|
+
* mainRecord: {
|
|
7773
|
+
* uri: hypercert.hypercertUri,
|
|
7774
|
+
* cid: hypercert.hypercertCid,
|
|
7775
|
+
* },
|
|
7776
|
+
* sidecar: {
|
|
7777
|
+
* collection: "org.myapp.evaluation",
|
|
7778
|
+
* record: {
|
|
7779
|
+
* $type: "org.myapp.evaluation",
|
|
7780
|
+
* subject: { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid },
|
|
7781
|
+
* score: 85,
|
|
7782
|
+
* createdAt: new Date().toISOString(),
|
|
7783
|
+
* },
|
|
7784
|
+
* },
|
|
7785
|
+
* });
|
|
7786
|
+
* ```
|
|
7787
|
+
*/
|
|
7788
|
+
async function attachSidecar(repo, params) {
|
|
7789
|
+
const sidecarRecord = await createSidecarRecord(repo, params.sidecar.collection, params.sidecar.record, {
|
|
7790
|
+
rkey: params.sidecar.rkey,
|
|
7791
|
+
});
|
|
7792
|
+
return {
|
|
7793
|
+
mainRecord: params.mainRecord,
|
|
7794
|
+
sidecarRecord,
|
|
7795
|
+
};
|
|
7796
|
+
}
|
|
7797
|
+
/**
|
|
7798
|
+
* Create a main record and multiple sidecar records in sequence.
|
|
7799
|
+
*
|
|
7800
|
+
* This orchestrates the creation of a main record followed by one or more
|
|
7801
|
+
* sidecar records that reference it. This is useful for workflows like:
|
|
7802
|
+
* - Creating a project with multiple hypercert claims
|
|
7803
|
+
* - Creating a hypercert with evidence and evaluation records
|
|
7804
|
+
*
|
|
7805
|
+
* @param repo - The repository instance
|
|
7806
|
+
* @param params - Parameters including the main record and sidecar definitions
|
|
7807
|
+
* @returns The main record and all created sidecar records
|
|
7808
|
+
*
|
|
7809
|
+
* @example
|
|
7810
|
+
* ```typescript
|
|
7811
|
+
* const result = await createWithSidecars(repo, {
|
|
7812
|
+
* main: {
|
|
7813
|
+
* collection: "org.hypercerts.project",
|
|
7814
|
+
* record: {
|
|
7815
|
+
* $type: "org.hypercerts.project",
|
|
7816
|
+
* title: "Climate Initiative 2024",
|
|
7817
|
+
* description: "Our climate work",
|
|
7818
|
+
* createdAt: new Date().toISOString(),
|
|
7819
|
+
* },
|
|
7820
|
+
* },
|
|
7821
|
+
* sidecars: [
|
|
7822
|
+
* {
|
|
7823
|
+
* collection: "org.hypercerts.claim.activity",
|
|
7824
|
+
* record: {
|
|
7825
|
+
* $type: "org.hypercerts.claim.activity",
|
|
7826
|
+
* title: "Tree Planting",
|
|
7827
|
+
* // ... other hypercert fields
|
|
7828
|
+
* // Note: If you need to reference the main record, you must wait
|
|
7829
|
+
* // for result.main and then call batchCreateSidecars separately
|
|
7830
|
+
* },
|
|
7831
|
+
* },
|
|
7832
|
+
* {
|
|
7833
|
+
* collection: "org.hypercerts.claim.activity",
|
|
7834
|
+
* record: {
|
|
7835
|
+
* $type: "org.hypercerts.claim.activity",
|
|
7836
|
+
* title: "Carbon Measurement",
|
|
7837
|
+
* // ... other hypercert fields
|
|
7838
|
+
* },
|
|
7839
|
+
* },
|
|
7840
|
+
* ],
|
|
7841
|
+
* });
|
|
7842
|
+
*
|
|
7843
|
+
* console.log(result.main.uri); // Main project record
|
|
7844
|
+
* console.log(result.sidecars.length); // 2 hypercert sidecars
|
|
7845
|
+
* ```
|
|
7846
|
+
*/
|
|
7847
|
+
async function createWithSidecars(repo, params) {
|
|
7848
|
+
// Create the main record first
|
|
7849
|
+
const main = await repo.records.create({
|
|
7850
|
+
collection: params.main.collection,
|
|
7851
|
+
record: params.main.record,
|
|
7852
|
+
rkey: params.main.rkey,
|
|
7853
|
+
});
|
|
7854
|
+
// Create all sidecar records
|
|
7855
|
+
const sidecars = [];
|
|
7856
|
+
for (const sidecar of params.sidecars) {
|
|
7857
|
+
const created = await repo.records.create({
|
|
7858
|
+
collection: sidecar.collection,
|
|
7859
|
+
record: sidecar.record,
|
|
7860
|
+
rkey: sidecar.rkey,
|
|
7861
|
+
});
|
|
7862
|
+
sidecars.push(created);
|
|
7863
|
+
}
|
|
7864
|
+
return { main, sidecars };
|
|
7865
|
+
}
|
|
7866
|
+
/**
|
|
7867
|
+
* Batch create multiple sidecar records.
|
|
7868
|
+
*
|
|
7869
|
+
* This is useful when you want to add multiple related records
|
|
7870
|
+
* efficiently. The sidecar records should already contain any necessary
|
|
7871
|
+
* references to the main record in their data.
|
|
7872
|
+
*
|
|
7873
|
+
* @param repo - The repository instance
|
|
7874
|
+
* @param sidecars - Array of sidecar definitions (records should include references)
|
|
7875
|
+
* @returns Array of created sidecar records
|
|
7876
|
+
*
|
|
7877
|
+
* @example
|
|
7878
|
+
* ```typescript
|
|
7879
|
+
* const hypercert = await repo.hypercerts.create({...});
|
|
7880
|
+
* const mainRef = { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid };
|
|
7881
|
+
*
|
|
7882
|
+
* const evaluations = await batchCreateSidecars(repo, [
|
|
7883
|
+
* {
|
|
7884
|
+
* collection: "org.myapp.evaluation",
|
|
7885
|
+
* record: {
|
|
7886
|
+
* $type: "org.myapp.evaluation",
|
|
7887
|
+
* subject: mainRef, // Reference already included
|
|
7888
|
+
* score: 85,
|
|
7889
|
+
* methodology: "Peer review",
|
|
7890
|
+
* createdAt: new Date().toISOString(),
|
|
7891
|
+
* },
|
|
7892
|
+
* },
|
|
7893
|
+
* {
|
|
7894
|
+
* collection: "org.myapp.comment",
|
|
7895
|
+
* record: {
|
|
7896
|
+
* $type: "org.myapp.comment",
|
|
7897
|
+
* subject: mainRef, // Reference already included
|
|
7898
|
+
* text: "Great work!",
|
|
7899
|
+
* createdAt: new Date().toISOString(),
|
|
7900
|
+
* },
|
|
7901
|
+
* },
|
|
7902
|
+
* ]);
|
|
7903
|
+
* ```
|
|
7904
|
+
*/
|
|
7905
|
+
async function batchCreateSidecars(repo, sidecars) {
|
|
7906
|
+
const results = [];
|
|
7907
|
+
for (const sidecar of sidecars) {
|
|
7908
|
+
const result = await createSidecarRecord(repo, sidecar.collection, sidecar.record, {
|
|
7909
|
+
rkey: sidecar.rkey,
|
|
7910
|
+
});
|
|
7911
|
+
results.push(result);
|
|
7912
|
+
}
|
|
7913
|
+
return results;
|
|
5666
7914
|
}
|
|
5667
7915
|
|
|
5668
7916
|
/**
|
|
@@ -5824,9 +8072,13 @@ Object.defineProperty(exports, "OrgHypercertsClaimCollection", {
|
|
|
5824
8072
|
enumerable: true,
|
|
5825
8073
|
get: function () { return lexicon.OrgHypercertsClaimCollection; }
|
|
5826
8074
|
});
|
|
5827
|
-
Object.defineProperty(exports, "
|
|
8075
|
+
Object.defineProperty(exports, "OrgHypercertsClaimContributionDetails", {
|
|
8076
|
+
enumerable: true,
|
|
8077
|
+
get: function () { return lexicon.OrgHypercertsClaimContributionDetails; }
|
|
8078
|
+
});
|
|
8079
|
+
Object.defineProperty(exports, "OrgHypercertsClaimContributorInformation", {
|
|
5828
8080
|
enumerable: true,
|
|
5829
|
-
get: function () { return lexicon.
|
|
8081
|
+
get: function () { return lexicon.OrgHypercertsClaimContributorInformation; }
|
|
5830
8082
|
});
|
|
5831
8083
|
Object.defineProperty(exports, "OrgHypercertsClaimEvaluation", {
|
|
5832
8084
|
enumerable: true,
|
|
@@ -5840,10 +8092,6 @@ Object.defineProperty(exports, "OrgHypercertsClaimMeasurement", {
|
|
|
5840
8092
|
enumerable: true,
|
|
5841
8093
|
get: function () { return lexicon.OrgHypercertsClaimMeasurement; }
|
|
5842
8094
|
});
|
|
5843
|
-
Object.defineProperty(exports, "OrgHypercertsClaimProject", {
|
|
5844
|
-
enumerable: true,
|
|
5845
|
-
get: function () { return lexicon.OrgHypercertsClaimProject; }
|
|
5846
|
-
});
|
|
5847
8095
|
Object.defineProperty(exports, "OrgHypercertsClaimRights", {
|
|
5848
8096
|
enumerable: true,
|
|
5849
8097
|
get: function () { return lexicon.OrgHypercertsClaimRights; }
|
|
@@ -5852,6 +8100,10 @@ Object.defineProperty(exports, "OrgHypercertsFundingReceipt", {
|
|
|
5852
8100
|
enumerable: true,
|
|
5853
8101
|
get: function () { return lexicon.OrgHypercertsFundingReceipt; }
|
|
5854
8102
|
});
|
|
8103
|
+
Object.defineProperty(exports, "OrgHypercertsHelperWorkScopeTag", {
|
|
8104
|
+
enumerable: true,
|
|
8105
|
+
get: function () { return lexicon.OrgHypercertsHelperWorkScopeTag; }
|
|
8106
|
+
});
|
|
5855
8107
|
Object.defineProperty(exports, "validate", {
|
|
5856
8108
|
enumerable: true,
|
|
5857
8109
|
get: function () { return lexicon.validate; }
|
|
@@ -5864,6 +8116,7 @@ exports.AccountActionSchema = AccountActionSchema;
|
|
|
5864
8116
|
exports.AccountAttrSchema = AccountAttrSchema;
|
|
5865
8117
|
exports.AccountPermissionSchema = AccountPermissionSchema;
|
|
5866
8118
|
exports.AuthenticationError = AuthenticationError;
|
|
8119
|
+
exports.BaseOperations = BaseOperations;
|
|
5867
8120
|
exports.BlobPermissionSchema = BlobPermissionSchema;
|
|
5868
8121
|
exports.CollaboratorPermissionsSchema = CollaboratorPermissionsSchema;
|
|
5869
8122
|
exports.CollaboratorSchema = CollaboratorSchema;
|
|
@@ -5875,6 +8128,7 @@ exports.IdentityPermissionSchema = IdentityPermissionSchema;
|
|
|
5875
8128
|
exports.InMemorySessionStore = InMemorySessionStore;
|
|
5876
8129
|
exports.InMemoryStateStore = InMemoryStateStore;
|
|
5877
8130
|
exports.IncludePermissionSchema = IncludePermissionSchema;
|
|
8131
|
+
exports.LexiconRegistry = LexiconRegistry;
|
|
5878
8132
|
exports.MimeTypeSchema = MimeTypeSchema;
|
|
5879
8133
|
exports.NetworkError = NetworkError;
|
|
5880
8134
|
exports.NsidSchema = NsidSchema;
|
|
@@ -5894,13 +8148,37 @@ exports.TRANSITION_SCOPES = TRANSITION_SCOPES;
|
|
|
5894
8148
|
exports.TimeoutConfigSchema = TimeoutConfigSchema;
|
|
5895
8149
|
exports.TransitionScopeSchema = TransitionScopeSchema;
|
|
5896
8150
|
exports.ValidationError = ValidationError;
|
|
8151
|
+
exports.attachSidecar = attachSidecar;
|
|
8152
|
+
exports.batchCreateSidecars = batchCreateSidecars;
|
|
8153
|
+
exports.buildAtUri = buildAtUri;
|
|
5897
8154
|
exports.buildScope = buildScope;
|
|
5898
8155
|
exports.createATProtoSDK = createATProtoSDK;
|
|
8156
|
+
exports.createArrayField = createArrayField;
|
|
8157
|
+
exports.createBlobField = createBlobField;
|
|
8158
|
+
exports.createBooleanField = createBooleanField;
|
|
8159
|
+
exports.createDatetimeField = createDatetimeField;
|
|
8160
|
+
exports.createIntegerField = createIntegerField;
|
|
8161
|
+
exports.createLexiconDoc = createLexiconDoc;
|
|
8162
|
+
exports.createNumberField = createNumberField;
|
|
8163
|
+
exports.createObjectField = createObjectField;
|
|
8164
|
+
exports.createRecordDef = createRecordDef;
|
|
8165
|
+
exports.createSidecarRecord = createSidecarRecord;
|
|
8166
|
+
exports.createStringField = createStringField;
|
|
8167
|
+
exports.createStrongRef = createStrongRef;
|
|
8168
|
+
exports.createStrongRefField = createStrongRefField;
|
|
8169
|
+
exports.createStrongRefFromResult = createStrongRefFromResult;
|
|
8170
|
+
exports.createWithSidecars = createWithSidecars;
|
|
8171
|
+
exports.extractRkeyFromUri = extractRkeyFromUri;
|
|
5899
8172
|
exports.hasAllPermissions = hasAllPermissions;
|
|
5900
8173
|
exports.hasAnyPermission = hasAnyPermission;
|
|
5901
8174
|
exports.hasPermission = hasPermission;
|
|
8175
|
+
exports.isStrongRef = isStrongRef;
|
|
8176
|
+
exports.isValidAtUri = isValidAtUri;
|
|
5902
8177
|
exports.mergeScopes = mergeScopes;
|
|
8178
|
+
exports.parseAtUri = parseAtUri;
|
|
5903
8179
|
exports.parseScope = parseScope;
|
|
5904
8180
|
exports.removePermissions = removePermissions;
|
|
8181
|
+
exports.validateLexiconStructure = validateLexiconStructure;
|
|
5905
8182
|
exports.validateScope = validateScope;
|
|
8183
|
+
exports.validateStrongRef = validateStrongRef;
|
|
5906
8184
|
//# sourceMappingURL=index.cjs.map
|