@atproto/dev-env 0.0.0-spaces-alpha-20260818163953

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +3424 -0
  2. package/LICENSE.txt +7 -0
  3. package/README.md +39 -0
  4. package/bin.js +7 -0
  5. package/dist/bin-multi-pds.d.ts +2 -0
  6. package/dist/bin-multi-pds.d.ts.map +1 -0
  7. package/dist/bin-multi-pds.js +130 -0
  8. package/dist/bin-multi-pds.js.map +1 -0
  9. package/dist/bin.d.ts +2 -0
  10. package/dist/bin.d.ts.map +1 -0
  11. package/dist/bin.js +56 -0
  12. package/dist/bin.js.map +1 -0
  13. package/dist/bsky.d.ts +27 -0
  14. package/dist/bsky.d.ts.map +1 -0
  15. package/dist/bsky.js +165 -0
  16. package/dist/bsky.js.map +1 -0
  17. package/dist/bsync.d.ts +22 -0
  18. package/dist/bsync.d.ts.map +1 -0
  19. package/dist/bsync.js +54 -0
  20. package/dist/bsync.js.map +1 -0
  21. package/dist/const.d.ts +4 -0
  22. package/dist/const.d.ts.map +1 -0
  23. package/dist/const.js +4 -0
  24. package/dist/const.js.map +1 -0
  25. package/dist/env.d.ts +2 -0
  26. package/dist/env.d.ts.map +1 -0
  27. package/dist/env.js +10 -0
  28. package/dist/env.js.map +1 -0
  29. package/dist/feed-gen.d.ts +15 -0
  30. package/dist/feed-gen.d.ts.map +1 -0
  31. package/dist/feed-gen.js +73 -0
  32. package/dist/feed-gen.js.map +1 -0
  33. package/dist/index.d.ts +17 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +17 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/introspect.d.ts +22 -0
  38. package/dist/introspect.d.ts.map +1 -0
  39. package/dist/introspect.js +65 -0
  40. package/dist/introspect.js.map +1 -0
  41. package/dist/mock/data.d.ts +3 -0
  42. package/dist/mock/data.d.ts.map +1 -0
  43. package/dist/mock/data.js +46 -0
  44. package/dist/mock/data.js.map +1 -0
  45. package/dist/mock/img/blur-hash-avatar-b64.d.ts +3 -0
  46. package/dist/mock/img/blur-hash-avatar-b64.d.ts.map +1 -0
  47. package/dist/mock/img/blur-hash-avatar-b64.js +2 -0
  48. package/dist/mock/img/blur-hash-avatar-b64.js.map +1 -0
  49. package/dist/mock/img/labeled-img-b64.d.ts +3 -0
  50. package/dist/mock/img/labeled-img-b64.d.ts.map +1 -0
  51. package/dist/mock/img/labeled-img-b64.js +2 -0
  52. package/dist/mock/img/labeled-img-b64.js.map +1 -0
  53. package/dist/mock/index.d.ts +3 -0
  54. package/dist/mock/index.d.ts.map +1 -0
  55. package/dist/mock/index.js +574 -0
  56. package/dist/mock/index.js.map +1 -0
  57. package/dist/moderator-client.d.ts +58 -0
  58. package/dist/moderator-client.d.ts.map +1 -0
  59. package/dist/moderator-client.js +110 -0
  60. package/dist/moderator-client.js.map +1 -0
  61. package/dist/network-no-appview.d.ts +21 -0
  62. package/dist/network-no-appview.d.ts.map +1 -0
  63. package/dist/network-no-appview.js +74 -0
  64. package/dist/network-no-appview.js.map +1 -0
  65. package/dist/network.d.ts +32 -0
  66. package/dist/network.d.ts.map +1 -0
  67. package/dist/network.js +190 -0
  68. package/dist/network.js.map +1 -0
  69. package/dist/ozone.d.ts +31 -0
  70. package/dist/ozone.d.ts.map +1 -0
  71. package/dist/ozone.js +254 -0
  72. package/dist/ozone.js.map +1 -0
  73. package/dist/pds.d.ts +23 -0
  74. package/dist/pds.d.ts.map +1 -0
  75. package/dist/pds.js +97 -0
  76. package/dist/pds.js.map +1 -0
  77. package/dist/plc.d.ts +14 -0
  78. package/dist/plc.d.ts.map +1 -0
  79. package/dist/plc.js +28 -0
  80. package/dist/plc.js.map +1 -0
  81. package/dist/seed/author-feed.d.ts +4 -0
  82. package/dist/seed/author-feed.d.ts.map +1 -0
  83. package/dist/seed/author-feed.js +50 -0
  84. package/dist/seed/author-feed.js.map +1 -0
  85. package/dist/seed/basic.d.ts +16 -0
  86. package/dist/seed/basic.d.ts.map +1 -0
  87. package/dist/seed/basic.js +113 -0
  88. package/dist/seed/basic.js.map +1 -0
  89. package/dist/seed/client.d.ts +151 -0
  90. package/dist/seed/client.d.ts.map +1 -0
  91. package/dist/seed/client.js +365 -0
  92. package/dist/seed/client.js.map +1 -0
  93. package/dist/seed/follows.d.ts +4 -0
  94. package/dist/seed/follows.d.ts.map +1 -0
  95. package/dist/seed/follows.js +55 -0
  96. package/dist/seed/follows.js.map +1 -0
  97. package/dist/seed/index.d.ts +11 -0
  98. package/dist/seed/index.d.ts.map +1 -0
  99. package/dist/seed/index.js +11 -0
  100. package/dist/seed/index.js.map +1 -0
  101. package/dist/seed/likes.d.ts +4 -0
  102. package/dist/seed/likes.d.ts.map +1 -0
  103. package/dist/seed/likes.js +37 -0
  104. package/dist/seed/likes.js.map +1 -0
  105. package/dist/seed/quotes.d.ts +4 -0
  106. package/dist/seed/quotes.d.ts.map +1 -0
  107. package/dist/seed/quotes.js +21 -0
  108. package/dist/seed/quotes.js.map +1 -0
  109. package/dist/seed/reposts.d.ts +4 -0
  110. package/dist/seed/reposts.d.ts.map +1 -0
  111. package/dist/seed/reposts.js +17 -0
  112. package/dist/seed/reposts.js.map +1 -0
  113. package/dist/seed/thread-v2.d.ts +227 -0
  114. package/dist/seed/thread-v2.d.ts.map +1 -0
  115. package/dist/seed/thread-v2.js +755 -0
  116. package/dist/seed/thread-v2.js.map +1 -0
  117. package/dist/seed/users-bulk.d.ts +3 -0
  118. package/dist/seed/users-bulk.d.ts.map +1 -0
  119. package/dist/seed/users-bulk.js +224 -0
  120. package/dist/seed/users-bulk.js.map +1 -0
  121. package/dist/seed/users.d.ts +3 -0
  122. package/dist/seed/users.d.ts.map +1 -0
  123. package/dist/seed/users.js +45 -0
  124. package/dist/seed/users.js.map +1 -0
  125. package/dist/seed/verifications.d.ts +5 -0
  126. package/dist/seed/verifications.d.ts.map +1 -0
  127. package/dist/seed/verifications.js +164 -0
  128. package/dist/seed/verifications.js.map +1 -0
  129. package/dist/service-profile-lexicon.d.ts +11 -0
  130. package/dist/service-profile-lexicon.d.ts.map +1 -0
  131. package/dist/service-profile-lexicon.js +115 -0
  132. package/dist/service-profile-lexicon.js.map +1 -0
  133. package/dist/service-profile-ozone.d.ts +18 -0
  134. package/dist/service-profile-ozone.d.ts.map +1 -0
  135. package/dist/service-profile-ozone.js +333 -0
  136. package/dist/service-profile-ozone.js.map +1 -0
  137. package/dist/service-profile.d.ts +24 -0
  138. package/dist/service-profile.d.ts.map +1 -0
  139. package/dist/service-profile.js +61 -0
  140. package/dist/service-profile.js.map +1 -0
  141. package/dist/types.d.ts +61 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +2 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/util.d.ts +14 -0
  146. package/dist/util.d.ts.map +1 -0
  147. package/dist/util.js +86 -0
  148. package/dist/util.js.map +1 -0
  149. package/package.json +69 -0
  150. package/src/bin-multi-pds.ts +166 -0
  151. package/src/bin.ts +64 -0
  152. package/src/bsky.ts +198 -0
  153. package/src/bsync.ts +66 -0
  154. package/src/const.ts +3 -0
  155. package/src/env.ts +9 -0
  156. package/src/feed-gen.ts +96 -0
  157. package/src/index.ts +17 -0
  158. package/src/introspect.ts +91 -0
  159. package/src/mock/data.ts +46 -0
  160. package/src/mock/img/blur-hash-avatar-b64.ts +1 -0
  161. package/src/mock/img/labeled-img-b64.ts +1 -0
  162. package/src/mock/index.ts +702 -0
  163. package/src/moderator-client.ts +267 -0
  164. package/src/network-no-appview.ts +89 -0
  165. package/src/network.ts +243 -0
  166. package/src/ozone.ts +287 -0
  167. package/src/pds.ts +118 -0
  168. package/src/plc.ts +33 -0
  169. package/src/seed/author-feed.ts +98 -0
  170. package/src/seed/basic.ts +189 -0
  171. package/src/seed/client.ts +676 -0
  172. package/src/seed/follows.ts +57 -0
  173. package/src/seed/index.ts +11 -0
  174. package/src/seed/likes.ts +43 -0
  175. package/src/seed/quotes.ts +53 -0
  176. package/src/seed/reposts.ts +18 -0
  177. package/src/seed/thread-v2.ts +925 -0
  178. package/src/seed/users-bulk.ts +229 -0
  179. package/src/seed/users.ts +64 -0
  180. package/src/seed/verifications.ts +254 -0
  181. package/src/service-profile-lexicon.ts +127 -0
  182. package/src/service-profile-ozone.ts +375 -0
  183. package/src/service-profile.ts +95 -0
  184. package/src/types.ts +68 -0
  185. package/src/util.ts +118 -0
  186. package/tsconfig.build.json +25 -0
  187. package/tsconfig.json +4 -0
