@hypercerts-org/sdk-core 0.10.0-beta.4 → 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 +153 -0
- package/README.md +131 -6
- package/dist/index.cjs +2597 -320
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1900 -169
- package/dist/index.mjs +3588 -1341
- 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 +358 -66
- 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) {
|
|
@@ -3632,50 +4198,18 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3632
4198
|
*/
|
|
3633
4199
|
async attachLocation(hypercertUri, location) {
|
|
3634
4200
|
try {
|
|
3635
|
-
// Validate required srs field
|
|
3636
4201
|
if (!location.srs) {
|
|
3637
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.");
|
|
3638
4203
|
}
|
|
3639
4204
|
// Validate that hypercert exists (unused but confirms hypercert is valid)
|
|
3640
4205
|
await this.get(hypercertUri);
|
|
3641
4206
|
const createdAt = new Date().toISOString();
|
|
3642
|
-
|
|
3643
|
-
let locationData;
|
|
3644
|
-
let locationType;
|
|
3645
|
-
if (location.geojson) {
|
|
3646
|
-
// Upload GeoJSON as a blob
|
|
3647
|
-
const arrayBuffer = await location.geojson.arrayBuffer();
|
|
3648
|
-
const uint8Array = new Uint8Array(arrayBuffer);
|
|
3649
|
-
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
3650
|
-
encoding: location.geojson.type || "application/geo+json",
|
|
3651
|
-
});
|
|
3652
|
-
if (uploadResult.success) {
|
|
3653
|
-
locationData = {
|
|
3654
|
-
$type: "blob",
|
|
3655
|
-
ref: { $link: uploadResult.data.blob.ref.toString() },
|
|
3656
|
-
mimeType: uploadResult.data.blob.mimeType,
|
|
3657
|
-
size: uploadResult.data.blob.size,
|
|
3658
|
-
};
|
|
3659
|
-
locationType = "geojson-point";
|
|
3660
|
-
}
|
|
3661
|
-
else {
|
|
3662
|
-
throw new NetworkError("Failed to upload GeoJSON blob");
|
|
3663
|
-
}
|
|
3664
|
-
}
|
|
3665
|
-
else {
|
|
3666
|
-
// Use value as a URI reference
|
|
3667
|
-
locationData = {
|
|
3668
|
-
$type: "org.hypercerts.defs#uri",
|
|
3669
|
-
uri: location.value,
|
|
3670
|
-
};
|
|
3671
|
-
locationType = "coordinate-decimal";
|
|
3672
|
-
}
|
|
3673
|
-
// Build location record according to app.certified.location lexicon
|
|
4207
|
+
const locationData = await this.resolveUriOrBlob(location.location, "application/geo+json");
|
|
3674
4208
|
const locationRecord = {
|
|
3675
4209
|
$type: HYPERCERT_COLLECTIONS.LOCATION,
|
|
3676
|
-
lpVersion: "1.0",
|
|
4210
|
+
lpVersion: location.lpVersion || "1.0",
|
|
3677
4211
|
srs: location.srs,
|
|
3678
|
-
locationType,
|
|
4212
|
+
locationType: location.locationType,
|
|
3679
4213
|
location: locationData,
|
|
3680
4214
|
createdAt,
|
|
3681
4215
|
name: location.name,
|
|
@@ -3693,6 +4227,16 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3693
4227
|
if (!result.success) {
|
|
3694
4228
|
throw new NetworkError("Failed to attach location");
|
|
3695
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
|
+
});
|
|
3696
4240
|
this.emit("locationAttached", { uri: result.data.uri, cid: result.data.cid, hypercertUri });
|
|
3697
4241
|
return { uri: result.data.uri, cid: result.data.cid };
|
|
3698
4242
|
}
|
|
@@ -3703,36 +4247,75 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3703
4247
|
}
|
|
3704
4248
|
}
|
|
3705
4249
|
/**
|
|
3706
|
-
*
|
|
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.
|
|
3707
4274
|
*
|
|
3708
|
-
* @param
|
|
3709
|
-
* @param evidence - Array of evidence items to add
|
|
4275
|
+
* @param evidence - HypercertEvidenceInput
|
|
3710
4276
|
* @returns Promise resolving to update result
|
|
3711
4277
|
* @throws {@link ValidationError} if validation fails
|
|
3712
4278
|
* @throws {@link NetworkError} if the operation fails
|
|
3713
4279
|
*
|
|
3714
|
-
* @remarks
|
|
3715
|
-
* Evidence is appended to existing evidence, not replaced.
|
|
3716
|
-
*
|
|
3717
4280
|
* @example
|
|
3718
4281
|
* ```typescript
|
|
3719
|
-
* await repo.hypercerts.addEvidence(
|
|
3720
|
-
*
|
|
3721
|
-
*
|
|
3722
|
-
*
|
|
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
|
+
* })
|
|
3723
4290
|
* ```
|
|
3724
4291
|
*/
|
|
3725
|
-
async addEvidence(
|
|
4292
|
+
async addEvidence(evidence) {
|
|
3726
4293
|
try {
|
|
3727
|
-
const
|
|
3728
|
-
const
|
|
3729
|
-
const
|
|
3730
|
-
const
|
|
3731
|
-
|
|
3732
|
-
|
|
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,
|
|
3733
4313
|
});
|
|
3734
|
-
|
|
3735
|
-
|
|
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 };
|
|
3736
4319
|
}
|
|
3737
4320
|
catch (error) {
|
|
3738
4321
|
if (error instanceof ValidationError || error instanceof NetworkError)
|
|
@@ -3741,22 +4324,29 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3741
4324
|
}
|
|
3742
4325
|
}
|
|
3743
4326
|
/**
|
|
3744
|
-
* 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.
|
|
3745
4331
|
*
|
|
3746
4332
|
* @param params - Contribution parameters
|
|
3747
|
-
* @param params.hypercertUri - Optional hypercert
|
|
3748
|
-
* @param params.contributors - Array of contributor DIDs
|
|
3749
|
-
* @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")
|
|
3750
4336
|
* @param params.description - Optional description of the contribution
|
|
3751
|
-
* @returns Promise resolving to contribution record URI and CID
|
|
4337
|
+
* @returns Promise resolving to contribution details record URI and CID
|
|
3752
4338
|
* @throws {@link ValidationError} if validation fails
|
|
3753
4339
|
* @throws {@link NetworkError} if the operation fails
|
|
3754
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
|
+
*
|
|
3755
4347
|
* @example
|
|
3756
4348
|
* ```typescript
|
|
3757
4349
|
* await repo.hypercerts.addContribution({
|
|
3758
|
-
* hypercertUri: hypercertUri,
|
|
3759
|
-
* contributors: ["did:plc:alice", "did:plc:bob"],
|
|
3760
4350
|
* role: "implementer",
|
|
3761
4351
|
* description: "On-ground implementation team",
|
|
3762
4352
|
* });
|
|
@@ -3766,28 +4356,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3766
4356
|
try {
|
|
3767
4357
|
const createdAt = new Date().toISOString();
|
|
3768
4358
|
const contributionRecord = {
|
|
3769
|
-
$type: HYPERCERT_COLLECTIONS.
|
|
3770
|
-
contributors: params.contributors,
|
|
4359
|
+
$type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
|
|
3771
4360
|
role: params.role,
|
|
3772
4361
|
createdAt,
|
|
3773
|
-
|
|
3774
|
-
hypercert: { uri: "", cid: "" }, // Will be set below if hypercertUri provided
|
|
4362
|
+
contributionDescription: params.description,
|
|
3775
4363
|
};
|
|
3776
|
-
|
|
3777
|
-
const hypercert = await this.get(params.hypercertUri);
|
|
3778
|
-
contributionRecord.hypercert = { uri: hypercert.uri, cid: hypercert.cid };
|
|
3779
|
-
}
|
|
3780
|
-
const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION, "main", false);
|
|
4364
|
+
const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false);
|
|
3781
4365
|
if (!validation.success) {
|
|
3782
|
-
throw new ValidationError(`Invalid contribution record: ${validation.error?.message}`);
|
|
4366
|
+
throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`);
|
|
3783
4367
|
}
|
|
3784
4368
|
const result = await this.agent.com.atproto.repo.createRecord({
|
|
3785
4369
|
repo: this.repoDid,
|
|
3786
|
-
collection: HYPERCERT_COLLECTIONS.
|
|
4370
|
+
collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
|
|
3787
4371
|
record: contributionRecord,
|
|
3788
4372
|
});
|
|
3789
4373
|
if (!result.success) {
|
|
3790
|
-
throw new NetworkError("Failed to create contribution");
|
|
4374
|
+
throw new NetworkError("Failed to create contribution details");
|
|
3791
4375
|
}
|
|
3792
4376
|
this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid });
|
|
3793
4377
|
return { uri: result.data.uri, cid: result.data.cid };
|
|
@@ -3924,7 +4508,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3924
4508
|
* @param params.title - Collection title
|
|
3925
4509
|
* @param params.claims - Array of hypercert references with weights
|
|
3926
4510
|
* @param params.shortDescription - Optional short description
|
|
3927
|
-
* @param params.
|
|
4511
|
+
* @param params.banner - Optional cover image blob
|
|
3928
4512
|
* @returns Promise resolving to collection record URI and CID
|
|
3929
4513
|
* @throws {@link ValidationError} if validation fails
|
|
3930
4514
|
* @throws {@link NetworkError} if the operation fails
|
|
@@ -3939,22 +4523,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3939
4523
|
* { uri: hypercert2Uri, cid: hypercert2Cid, weight: "0.3" },
|
|
3940
4524
|
* { uri: hypercert3Uri, cid: hypercert3Cid, weight: "0.2" },
|
|
3941
4525
|
* ],
|
|
3942
|
-
*
|
|
4526
|
+
* banner: coverImageBlob,
|
|
3943
4527
|
* });
|
|
3944
4528
|
* ```
|
|
3945
4529
|
*/
|
|
3946
4530
|
async createCollection(params) {
|
|
3947
4531
|
try {
|
|
3948
4532
|
const createdAt = new Date().toISOString();
|
|
3949
|
-
let
|
|
3950
|
-
if (params.
|
|
3951
|
-
const arrayBuffer = await params.
|
|
4533
|
+
let bannerRef;
|
|
4534
|
+
if (params.banner) {
|
|
4535
|
+
const arrayBuffer = await params.banner.arrayBuffer();
|
|
3952
4536
|
const uint8Array = new Uint8Array(arrayBuffer);
|
|
3953
4537
|
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
3954
|
-
encoding: params.
|
|
4538
|
+
encoding: params.banner.type || "image/jpeg",
|
|
3955
4539
|
});
|
|
3956
4540
|
if (uploadResult.success) {
|
|
3957
|
-
|
|
4541
|
+
bannerRef = {
|
|
3958
4542
|
$type: "blob",
|
|
3959
4543
|
ref: { $link: uploadResult.data.blob.ref.toString() },
|
|
3960
4544
|
mimeType: uploadResult.data.blob.mimeType,
|
|
@@ -3971,8 +4555,8 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
3971
4555
|
if (params.shortDescription) {
|
|
3972
4556
|
collectionRecord.shortDescription = params.shortDescription;
|
|
3973
4557
|
}
|
|
3974
|
-
if (
|
|
3975
|
-
collectionRecord.
|
|
4558
|
+
if (bannerRef) {
|
|
4559
|
+
collectionRecord.banner = bannerRef;
|
|
3976
4560
|
}
|
|
3977
4561
|
const validation = lexicon.validate(collectionRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
|
|
3978
4562
|
if (!validation.success) {
|
|
@@ -4083,190 +4667,606 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
|
|
|
4083
4667
|
throw new NetworkError(`Failed to list collections: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
4084
4668
|
}
|
|
4085
4669
|
}
|
|
4086
|
-
}
|
|
4087
|
-
|
|
4088
|
-
/**
|
|
4089
|
-
* CollaboratorOperationsImpl - SDS collaborator management operations.
|
|
4090
|
-
*
|
|
4091
|
-
* This module provides the implementation for managing collaborator
|
|
4092
|
-
* access on Shared Data Server (SDS) repositories.
|
|
4093
|
-
*
|
|
4094
|
-
* @packageDocumentation
|
|
4095
|
-
*/
|
|
4096
|
-
/**
|
|
4097
|
-
* Implementation of collaborator operations for SDS access control.
|
|
4098
|
-
*
|
|
4099
|
-
* This class manages access permissions for shared repositories on
|
|
4100
|
-
* Shared Data Servers (SDS). It provides role-based access control
|
|
4101
|
-
* with predefined permission sets.
|
|
4102
|
-
*
|
|
4103
|
-
* @remarks
|
|
4104
|
-
* This class is typically not instantiated directly. Access it through
|
|
4105
|
-
* {@link Repository.collaborators} on an SDS-connected repository.
|
|
4106
|
-
*
|
|
4107
|
-
* **Role Hierarchy**:
|
|
4108
|
-
* - `viewer`: Read-only access
|
|
4109
|
-
* - `editor`: Read + Create + Update
|
|
4110
|
-
* - `admin`: All permissions except ownership transfer
|
|
4111
|
-
* - `owner`: Full control including ownership management
|
|
4112
|
-
*
|
|
4113
|
-
* **SDS API Endpoints Used**:
|
|
4114
|
-
* - `com.sds.repo.grantAccess`: Grant access to a user
|
|
4115
|
-
* - `com.sds.repo.revokeAccess`: Revoke access from a user
|
|
4116
|
-
* - `com.sds.repo.listCollaborators`: List all collaborators
|
|
4117
|
-
* - `com.sds.repo.getPermissions`: Get current user's permissions
|
|
4118
|
-
* - `com.sds.repo.transferOwnership`: Transfer repository ownership
|
|
4119
|
-
*
|
|
4120
|
-
* @example
|
|
4121
|
-
* ```typescript
|
|
4122
|
-
* // Get SDS repository
|
|
4123
|
-
* const sdsRepo = sdk.repository(session, { server: "sds" });
|
|
4124
|
-
*
|
|
4125
|
-
* // Grant editor access
|
|
4126
|
-
* await sdsRepo.collaborators.grant({
|
|
4127
|
-
* userDid: "did:plc:new-user",
|
|
4128
|
-
* role: "editor",
|
|
4129
|
-
* });
|
|
4130
|
-
*
|
|
4131
|
-
* // List all collaborators
|
|
4132
|
-
* const collaborators = await sdsRepo.collaborators.list();
|
|
4133
|
-
*
|
|
4134
|
-
* // Check specific user
|
|
4135
|
-
* const hasAccess = await sdsRepo.collaborators.hasAccess("did:plc:someone");
|
|
4136
|
-
* const role = await sdsRepo.collaborators.getRole("did:plc:someone");
|
|
4137
|
-
* ```
|
|
4138
|
-
*
|
|
4139
|
-
* @internal
|
|
4140
|
-
*/
|
|
4141
|
-
class CollaboratorOperationsImpl {
|
|
4142
|
-
/**
|
|
4143
|
-
* Creates a new CollaboratorOperationsImpl.
|
|
4144
|
-
*
|
|
4145
|
-
* @param session - Authenticated OAuth session with fetchHandler
|
|
4146
|
-
* @param repoDid - DID of the repository to manage
|
|
4147
|
-
* @param serverUrl - SDS server URL
|
|
4148
|
-
*
|
|
4149
|
-
* @internal
|
|
4150
|
-
*/
|
|
4151
|
-
constructor(session, repoDid, serverUrl) {
|
|
4152
|
-
this.session = session;
|
|
4153
|
-
this.repoDid = repoDid;
|
|
4154
|
-
this.serverUrl = serverUrl;
|
|
4155
|
-
}
|
|
4156
|
-
/**
|
|
4157
|
-
* Converts a role to its corresponding permissions object.
|
|
4158
|
-
*
|
|
4159
|
-
* @param role - The role to convert
|
|
4160
|
-
* @returns Permission flags for the role
|
|
4161
|
-
* @internal
|
|
4162
|
-
*/
|
|
4163
|
-
roleToPermissions(role) {
|
|
4164
|
-
switch (role) {
|
|
4165
|
-
case "viewer":
|
|
4166
|
-
return { read: true, create: false, update: false, delete: false, admin: false, owner: false };
|
|
4167
|
-
case "editor":
|
|
4168
|
-
return { read: true, create: true, update: true, delete: false, admin: false, owner: false };
|
|
4169
|
-
case "admin":
|
|
4170
|
-
return { read: true, create: true, update: true, delete: true, admin: true, owner: false };
|
|
4171
|
-
case "owner":
|
|
4172
|
-
return { read: true, create: true, update: true, delete: true, admin: true, owner: true };
|
|
4173
|
-
}
|
|
4174
|
-
}
|
|
4175
|
-
/**
|
|
4176
|
-
* Determines the role from a permissions object.
|
|
4177
|
-
*
|
|
4178
|
-
* @param permissions - The permissions to analyze
|
|
4179
|
-
* @returns The highest role matching the permissions
|
|
4180
|
-
* @internal
|
|
4181
|
-
*/
|
|
4182
|
-
permissionsToRole(permissions) {
|
|
4183
|
-
if (permissions.owner)
|
|
4184
|
-
return "owner";
|
|
4185
|
-
if (permissions.admin)
|
|
4186
|
-
return "admin";
|
|
4187
|
-
if (permissions.create || permissions.update)
|
|
4188
|
-
return "editor";
|
|
4189
|
-
return "viewer";
|
|
4190
|
-
}
|
|
4191
|
-
/**
|
|
4192
|
-
* Normalizes permissions from SDS API format to SDK format.
|
|
4193
|
-
*
|
|
4194
|
-
* The SDS API returns permissions as an object with boolean flags
|
|
4195
|
-
* (e.g., `{ read: true, create: true, update: false, ... }`).
|
|
4196
|
-
* This method ensures all expected fields are present with default values.
|
|
4197
|
-
*
|
|
4198
|
-
* @param permissions - Permissions object from SDS API
|
|
4199
|
-
* @returns Normalized permission flags object
|
|
4200
|
-
* @internal
|
|
4201
|
-
*/
|
|
4202
|
-
parsePermissions(permissions) {
|
|
4203
|
-
return {
|
|
4204
|
-
read: permissions.read ?? false,
|
|
4205
|
-
create: permissions.create ?? false,
|
|
4206
|
-
update: permissions.update ?? false,
|
|
4207
|
-
delete: permissions.delete ?? false,
|
|
4208
|
-
admin: permissions.admin ?? false,
|
|
4209
|
-
owner: permissions.owner ?? false,
|
|
4210
|
-
};
|
|
4211
|
-
}
|
|
4212
4670
|
/**
|
|
4213
|
-
*
|
|
4671
|
+
* Creates a new project that organizes multiple hypercert activities.
|
|
4214
4672
|
*
|
|
4215
|
-
*
|
|
4216
|
-
* @param params.userDid - DID of the user to grant access to
|
|
4217
|
-
* @param params.role - Role to assign (determines permissions)
|
|
4218
|
-
* @throws {@link NetworkError} if the grant operation fails
|
|
4673
|
+
* Projects are now implemented as collections with `type='project'`.
|
|
4219
4674
|
*
|
|
4220
|
-
* @
|
|
4221
|
-
*
|
|
4222
|
-
* to the new role.
|
|
4675
|
+
* @param params - Project creation parameters
|
|
4676
|
+
* @returns Promise resolving to created project URI and CID
|
|
4223
4677
|
*
|
|
4224
4678
|
* @example
|
|
4225
4679
|
* ```typescript
|
|
4226
|
-
*
|
|
4227
|
-
*
|
|
4228
|
-
*
|
|
4229
|
-
*
|
|
4230
|
-
* }
|
|
4231
|
-
*
|
|
4232
|
-
*
|
|
4233
|
-
* await repo.collaborators.grant({
|
|
4234
|
-
* userDid: "did:plc:viewer-user",
|
|
4235
|
-
* role: "editor",
|
|
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
|
+
* ]
|
|
4236
4687
|
* });
|
|
4688
|
+
* console.log(`Created project: ${result.uri}`);
|
|
4237
4689
|
* ```
|
|
4238
4690
|
*/
|
|
4239
|
-
async
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
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({
|
|
4245
4765
|
repo: this.repoDid,
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
})
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
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);
|
|
4252
4780
|
}
|
|
4253
4781
|
}
|
|
4254
4782
|
/**
|
|
4255
|
-
*
|
|
4783
|
+
* Gets a project by its AT-URI.
|
|
4256
4784
|
*
|
|
4257
|
-
*
|
|
4258
|
-
* @param params.userDid - DID of the user to revoke access from
|
|
4259
|
-
* @throws {@link NetworkError} if the revoke operation fails
|
|
4785
|
+
* Projects are collections with `type='project'`.
|
|
4260
4786
|
*
|
|
4261
|
-
* @
|
|
4262
|
-
*
|
|
4263
|
-
* - Revoked access is recorded with a `revokedAt` timestamp
|
|
4787
|
+
* @param uri - AT-URI of the project
|
|
4788
|
+
* @returns Promise resolving to project data (as collection)
|
|
4264
4789
|
*
|
|
4265
4790
|
* @example
|
|
4266
4791
|
* ```typescript
|
|
4267
|
-
* await repo.
|
|
4268
|
-
*
|
|
4269
|
-
*
|
|
4792
|
+
* const { record } = await repo.hypercerts.getProject(projectUri);
|
|
4793
|
+
* console.log(`${record.title}: ${record.items?.length || 0} activities`);
|
|
4794
|
+
* ```
|
|
4795
|
+
*/
|
|
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
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
/**
|
|
4836
|
+
* Lists all projects with optional pagination.
|
|
4837
|
+
*
|
|
4838
|
+
* Projects are collections with `type='project'`. This method filters
|
|
4839
|
+
* collections to only return those with type='project'.
|
|
4840
|
+
*
|
|
4841
|
+
* @param params - Optional pagination parameters
|
|
4842
|
+
* @returns Promise resolving to paginated list of projects
|
|
4843
|
+
*
|
|
4844
|
+
* @example
|
|
4845
|
+
* ```typescript
|
|
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({
|
|
5234
|
+
* userDid: "did:plc:viewer-user",
|
|
5235
|
+
* role: "editor",
|
|
5236
|
+
* });
|
|
5237
|
+
* ```
|
|
5238
|
+
*/
|
|
5239
|
+
async grant(params) {
|
|
5240
|
+
const permissions = this.roleToPermissions(params.role);
|
|
5241
|
+
const response = await this.session.fetchHandler(`${this.serverUrl}/xrpc/com.sds.repo.grantAccess`, {
|
|
5242
|
+
method: "POST",
|
|
5243
|
+
headers: { "Content-Type": "application/json" },
|
|
5244
|
+
body: JSON.stringify({
|
|
5245
|
+
repo: this.repoDid,
|
|
5246
|
+
userDid: params.userDid,
|
|
5247
|
+
permissions,
|
|
5248
|
+
}),
|
|
5249
|
+
});
|
|
5250
|
+
if (!response.ok) {
|
|
5251
|
+
throw new NetworkError(`Failed to grant access: ${response.statusText}`);
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
/**
|
|
5255
|
+
* Revokes repository access from a user.
|
|
5256
|
+
*
|
|
5257
|
+
* @param params - Revoke parameters
|
|
5258
|
+
* @param params.userDid - DID of the user to revoke access from
|
|
5259
|
+
* @throws {@link NetworkError} if the revoke operation fails
|
|
5260
|
+
*
|
|
5261
|
+
* @remarks
|
|
5262
|
+
* - Cannot revoke access from the repository owner
|
|
5263
|
+
* - Revoked access is recorded with a `revokedAt` timestamp
|
|
5264
|
+
*
|
|
5265
|
+
* @example
|
|
5266
|
+
* ```typescript
|
|
5267
|
+
* await repo.collaborators.revoke({
|
|
5268
|
+
* userDid: "did:plc:former-collaborator",
|
|
5269
|
+
* });
|
|
4270
5270
|
* ```
|
|
4271
5271
|
*/
|
|
4272
5272
|
async revoke(params) {
|
|
@@ -4833,6 +5833,7 @@ class Repository {
|
|
|
4833
5833
|
* @param repoDid - DID of the repository to operate on
|
|
4834
5834
|
* @param isSDS - Whether this is a Shared Data Server
|
|
4835
5835
|
* @param logger - Optional logger for debugging
|
|
5836
|
+
* @param lexiconRegistry - Registry for custom lexicon management
|
|
4836
5837
|
*
|
|
4837
5838
|
* @remarks
|
|
4838
5839
|
* This constructor is typically not called directly. Use
|
|
@@ -4840,12 +5841,13 @@ class Repository {
|
|
|
4840
5841
|
*
|
|
4841
5842
|
* @internal
|
|
4842
5843
|
*/
|
|
4843
|
-
constructor(session, serverUrl, repoDid, isSDS, logger) {
|
|
5844
|
+
constructor(session, serverUrl, repoDid, isSDS, logger, lexiconRegistry) {
|
|
4844
5845
|
this.session = session;
|
|
4845
5846
|
this.serverUrl = serverUrl;
|
|
4846
5847
|
this.repoDid = repoDid;
|
|
4847
5848
|
this._isSDS = isSDS;
|
|
4848
5849
|
this.logger = logger;
|
|
5850
|
+
this.lexiconRegistry = lexiconRegistry || new LexiconRegistry();
|
|
4849
5851
|
// Create a ConfigurableAgent that routes requests to the specified server URL
|
|
4850
5852
|
// This allows routing to PDS, SDS, or any custom server while maintaining
|
|
4851
5853
|
// the OAuth session's authentication
|
|
@@ -4920,7 +5922,53 @@ class Repository {
|
|
|
4920
5922
|
* ```
|
|
4921
5923
|
*/
|
|
4922
5924
|
repo(did) {
|
|
4923
|
-
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;
|
|
4924
5972
|
}
|
|
4925
5973
|
/**
|
|
4926
5974
|
* Low-level record operations for CRUD on any AT Protocol record type.
|
|
@@ -4953,7 +6001,7 @@ class Repository {
|
|
|
4953
6001
|
*/
|
|
4954
6002
|
get records() {
|
|
4955
6003
|
if (!this._records) {
|
|
4956
|
-
this._records = new RecordOperationsImpl(this.agent, this.repoDid);
|
|
6004
|
+
this._records = new RecordOperationsImpl(this.agent, this.repoDid, this.lexiconRegistry);
|
|
4957
6005
|
}
|
|
4958
6006
|
return this._records;
|
|
4959
6007
|
}
|
|
@@ -5130,26 +6178,67 @@ class Repository {
|
|
|
5130
6178
|
}
|
|
5131
6179
|
}
|
|
5132
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
|
+
});
|
|
5133
6216
|
/**
|
|
5134
6217
|
* Zod schema for OAuth configuration validation.
|
|
5135
6218
|
*
|
|
5136
6219
|
* @remarks
|
|
5137
|
-
* All URLs must be valid and use HTTPS in production.
|
|
5138
|
-
*
|
|
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.
|
|
5139
6223
|
*/
|
|
5140
6224
|
const OAuthConfigSchema = zod.z.object({
|
|
5141
6225
|
/**
|
|
5142
6226
|
* URL to the OAuth client metadata JSON document.
|
|
5143
6227
|
* This document describes your application to the authorization server.
|
|
5144
6228
|
*
|
|
6229
|
+
* For local development, you can use `http://localhost/` as a loopback client.
|
|
6230
|
+
*
|
|
5145
6231
|
* @see https://atproto.com/specs/oauth#client-metadata
|
|
5146
6232
|
*/
|
|
5147
|
-
clientId:
|
|
6233
|
+
clientId: urlOrLoopback,
|
|
5148
6234
|
/**
|
|
5149
6235
|
* URL where users are redirected after authentication.
|
|
5150
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`.
|
|
5151
6240
|
*/
|
|
5152
|
-
redirectUri:
|
|
6241
|
+
redirectUri: urlOrLoopback,
|
|
5153
6242
|
/**
|
|
5154
6243
|
* OAuth scopes to request, space-separated.
|
|
5155
6244
|
*
|
|
@@ -5183,8 +6272,11 @@ const OAuthConfigSchema = zod.z.object({
|
|
|
5183
6272
|
/**
|
|
5184
6273
|
* URL to your public JWKS (JSON Web Key Set) endpoint.
|
|
5185
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`.
|
|
5186
6278
|
*/
|
|
5187
|
-
jwksUri:
|
|
6279
|
+
jwksUri: urlOrLoopback,
|
|
5188
6280
|
/**
|
|
5189
6281
|
* Private JWK (JSON Web Key) as a JSON string.
|
|
5190
6282
|
* Used for signing DPoP proofs and client assertions.
|
|
@@ -5194,28 +6286,64 @@ const OAuthConfigSchema = zod.z.object({
|
|
|
5194
6286
|
* Typically loaded from environment variables or a secrets manager.
|
|
5195
6287
|
*/
|
|
5196
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(),
|
|
5197
6308
|
});
|
|
5198
6309
|
/**
|
|
5199
6310
|
* Zod schema for server URL configuration.
|
|
5200
6311
|
*
|
|
5201
6312
|
* @remarks
|
|
5202
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.
|
|
5203
6315
|
*/
|
|
5204
6316
|
const ServerConfigSchema = zod.z.object({
|
|
5205
6317
|
/**
|
|
5206
6318
|
* Personal Data Server URL - the user's own AT Protocol server.
|
|
5207
6319
|
* This is the primary server for user data operations.
|
|
5208
6320
|
*
|
|
5209
|
-
* @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
|
+
* ```
|
|
5210
6330
|
*/
|
|
5211
|
-
pds:
|
|
6331
|
+
pds: urlOrLoopback.optional(),
|
|
5212
6332
|
/**
|
|
5213
6333
|
* Shared Data Server URL - for collaborative data storage.
|
|
5214
6334
|
* Required for collaborator and organization operations.
|
|
5215
6335
|
*
|
|
5216
|
-
* @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
|
+
* ```
|
|
5217
6345
|
*/
|
|
5218
|
-
sds:
|
|
6346
|
+
sds: urlOrLoopback.optional(),
|
|
5219
6347
|
});
|
|
5220
6348
|
/**
|
|
5221
6349
|
* Zod schema for timeout configuration.
|
|
@@ -5342,6 +6470,10 @@ class ATProtoSDK {
|
|
|
5342
6470
|
};
|
|
5343
6471
|
this.config = configWithDefaults;
|
|
5344
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);
|
|
5345
6477
|
// Initialize OAuth client
|
|
5346
6478
|
this.oauthClient = new OAuthClient(configWithDefaults);
|
|
5347
6479
|
this.logger?.info("ATProto SDK initialized");
|
|
@@ -5625,45 +6757,1160 @@ class ATProtoSDK {
|
|
|
5625
6757
|
}
|
|
5626
6758
|
// Get repository DID (default to session DID)
|
|
5627
6759
|
const repoDid = session.did || session.sub;
|
|
5628
|
-
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;
|
|
5629
7281
|
}
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
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");
|
|
5637
7310
|
}
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
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;
|
|
5645
7357
|
}
|
|
7358
|
+
const obj = ref;
|
|
7359
|
+
return typeof obj.uri === "string" && obj.uri.length > 0 && typeof obj.cid === "string" && obj.cid.length > 0;
|
|
5646
7360
|
}
|
|
5647
7361
|
/**
|
|
5648
|
-
*
|
|
7362
|
+
* Type guard to check if a value is a strongRef.
|
|
5649
7363
|
*
|
|
5650
|
-
* This is
|
|
7364
|
+
* This is an alias for `validateStrongRef` that provides better semantics
|
|
7365
|
+
* for type narrowing in TypeScript.
|
|
5651
7366
|
*
|
|
5652
|
-
* @param
|
|
5653
|
-
* @returns
|
|
7367
|
+
* @param value - The value to check
|
|
7368
|
+
* @returns True if the value is a strongRef, false otherwise
|
|
5654
7369
|
*
|
|
5655
7370
|
* @example
|
|
5656
7371
|
* ```typescript
|
|
5657
|
-
*
|
|
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
|
|
5658
7386
|
*
|
|
5659
|
-
*
|
|
5660
|
-
*
|
|
5661
|
-
*
|
|
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,
|
|
5662
7405
|
* });
|
|
5663
7406
|
* ```
|
|
5664
7407
|
*/
|
|
5665
|
-
function
|
|
5666
|
-
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;
|
|
5667
7914
|
}
|
|
5668
7915
|
|
|
5669
7916
|
/**
|
|
@@ -5825,9 +8072,13 @@ Object.defineProperty(exports, "OrgHypercertsClaimCollection", {
|
|
|
5825
8072
|
enumerable: true,
|
|
5826
8073
|
get: function () { return lexicon.OrgHypercertsClaimCollection; }
|
|
5827
8074
|
});
|
|
5828
|
-
Object.defineProperty(exports, "
|
|
8075
|
+
Object.defineProperty(exports, "OrgHypercertsClaimContributionDetails", {
|
|
8076
|
+
enumerable: true,
|
|
8077
|
+
get: function () { return lexicon.OrgHypercertsClaimContributionDetails; }
|
|
8078
|
+
});
|
|
8079
|
+
Object.defineProperty(exports, "OrgHypercertsClaimContributorInformation", {
|
|
5829
8080
|
enumerable: true,
|
|
5830
|
-
get: function () { return lexicon.
|
|
8081
|
+
get: function () { return lexicon.OrgHypercertsClaimContributorInformation; }
|
|
5831
8082
|
});
|
|
5832
8083
|
Object.defineProperty(exports, "OrgHypercertsClaimEvaluation", {
|
|
5833
8084
|
enumerable: true,
|
|
@@ -5841,10 +8092,6 @@ Object.defineProperty(exports, "OrgHypercertsClaimMeasurement", {
|
|
|
5841
8092
|
enumerable: true,
|
|
5842
8093
|
get: function () { return lexicon.OrgHypercertsClaimMeasurement; }
|
|
5843
8094
|
});
|
|
5844
|
-
Object.defineProperty(exports, "OrgHypercertsClaimProject", {
|
|
5845
|
-
enumerable: true,
|
|
5846
|
-
get: function () { return lexicon.OrgHypercertsClaimProject; }
|
|
5847
|
-
});
|
|
5848
8095
|
Object.defineProperty(exports, "OrgHypercertsClaimRights", {
|
|
5849
8096
|
enumerable: true,
|
|
5850
8097
|
get: function () { return lexicon.OrgHypercertsClaimRights; }
|
|
@@ -5853,6 +8100,10 @@ Object.defineProperty(exports, "OrgHypercertsFundingReceipt", {
|
|
|
5853
8100
|
enumerable: true,
|
|
5854
8101
|
get: function () { return lexicon.OrgHypercertsFundingReceipt; }
|
|
5855
8102
|
});
|
|
8103
|
+
Object.defineProperty(exports, "OrgHypercertsHelperWorkScopeTag", {
|
|
8104
|
+
enumerable: true,
|
|
8105
|
+
get: function () { return lexicon.OrgHypercertsHelperWorkScopeTag; }
|
|
8106
|
+
});
|
|
5856
8107
|
Object.defineProperty(exports, "validate", {
|
|
5857
8108
|
enumerable: true,
|
|
5858
8109
|
get: function () { return lexicon.validate; }
|
|
@@ -5865,6 +8116,7 @@ exports.AccountActionSchema = AccountActionSchema;
|
|
|
5865
8116
|
exports.AccountAttrSchema = AccountAttrSchema;
|
|
5866
8117
|
exports.AccountPermissionSchema = AccountPermissionSchema;
|
|
5867
8118
|
exports.AuthenticationError = AuthenticationError;
|
|
8119
|
+
exports.BaseOperations = BaseOperations;
|
|
5868
8120
|
exports.BlobPermissionSchema = BlobPermissionSchema;
|
|
5869
8121
|
exports.CollaboratorPermissionsSchema = CollaboratorPermissionsSchema;
|
|
5870
8122
|
exports.CollaboratorSchema = CollaboratorSchema;
|
|
@@ -5876,6 +8128,7 @@ exports.IdentityPermissionSchema = IdentityPermissionSchema;
|
|
|
5876
8128
|
exports.InMemorySessionStore = InMemorySessionStore;
|
|
5877
8129
|
exports.InMemoryStateStore = InMemoryStateStore;
|
|
5878
8130
|
exports.IncludePermissionSchema = IncludePermissionSchema;
|
|
8131
|
+
exports.LexiconRegistry = LexiconRegistry;
|
|
5879
8132
|
exports.MimeTypeSchema = MimeTypeSchema;
|
|
5880
8133
|
exports.NetworkError = NetworkError;
|
|
5881
8134
|
exports.NsidSchema = NsidSchema;
|
|
@@ -5895,13 +8148,37 @@ exports.TRANSITION_SCOPES = TRANSITION_SCOPES;
|
|
|
5895
8148
|
exports.TimeoutConfigSchema = TimeoutConfigSchema;
|
|
5896
8149
|
exports.TransitionScopeSchema = TransitionScopeSchema;
|
|
5897
8150
|
exports.ValidationError = ValidationError;
|
|
8151
|
+
exports.attachSidecar = attachSidecar;
|
|
8152
|
+
exports.batchCreateSidecars = batchCreateSidecars;
|
|
8153
|
+
exports.buildAtUri = buildAtUri;
|
|
5898
8154
|
exports.buildScope = buildScope;
|
|
5899
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;
|
|
5900
8172
|
exports.hasAllPermissions = hasAllPermissions;
|
|
5901
8173
|
exports.hasAnyPermission = hasAnyPermission;
|
|
5902
8174
|
exports.hasPermission = hasPermission;
|
|
8175
|
+
exports.isStrongRef = isStrongRef;
|
|
8176
|
+
exports.isValidAtUri = isValidAtUri;
|
|
5903
8177
|
exports.mergeScopes = mergeScopes;
|
|
8178
|
+
exports.parseAtUri = parseAtUri;
|
|
5904
8179
|
exports.parseScope = parseScope;
|
|
5905
8180
|
exports.removePermissions = removePermissions;
|
|
8181
|
+
exports.validateLexiconStructure = validateLexiconStructure;
|
|
5906
8182
|
exports.validateScope = validateScope;
|
|
8183
|
+
exports.validateStrongRef = validateStrongRef;
|
|
5907
8184
|
//# sourceMappingURL=index.cjs.map
|