package/dist/util.js ADDED
@@ -0,0 +1,86 @@
1
+ import * as plc from '@did-plc/lib';
2
+ import { request } from 'undici';
3
+ import { Secp256k1Keypair } from '@atproto/crypto';
4
+ export const mockNetworkUtilities = (pds, bsky) => {
5
+ const pdses = Array.isArray(pds) ? pds : [pds];
6
+ for (const p of pdses) {
7
+ mockResolvers(p.ctx.idResolver, pdses);
8
+ }
9
+ if (bsky) {
10
+ mockResolvers(bsky.ctx.idResolver, pdses);
11
+ mockResolvers(bsky.dataplane.idResolver, pdses);
12
+ }
13
+ };
14
+ export const mockResolvers = (idResolver, pds,
15
+ // handles resolve against the matching pds by default, but may point
16
+ // elsewhere e.g. an entryway
17
+ handleResolveUrl) => {
18
+ const pdses = Array.isArray(pds) ? pds : [pds];
19
+ // Map pds public url to its local url when resolving from plc
20
+ const origResolveDid = idResolver.did.resolveNoCache;
21
+ idResolver.did.resolveNoCache = async (did) => {
22
+ const result = await origResolveDid.call(idResolver.did, did);
23
+ const service = result?.service?.find((svc) => svc.id === '#atproto_pds');
24
+ if (typeof service?.serviceEndpoint === 'string') {
25
+ for (const p of pdses) {
26
+ service.serviceEndpoint = service.serviceEndpoint.replace(p.ctx.cfg.service.publicUrl, `http://localhost:${p.port}`);
27
+ }
28
+ }
29
+ return result;
30
+ };
31
+ const origResolveHandleDns = idResolver.handle.resolveDns;
32
+ idResolver.handle.resolve = async (handle) => {
33
+ // Handle domains across PDSes are disjoint suffixes (.test, .test2, ...);
34
+ // first match wins.
35
+ const match = pdses.find((p) => p.ctx.cfg.identity.serviceHandleDomains.some((d) => handle.endsWith(d)));
36
+ if (!match) {
37
+ return origResolveHandleDns.call(idResolver.handle, handle);
38
+ }
39
+ const url = new URL(`/.well-known/atproto-did`, handleResolveUrl ?? match.url);
40
+ try {
41
+ const res = await request(url, { headers: { host: handle } });
42
+ if (res.statusCode !== 200) {
43
+ await res.body.dump();
44
+ return undefined;
45
+ }
46
+ return res.body.text();
47
+ }
48
+ catch (err) {
49
+ return undefined;
50
+ }
51
+ };
52
+ };
53
+ export const mockMailer = (pds) => {
54
+ const mailer = pds.ctx.mailer;
55
+ const _origSendMail = mailer.transporter.sendMail;
56
+ mailer.transporter.sendMail = async (opts) => {
57
+ const result = await _origSendMail.call(mailer.transporter, opts);
58
+ console.log(`✉️ Email: ${JSON.stringify(result, null, 2)}`);
59
+ return result;
60
+ };
61
+ };
62
+ const usedLockIds = new Set();
63
+ export const uniqueLockId = () => {
64
+ let lockId;
65
+ do {
66
+ lockId = 1000 + Math.ceil(1000 * Math.random());
67
+ } while (usedLockIds.has(lockId));
68
+ usedLockIds.add(lockId);
69
+ return lockId;
70
+ };
71
+ export const createDidAndKey = async (opts) => {
72
+ const { plcUrl, handle, pds } = opts;
73
+ const key = await Secp256k1Keypair.create({ exportable: true });
74
+ const did = await new plc.Client(plcUrl).createDid({
75
+ signingKey: key.did(),
76
+ rotationKeys: [key.did()],
77
+ handle,
78
+ pds,
79
+ signer: key,
80
+ });
81
+ return {
82
+ key,
83
+ did,
84
+ };
85
+ };
86
+ //# sourceMappingURL=util.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,cAAc,CAAA;AACnC,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAMlD,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAClC,GAAwB,EACxB,IAAe,EACf,EAAE;IACF,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IAC9C,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,CAAC,CAAA;IACxC,CAAC;IACD,IAAI,IAAI,EAAE,CAAC;QACT,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,CAAC,CAAA;QACzC,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC,UAAU,EAAE,KAAK,CAAC,CAAA;IACjD,CAAC;AACH,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,UAAsB,EACtB,GAAwB;AACxB,qEAAqE;AACrE,6BAA6B;AAC7B,gBAAyB,EACzB,EAAE;IACF,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IAE9C,8DAA8D;IAC9D,MAAM,cAAc,GAAG,UAAU,CAAC,GAAG,CAAC,cAAc,CAAA;IACpD,UAAU,CAAC,GAAG,CAAC,cAAc,GAAG,KAAK,EAAE,GAAW,EAAE,EAAE;QACpD,MAAM,MAAM,GAAG,MAAO,cAAc,CAAC,IAAI,CACvC,UAAU,CAAC,GAAG,EACd,GAAG,CACkC,CAAA;QACvC,MAAM,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,cAAc,CAAC,CAAA;QACzE,IAAI,OAAO,OAAO,EAAE,eAAe,KAAK,QAAQ,EAAE,CAAC;YACjD,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;gBACtB,OAAO,CAAC,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,OAAO,CACvD,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,EAC3B,oBAAoB,CAAC,CAAC,IAAI,EAAE,CAC7B,CAAA;YACH,CAAC;QACH,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC,CAAA;IAED,MAAM,oBAAoB,GAAG,UAAU,CAAC,MAAM,CAAC,UAAU,CAAA;IACzD,UAAU,CAAC,MAAM,CAAC,OAAO,GAAG,KAAK,EAAE,MAAc,EAAE,EAAE;QACnD,0EAA0E;QAC1E,oBAAoB;QACpB,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAC7B,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CACxE,CAAA;QACD,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,oBAAoB,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC7D,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,GAAG,CACjB,0BAA0B,EAC1B,gBAAgB,IAAI,KAAK,CAAC,GAAG,CAC9B,CAAA;QACD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;YAC7D,IAAI,GAAG,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;gBAC3B,MAAM,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAA;gBACrB,OAAO,SAAS,CAAA;YAClB,CAAC;YAED,OAAO,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAA;QACxB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,SAAS,CAAA;QAClB,CAAC;IACH,CAAC,CAAA;AACH,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,GAAY,EAAE,EAAE;IACzC,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAA;IAC7B,MAAM,aAAa,GAAG,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAA;IACjD,MAAM,CAAC,WAAW,CAAC,QAAQ,GAAG,KAAK,EAAE,IAAI,EAAE,EAAE;QAC3C,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,IAAI,CAAC,CAAA;QACjE,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;QAC3D,OAAO,MAAM,CAAA;IACf,CAAC,CAAA;AACH,CAAC,CAAA;AAED,MAAM,WAAW,GAAG,IAAI,GAAG,EAAE,CAAA;AAC7B,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,EAAE;IAC/B,IAAI,MAAc,CAAA;IAClB,GAAG,CAAC;QACF,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAA;IACjD,CAAC,QAAQ,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,EAAC;IACjC,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IACvB,OAAO,MAAM,CAAA;AACf,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,EAAE,IAIrC,EAAsB,EAAE;IACvB,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IACpC,MAAM,GAAG,GAAG,MAAM,gBAAgB,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/D,MAAM,GAAG,GAAG,MAAM,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC;QACjD,UAAU,EAAE,GAAG,CAAC,GAAG,EAAE;QACrB,YAAY,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC;QACzB,MAAM;QACN,GAAG;QACH,MAAM,EAAE,GAAG;KACZ,CAAC,CAAA;IACF,OAAO;QACL,GAAG;QACH,GAAG;KACJ,CAAA;AACH,CAAC,CAAA","sourcesContent":["import * as plc from '@did-plc/lib'\nimport { request } from 'undici'\nimport { Secp256k1Keypair } from '@atproto/crypto'\nimport type { IdResolver } from '@atproto/identity'\nimport type { TestBsky } from './bsky.js'\nimport type { TestPds } from './pds.js'\nimport type { DidAndKey } from './types.js'\n\nexport const mockNetworkUtilities = (\n pds: TestPds | TestPds[],\n bsky?: TestBsky,\n) => {\n const pdses = Array.isArray(pds) ? pds : [pds]\n for (const p of pdses) {\n mockResolvers(p.ctx.idResolver, pdses)\n }\n if (bsky) {\n mockResolvers(bsky.ctx.idResolver, pdses)\n mockResolvers(bsky.dataplane.idResolver, pdses)\n }\n}\n\nexport const mockResolvers = (\n idResolver: IdResolver,\n pds: TestPds | TestPds[],\n // handles resolve against the matching pds by default, but may point\n // elsewhere e.g. an entryway\n handleResolveUrl?: string,\n) => {\n const pdses = Array.isArray(pds) ? pds : [pds]\n\n // Map pds public url to its local url when resolving from plc\n const origResolveDid = idResolver.did.resolveNoCache\n idResolver.did.resolveNoCache = async (did: string) => {\n const result = await (origResolveDid.call(\n idResolver.did,\n did,\n ) as ReturnType<typeof origResolveDid>)\n const service = result?.service?.find((svc) => svc.id === '#atproto_pds')\n if (typeof service?.serviceEndpoint === 'string') {\n for (const p of pdses) {\n service.serviceEndpoint = service.serviceEndpoint.replace(\n p.ctx.cfg.service.publicUrl,\n `http://localhost:${p.port}`,\n )\n }\n }\n return result\n }\n\n const origResolveHandleDns = idResolver.handle.resolveDns\n idResolver.handle.resolve = async (handle: string) => {\n // Handle domains across PDSes are disjoint suffixes (.test, .test2, ...);\n // first match wins.\n const match = pdses.find((p) =>\n p.ctx.cfg.identity.serviceHandleDomains.some((d) => handle.endsWith(d)),\n )\n if (!match) {\n return origResolveHandleDns.call(idResolver.handle, handle)\n }\n\n const url = new URL(\n `/.well-known/atproto-did`,\n handleResolveUrl ?? match.url,\n )\n try {\n const res = await request(url, { headers: { host: handle } })\n if (res.statusCode !== 200) {\n await res.body.dump()\n return undefined\n }\n\n return res.body.text()\n } catch (err) {\n return undefined\n }\n }\n}\n\nexport const mockMailer = (pds: TestPds) => {\n const mailer = pds.ctx.mailer\n const _origSendMail = mailer.transporter.sendMail\n mailer.transporter.sendMail = async (opts) => {\n const result = await _origSendMail.call(mailer.transporter, opts)\n console.log(`✉️ Email: ${JSON.stringify(result, null, 2)}`)\n return result\n }\n}\n\nconst usedLockIds = new Set()\nexport const uniqueLockId = () => {\n let lockId: number\n do {\n lockId = 1000 + Math.ceil(1000 * Math.random())\n } while (usedLockIds.has(lockId))\n usedLockIds.add(lockId)\n return lockId\n}\n\nexport const createDidAndKey = async (opts: {\n plcUrl: string\n handle: string\n pds: string\n}): Promise<DidAndKey> => {\n const { plcUrl, handle, pds } = opts\n const key = await Secp256k1Keypair.create({ exportable: true })\n const did = await new plc.Client(plcUrl).createDid({\n signingKey: key.did(),\n rotationKeys: [key.did()],\n handle,\n pds,\n signer: key,\n })\n return {\n key,\n did,\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@atproto/dev-env",
3
+ "version": "0.0.0-spaces-alpha-20260818163953",
4
+ "license": "MIT",
5
+ "description": "Local development environment helper for atproto development",
6
+ "keywords": [
7
+ "atproto"
8
+ ],
9
+ "homepage": "https://atproto.com",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/bluesky-social/atproto",
13
+ "directory": "packages/dev-env"
14
+ },
15
+ "files": [
16
+ "./src",
17
+ "./tsconfig.json",
18
+ "./tsconfig.build.json",
19
+ "./dist",
20
+ "./README.md",
21
+ "./CHANGELOG.md"
22
+ ],
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "dependencies": {
34
+ "@did-plc/lib": "^0.0.1",
35
+ "@did-plc/server": "^0.0.1",
36
+ "dotenv": "^16.0.3",
37
+ "express": "^4.18.2",
38
+ "get-port": "^5.1.1",
39
+ "http-terminator": "^3.2.0",
40
+ "multiformats": "^13.0.0",
41
+ "uint8arrays": "^5.0.0",
42
+ "undici": "^8.5.0",
43
+ "@atproto/api": "^0.0.0-spaces-alpha-20260818163953",
44
+ "@atproto/bsky": "^0.0.0-spaces-alpha-20260818163953",
45
+ "@atproto/common-web": "^0.0.0-spaces-alpha-20260818163953",
46
+ "@atproto/identity": "^0.0.0-spaces-alpha-20260818163953",
47
+ "@atproto/bsync": "^0.0.0-spaces-alpha-20260818163953",
48
+ "@atproto/lex": "^0.0.0-spaces-alpha-20260818163953",
49
+ "@atproto/lexicon": "^0.0.0-spaces-alpha-20260818163953",
50
+ "@atproto/crypto": "^0.0.0-spaces-alpha-20260818163953",
51
+ "@atproto/ozone": "^0.0.0-spaces-alpha-20260818163953",
52
+ "@atproto/pds": "^0.0.0-spaces-alpha-20260818163953",
53
+ "@atproto/sync": "^0.0.0-spaces-alpha-20260818163953",
54
+ "@atproto/xrpc-server": "^0.0.0-spaces-alpha-20260818163953",
55
+ "@atproto/syntax": "^0.0.0-spaces-alpha-20260818163953"
56
+ },
57
+ "devDependencies": {
58
+ "@types/express": "^4.17.13"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc --build tsconfig.build.json",
62
+ "start": "../dev-infra/with-test-redis-and-db.sh node --enable-source-maps dist/bin.js",
63
+ "dev": "../dev-infra/with-test-redis-and-db.sh node --enable-source-maps --watch dist/bin.js",
64
+ "start:multi-pds": "node --enable-source-maps dist/bin-multi-pds.js"
65
+ },
66
+ "bin": {
67
+ "dev-env": "bin.js"
68
+ }
69
+ }
@@ -0,0 +1,166 @@
1
+ import './env.js'
2
+ import getPort from 'get-port'
3
+ import { IntrospectServer } from './introspect.js'
4
+ import { TestPds } from './pds.js'
5
+ import { TestPlc } from './plc.js'
6
+ import { LexiconAuthorityProfile } from './service-profile-lexicon.js'
7
+ import { mockMailer, mockNetworkUtilities } from './util.js'
8
+
9
+ const INTROSPECT_PORT = 2581
10
+ const PRIMARY_PDS_PORT = 2583
11
+ const PLC_PORT = 2582
12
+
13
+ // The lex-authority account hosts permission-set / space lexicon docs that
14
+ // dev-env PDSes resolve via `lexiconDidAuthority`. Demo apps can log into this
15
+ // account to publish new lexicons (e.g. `space`-typed documents) at runtime.
16
+ const LEX_AUTHORITY_USER = {
17
+ email: 'lex-authority@test.com',
18
+ handle: 'lex-authority.test',
19
+ password: 'hunter2',
20
+ }
21
+
22
+ const parsePdsCount = (): number => {
23
+ const arg = process.argv[2]
24
+ if (arg === undefined) return 3
25
+ const n = Number.parseInt(arg, 10)
26
+ if (!Number.isFinite(n) || n < 1) {
27
+ throw new Error(`Invalid PDS count "${arg}": must be an integer >= 1`)
28
+ }
29
+ return n
30
+ }
31
+
32
+ const run = async () => {
33
+ const totalPdsCount = parsePdsCount()
34
+ const extraPdsCount = totalPdsCount - 1
35
+
36
+ console.log(`
37
+ ██████╗
38
+ ██╔═══██╗
39
+ ██║██╗██║
40
+ ██║██║██║
41
+ ╚█║████╔╝
42
+ ╚╝╚═══╝ protocol
43
+
44
+ [ created by Bluesky ]`)
45
+
46
+ const plc = await TestPlc.create({ port: PLC_PORT })
47
+
48
+ // Step 1 — scratch PDS. We need an existing PDS to host the lex-authority
49
+ // account creation, but we don't want the scratch to live past startup. The
50
+ // account migrates to the primary PDS once we know its DID.
51
+ const scratchPds = await TestPds.create({
52
+ didPlcUrl: plc.url,
53
+ inviteRequired: false,
54
+ port: await getPort(),
55
+ })
56
+
57
+ // Step 2 — create the lex-authority account on the scratch PDS so we can
58
+ // capture its DID before any "real" PDS spins up with `lexiconDidAuthority`
59
+ // pointing at it.
60
+ const lexAuthority = await LexiconAuthorityProfile.create(
61
+ scratchPds,
62
+ LEX_AUTHORITY_USER,
63
+ )
64
+ const lexAuthorityDid = lexAuthority.did
65
+
66
+ // Step 3 — primary PDS, configured to resolve lexicons from the authority.
67
+ const primary = await TestPds.create({
68
+ port: PRIMARY_PDS_PORT,
69
+ hostname: 'localhost',
70
+ enableDidDocWithSession: true,
71
+ didPlcUrl: plc.url,
72
+ lexiconDidAuthority: lexAuthorityDid,
73
+ })
74
+
75
+ // Step 4 — migrate the authority account to the primary PDS, then publish
76
+ // the seed lexicon docs (permission sets, etc.) at the new home.
77
+ await lexAuthority.migrateTo(primary)
78
+ await lexAuthority.createRecords()
79
+
80
+ // Step 5 — extra PDSes, each pointing at the same lex authority.
81
+ const extraPdses: TestPds[] = []
82
+ for (let i = 0; i < extraPdsCount; i++) {
83
+ const domain = `.test${i + 2}`
84
+ const extra = await TestPds.create({
85
+ didPlcUrl: plc.url,
86
+ port: await getPort(),
87
+ serviceHandleDomains: [domain],
88
+ lexiconDidAuthority: lexAuthorityDid,
89
+ })
90
+ extraPdses.push(extra)
91
+ }
92
+
93
+ // Step 6 — close the scratch PDS now that the authority lives on primary.
94
+ await scratchPds.close()
95
+
96
+ // Mock network utilities (handle/DID resolution) across the surviving PDSes.
97
+ const allPdsesArray = [primary, ...extraPdses]
98
+ mockNetworkUtilities(allPdsesArray)
99
+ for (const pds of allPdsesArray) {
100
+ mockMailer(pds)
101
+ }
102
+
103
+ const introspect = await IntrospectServer.start(
104
+ INTROSPECT_PORT,
105
+ plc,
106
+ allPdsesArray,
107
+ undefined,
108
+ undefined,
109
+ undefined,
110
+ {
111
+ did: lexAuthorityDid,
112
+ handle: LEX_AUTHORITY_USER.handle,
113
+ password: LEX_AUTHORITY_USER.password,
114
+ pds: primary.url,
115
+ },
116
+ )
117
+
118
+ console.log(`🔍 Introspection server http://localhost:${introspect.port}`)
119
+ console.log(`👤 DID Placeholder server ${plc.url}`)
120
+
121
+ const allPdses: { label: string; pds: TestPds; domain: string }[] = [
122
+ { label: 'pds1 (primary)', pds: primary, domain: 'test' },
123
+ ...extraPdses.map((pds, i) => ({
124
+ label: `pds${i + 2}`,
125
+ pds,
126
+ domain: `test${i + 2}`,
127
+ })),
128
+ ]
129
+
130
+ for (const { label, pds } of allPdses) {
131
+ console.log(`🌞 ${label} http://localhost:${pds.port}`)
132
+ }
133
+
134
+ console.log(
135
+ `\n📚 Lex authority ${LEX_AUTHORITY_USER.handle} (${lexAuthorityDid})`,
136
+ )
137
+ console.log(
138
+ ` hosted on pds1 — log in with password "${LEX_AUTHORITY_USER.password}"`,
139
+ )
140
+
141
+ // Seed alice/bob/carol on each PDS so each has data that's reachable cross-PDS.
142
+ const seedNames = ['alice', 'bob', 'carol']
143
+ const seeded: { handle: string; did: string; pds: string }[] = []
144
+ for (const { label, pds, domain } of allPdses) {
145
+ const agent = pds.getAgent()
146
+ for (const name of seedNames) {
147
+ const handle = `${name}.${domain}`
148
+ const res = await agent.com.atproto.server.createAccount({
149
+ handle,
150
+ email: `${name}-${domain}@test.com`,
151
+ password: `${name}-pass`,
152
+ })
153
+ seeded.push({ handle, did: res.data.did, pds: label })
154
+ }
155
+ }
156
+
157
+ console.log('\n👥 Seeded accounts:')
158
+ for (const acct of seeded) {
159
+ console.log(
160
+ ` ${acct.pds.padEnd(16)} handle=${acct.handle.padEnd(20)} did=${acct.did}`,
161
+ )
162
+ }
163
+ console.log(' (passwords: alice-pass / bob-pass / carol-pass)')
164
+ }
165
+
166
+ run()
package/src/bin.ts ADDED
@@ -0,0 +1,64 @@
1
+ import './env.js'
2
+ import { generateMockSetup } from './mock/index.js'
3
+ import { TestNetwork } from './network.js'
4
+ import { mockMailer } from './util.js'
5
+
6
+ const run = async () => {
7
+ console.log(`
8
+ ██████╗
9
+ ██╔═══██╗
10
+ ██║██╗██║
11
+ ██║██║██║
12
+ ╚█║████╔╝
13
+ ╚╝╚═══╝ protocol
14
+
15
+ [ created by Bluesky ]`)
16
+
17
+ const network = await TestNetwork.create({
18
+ pds: {
19
+ port: 2583,
20
+ hostname: 'localhost',
21
+ enableDidDocWithSession: true,
22
+ },
23
+ bsky: {
24
+ dbPostgresSchema: 'bsky',
25
+ port: 2584,
26
+ publicUrl: 'http://localhost:2584',
27
+ },
28
+ plc: { port: 2582 },
29
+ ozone: {
30
+ port: 2587,
31
+ chatUrl: 'http://localhost:2590', // must run separate chat service
32
+ chatDid: 'did:example:chat',
33
+ dbMaterializedViewRefreshIntervalMs: 30_000,
34
+ },
35
+ introspect: { port: 2581 },
36
+ })
37
+ mockMailer(network.pds)
38
+
39
+ if (network.introspect) {
40
+ console.log(
41
+ `🔍 Dev-env introspection server http://localhost:${network.introspect.port}`,
42
+ )
43
+ }
44
+ console.log(`👤 DID Placeholder server http://localhost:${network.plc.port}`)
45
+ console.log(`🌞 Main PDS http://localhost:${network.pds.port}`)
46
+ console.log(`🌞 Main PDS account DB`, network.pds.ctx.cfg.db.accountDbLoc)
47
+ console.log(
48
+ `🔨 Lexicon authority DID ${network.pds.ctx.cfg.lexicon.didAuthority}`,
49
+ )
50
+ console.log(`🗼 Ozone server http://localhost:${network.ozone.port}`)
51
+ console.log(`🗼 Ozone service DID ${network.ozone.ctx.cfg.service.did}`)
52
+ console.log(`🔄 Bsync server http://localhost:${network.bsync.port}`)
53
+ console.log(`🌅 Bsky Appview http://localhost:${network.bsky.port}`)
54
+ console.log(`🌅 Bsky Appview DID ${network.bsky.serverDid}`)
55
+ for (const fg of network.feedGens) {
56
+ console.log(`🤖 Feed Generator (${fg.did}) http://localhost:${fg.port}`)
57
+ }
58
+
59
+ console.time('✅ Dev environment is ready')
60
+ await generateMockSetup(network)
61
+ console.timeEnd('✅ Dev environment is ready')
62
+ }
63
+
64
+ run()
package/src/bsky.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { Client as PlcClient } from '@did-plc/lib'
2
+ import getPort from 'get-port'
3
+ import * as ui8 from 'uint8arrays'
4
+ import { AtpAgent } from '@atproto/api'
5
+ import * as bsky from '@atproto/bsky'
6
+ import { Secp256k1Keypair } from '@atproto/crypto'
7
+ import { Client } from '@atproto/lex'
8
+ import type { DidString } from '@atproto/syntax'
9
+ import { ADMIN_PASSWORD, EXAMPLE_LABELER } from './const.js'
10
+ import type { BskyConfig } from './types.js'
11
+ export * from '@atproto/bsky'
12
+
13
+ export class TestBsky {
14
+ constructor(
15
+ public url: string,
16
+ public port: number,
17
+ public db: bsky.Database,
18
+ public server: bsky.BskyAppView,
19
+ public dataplane: bsky.DataPlaneServer,
20
+ public bsyncSub: bsky.BsyncSubscription,
21
+ public sub: bsky.RepoSubscription,
22
+ public serverDid: string,
23
+ ) {}
24
+
25
+ static async create(cfg: BskyConfig): Promise<TestBsky> {
26
+ const serviceKeypair = cfg.privateKey
27
+ ? await Secp256k1Keypair.import(cfg.privateKey)
28
+ : await Secp256k1Keypair.create()
29
+ const plcClient = new PlcClient(cfg.plcUrl)
30
+
31
+ const port = cfg.port || (await getPort())
32
+ const url = `http://localhost:${port}`
33
+ const serverDid = (await plcClient.createDid({
34
+ signingKey: serviceKeypair.did(),
35
+ rotationKeys: [serviceKeypair.did()],
36
+ handle: 'bsky.test',
37
+ pds: `http://localhost:${port}`,
38
+ signer: serviceKeypair,
39
+ })) as DidString
40
+
41
+ const endpoint = `http://localhost:${port}`
42
+
43
+ await plcClient.updateData(serverDid, serviceKeypair, (x) => {
44
+ x.services['bsky_notif'] = {
45
+ type: 'BskyNotificationService',
46
+ endpoint,
47
+ }
48
+ x.services['bsky_appview'] = {
49
+ type: 'BskyAppView',
50
+ endpoint,
51
+ }
52
+ return x
53
+ })
54
+
55
+ // shared across server, ingester, and indexer in order to share pool, avoid too many pg connections.
56
+ const db = new bsky.Database({
57
+ url: cfg.dbPostgresUrl,
58
+ schema: cfg.dbPostgresSchema,
59
+ poolSize: 10,
60
+ })
61
+
62
+ const dataplanePort = await getPort()
63
+ const dataplane = await bsky.DataPlaneServer.create(
64
+ db,
65
+ dataplanePort,
66
+ cfg.plcUrl,
67
+ )
68
+
69
+ const config = new bsky.ServerConfig({
70
+ version: 'unknown',
71
+ port,
72
+ didPlcUrl: cfg.plcUrl,
73
+ publicUrl: 'https://bsky.public.url',
74
+ serverDid,
75
+ alternateAudienceDids: [],
76
+ dataplaneUrls: [`http://localhost:${dataplanePort}`],
77
+ dataplaneHttpVersion: '1.1',
78
+ bsyncHttpVersion: '1.1',
79
+ bsyncApiKey: 'bsync-api-key',
80
+ modServiceDid: cfg.modServiceDid ?? 'did:example:invalidMod',
81
+ labelsFromIssuerDids: [EXAMPLE_LABELER],
82
+ bigThreadUris: new Set(),
83
+ maxThreadParents: cfg.maxThreadParents ?? 50,
84
+ disableSsrfProtection: true,
85
+ searchTagsHide: new Set(),
86
+ searchTagsHideAll: new Set(),
87
+ threadTagsBumpDown: new Set(),
88
+ threadTagsHide: new Set(),
89
+ visibilityTagHide: '',
90
+ visibilityTagRankPrefix: '',
91
+ debugFieldAllowedDids: new Set(),
92
+ draftsLimit: 500,
93
+ ...cfg,
94
+ adminPasswords: [ADMIN_PASSWORD],
95
+ etcdHosts: [],
96
+ })
97
+
98
+ // Separate migration db in case migration changes some connection state that we need in the tests, e.g. "alter database ... set ..."
99
+ const migrationDb = new bsky.Database({
100
+ url: cfg.dbPostgresUrl,
101
+ schema: cfg.dbPostgresSchema,
102
+ })
103
+ if (cfg.migration) {
104
+ await migrationDb.migrateToOrThrow(cfg.migration)
105
+ } else {
106
+ await migrationDb.migrateToLatestOrThrow()
107
+ }
108
+ await migrationDb.close()
109
+
110
+ // api server
111
+ const server = bsky.BskyAppView.create({
112
+ config,
113
+ signingKey: serviceKeypair,
114
+ })
115
+
116
+ const bsyncSub = new bsky.BsyncSubscription({
117
+ config,
118
+ db,
119
+ })
120
+
121
+ const sub = new bsky.RepoSubscription({
122
+ service: cfg.repoProvider,
123
+ db,
124
+ idResolver: dataplane.idResolver,
125
+ })
126
+
127
+ await server.start()
128
+
129
+ bsyncSub.start()
130
+ void sub.start()
131
+
132
+ return new TestBsky(
133
+ url,
134
+ port,
135
+ db,
136
+ server,
137
+ dataplane,
138
+ bsyncSub,
139
+ sub,
140
+ serverDid,
141
+ )
142
+ }
143
+
144
+ get ctx(): bsky.AppContext {
145
+ return this.server.ctx
146
+ }
147
+
148
+ getAgent(): AtpAgent {
149
+ const agent = new AtpAgent({ service: this.url })
150
+ agent.configureLabelers([EXAMPLE_LABELER])
151
+ return agent
152
+ }
153
+
154
+ getClient(): Client {
155
+ const client = new Client({ service: this.url })
156
+ client.setLabelers([EXAMPLE_LABELER])
157
+ return client
158
+ }
159
+
160
+ adminAuth(): string {
161
+ const [password] = this.ctx.cfg.adminPasswords
162
+ return (
163
+ 'Basic ' +
164
+ ui8.toString(ui8.fromString(`admin:${password}`, 'utf8'), 'base64pad')
165
+ )
166
+ }
167
+
168
+ adminAuthHeaders() {
169
+ return {
170
+ authorization: this.adminAuth(),
171
+ }
172
+ }
173
+
174
+ async close() {
175
+ // @TODO Use disposable stack when it becomes available (Node24+)
176
+ try {
177
+ await this.server.destroy()
178
+ } finally {
179
+ try {
180
+ await this.bsyncSub.destroy()
181
+ } finally {
182
+ try {
183
+ await this.dataplane.destroy()
184
+ } finally {
185
+ try {
186
+ await this.sub.destroy()
187
+ } finally {
188
+ await this.db.close()
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ async [Symbol.asyncDispose]() {
196
+ await this.close()
197
+ }
198
+ }
package/src/bsync.ts ADDED
@@ -0,0 +1,66 @@
1
+ import getPort from 'get-port'
2
+ import * as bsync from '@atproto/bsync'
3
+ import type { BsyncConfig } from './types.js'
4
+
5
+ export class TestBsync {
6
+ constructor(
7
+ public url: string,
8
+ public port: number,
9
+ public service: bsync.BsyncService,
10
+ ) {}
11
+
12
+ static async create(cfg: BsyncConfig): Promise<TestBsync> {
13
+ const port = cfg.port || (await getPort())
14
+ const url = `http://localhost:${port}`
15
+
16
+ const config = bsync.envToCfg({
17
+ port,
18
+ apiKeys: cfg.apiKeys ?? ['api-key'],
19
+ dbSchema: 'bsync',
20
+ // Keep the pool small: a bsync runs alongside every test network, so a
21
+ // default-sized pool per network quickly exhausts postgres connections.
22
+ dbPoolSize: cfg.dbPoolSize ?? 5,
23
+ ...cfg,
24
+ })
25
+
26
+ const service = await bsync.BsyncService.create(config)
27
+ await service.ctx.db.migrateToLatestOrThrow()
28
+ await service.start()
29
+
30
+ return new TestBsync(url, port, service)
31
+ }
32
+
33
+ get ctx(): bsync.AppContext {
34
+ return this.service.ctx
35
+ }
36
+
37
+ /**
38
+ * Current head (max id) of each operation stream. Used to wait for the bsky
39
+ * bsync subscription to catch up in tests, without waiting for the long-poll
40
+ * timeout.
41
+ */
42
+ async getStreamHeads(): Promise<{
43
+ op?: string
44
+ mute?: string
45
+ notif?: string
46
+ }> {
47
+ const db = this.ctx.db.db
48
+ const max = async (table: 'operation' | 'mute_op' | 'notif_op') => {
49
+ const row = await db
50
+ .selectFrom(table)
51
+ .select((eb) => eb.fn.max('id').as('id'))
52
+ .executeTakeFirst()
53
+ return row?.id != null ? String(row.id) : undefined
54
+ }
55
+ const [op, mute, notif] = await Promise.all([
56
+ max('operation'),
57
+ max('mute_op'),
58
+ max('notif_op'),
59
+ ])
60
+ return { op, mute, notif }
61
+ }
62
+
63
+ async close() {
64
+ await this.service.destroy()
65
+ }
66
+ }
package/src/const.ts ADDED
@@ -0,0 +1,3 @@
1
+ export const ADMIN_PASSWORD = 'admin-pass'
2
+ export const JWT_SECRET = 'jwt-secret'
3
+ export const EXAMPLE_LABELER = 'did:example:labeler'
package/src/env.ts ADDED
@@ -0,0 +1,9 @@
1
+ // NOTE: this file should be imported first, particularly before `@atproto/common` (for logging), to ensure that environment variables are respected in library code
2
+ import { config } from 'dotenv'
3
+
4
+ const env = process.env.ENV
5
+ if (env) {
6
+ config({ path: `./.${env}.env` })
7
+ } else {
8
+ config()
9
+ }