@atlasauth/emulator-core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/dist/adopt.d.ts +50 -0
  2. package/dist/adopt.js +57 -0
  3. package/dist/adopt.js.map +1 -0
  4. package/dist/coverage.d.ts +15 -0
  5. package/dist/coverage.js +31 -0
  6. package/dist/coverage.js.map +1 -0
  7. package/dist/crypto-util.d.ts +10 -0
  8. package/dist/crypto-util.js +76 -0
  9. package/dist/crypto-util.js.map +1 -0
  10. package/dist/custom-html.d.ts +57 -0
  11. package/dist/custom-html.js +491 -0
  12. package/dist/custom-html.js.map +1 -0
  13. package/dist/engine.d.ts +34 -3
  14. package/dist/engine.js +57 -8
  15. package/dist/engine.js.map +1 -1
  16. package/dist/error-catalog.js +16 -0
  17. package/dist/error-catalog.js.map +1 -1
  18. package/dist/event-gate.d.ts +3 -0
  19. package/dist/event-gate.js +430 -0
  20. package/dist/event-gate.js.map +1 -0
  21. package/dist/generic.js +14 -2
  22. package/dist/generic.js.map +1 -1
  23. package/dist/handlers/access-signals.d.ts +48 -0
  24. package/dist/handlers/access-signals.js +76 -0
  25. package/dist/handlers/access-signals.js.map +1 -0
  26. package/dist/handlers/approvals.d.ts +2 -0
  27. package/dist/handlers/approvals.js +418 -0
  28. package/dist/handlers/approvals.js.map +1 -0
  29. package/dist/handlers/authz-data.d.ts +2 -0
  30. package/dist/handlers/authz-data.js +627 -0
  31. package/dist/handlers/authz-data.js.map +1 -0
  32. package/dist/handlers/authz-layers.d.ts +229 -0
  33. package/dist/handlers/authz-layers.js +786 -0
  34. package/dist/handlers/authz-layers.js.map +1 -0
  35. package/dist/handlers/authz-state.d.ts +80 -0
  36. package/dist/handlers/authz-state.js +175 -0
  37. package/dist/handlers/authz-state.js.map +1 -0
  38. package/dist/handlers/authz.d.ts +2 -0
  39. package/dist/handlers/authz.js +410 -0
  40. package/dist/handlers/authz.js.map +1 -0
  41. package/dist/handlers/billing.d.ts +100 -0
  42. package/dist/handlers/billing.js +877 -0
  43. package/dist/handlers/billing.js.map +1 -0
  44. package/dist/handlers/cedar-ext.d.ts +35 -0
  45. package/dist/handlers/cedar-ext.js +174 -0
  46. package/dist/handlers/cedar-ext.js.map +1 -0
  47. package/dist/handlers/cedar.d.ts +146 -0
  48. package/dist/handlers/cedar.js +689 -0
  49. package/dist/handlers/cedar.js.map +1 -0
  50. package/dist/handlers/connections.d.ts +5 -0
  51. package/dist/handlers/connections.js +561 -0
  52. package/dist/handlers/connections.js.map +1 -0
  53. package/dist/handlers/control.js +6 -1
  54. package/dist/handlers/control.js.map +1 -1
  55. package/dist/handlers/device.d.ts +47 -0
  56. package/dist/handlers/device.js +665 -0
  57. package/dist/handlers/device.js.map +1 -0
  58. package/dist/handlers/emu3-misc.d.ts +38 -0
  59. package/dist/handlers/emu3-misc.js +1056 -0
  60. package/dist/handlers/emu3-misc.js.map +1 -0
  61. package/dist/handlers/emu4-actions-access.d.ts +46 -0
  62. package/dist/handlers/emu4-actions-access.js +107 -0
  63. package/dist/handlers/emu4-actions-access.js.map +1 -0
  64. package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
  65. package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
  66. package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
  67. package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
  68. package/dist/handlers/emu4-actions-catalog.js +143 -0
  69. package/dist/handlers/emu4-actions-catalog.js.map +1 -0
  70. package/dist/handlers/emu4-actions-def.d.ts +50 -0
  71. package/dist/handlers/emu4-actions-def.js +250 -0
  72. package/dist/handlers/emu4-actions-def.js.map +1 -0
  73. package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
  74. package/dist/handlers/emu4-actions-sandbox.js +318 -0
  75. package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
  76. package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
  77. package/dist/handlers/emu4-actions-typegen.js +73 -0
  78. package/dist/handlers/emu4-actions-typegen.js.map +1 -0
  79. package/dist/handlers/emu4-actions.d.ts +2 -0
  80. package/dist/handlers/emu4-actions.js +506 -0
  81. package/dist/handlers/emu4-actions.js.map +1 -0
  82. package/dist/handlers/emu4-authz-trust.d.ts +40 -0
  83. package/dist/handlers/emu4-authz-trust.js +488 -0
  84. package/dist/handlers/emu4-authz-trust.js.map +1 -0
  85. package/dist/handlers/emu4-authz.d.ts +2 -0
  86. package/dist/handlers/emu4-authz.js +752 -0
  87. package/dist/handlers/emu4-authz.js.map +1 -0
  88. package/dist/handlers/emu4-billing-core.d.ts +23 -0
  89. package/dist/handlers/emu4-billing-core.js +22 -0
  90. package/dist/handlers/emu4-billing-core.js.map +1 -0
  91. package/dist/handlers/emu4-billing-x.d.ts +2 -0
  92. package/dist/handlers/emu4-billing-x.js +489 -0
  93. package/dist/handlers/emu4-billing-x.js.map +1 -0
  94. package/dist/handlers/emu4-billing.d.ts +14 -0
  95. package/dist/handlers/emu4-billing.js +846 -0
  96. package/dist/handlers/emu4-billing.js.map +1 -0
  97. package/dist/handlers/emu4-clones.d.ts +2 -0
  98. package/dist/handlers/emu4-clones.js +322 -0
  99. package/dist/handlers/emu4-clones.js.map +1 -0
  100. package/dist/handlers/emu4-compliance.d.ts +8 -0
  101. package/dist/handlers/emu4-compliance.js +39 -0
  102. package/dist/handlers/emu4-compliance.js.map +1 -0
  103. package/dist/handlers/emu4-config.d.ts +14 -0
  104. package/dist/handlers/emu4-config.js +148 -0
  105. package/dist/handlers/emu4-config.js.map +1 -0
  106. package/dist/handlers/emu4-cookies.d.ts +8 -0
  107. package/dist/handlers/emu4-cookies.js +248 -0
  108. package/dist/handlers/emu4-cookies.js.map +1 -0
  109. package/dist/handlers/emu4-erasure.d.ts +2 -0
  110. package/dist/handlers/emu4-erasure.js +587 -0
  111. package/dist/handlers/emu4-erasure.js.map +1 -0
  112. package/dist/handlers/emu4-federation.d.ts +8 -0
  113. package/dist/handlers/emu4-federation.js +18 -0
  114. package/dist/handlers/emu4-federation.js.map +1 -0
  115. package/dist/handlers/emu4-journey-math.d.ts +42 -0
  116. package/dist/handlers/emu4-journey-math.js +99 -0
  117. package/dist/handlers/emu4-journey-math.js.map +1 -0
  118. package/dist/handlers/emu4-messaging.d.ts +21 -0
  119. package/dist/handlers/emu4-messaging.js +595 -0
  120. package/dist/handlers/emu4-messaging.js.map +1 -0
  121. package/dist/handlers/emu4-minors.d.ts +7 -0
  122. package/dist/handlers/emu4-minors.js +408 -0
  123. package/dist/handlers/emu4-minors.js.map +1 -0
  124. package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
  125. package/dist/handlers/emu4-oauth-audit.js +17 -0
  126. package/dist/handlers/emu4-oauth-audit.js.map +1 -0
  127. package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
  128. package/dist/handlers/emu4-oauth-auth.js +363 -0
  129. package/dist/handlers/emu4-oauth-auth.js.map +1 -0
  130. package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
  131. package/dist/handlers/emu4-oauth-delivery.js +207 -0
  132. package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
  133. package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
  134. package/dist/handlers/emu4-oauth-keys.js +268 -0
  135. package/dist/handlers/emu4-oauth-keys.js.map +1 -0
  136. package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
  137. package/dist/handlers/emu4-oauth-pub.js +420 -0
  138. package/dist/handlers/emu4-oauth-pub.js.map +1 -0
  139. package/dist/handlers/emu4-oauth.d.ts +10 -0
  140. package/dist/handlers/emu4-oauth.js +23 -0
  141. package/dist/handlers/emu4-oauth.js.map +1 -0
  142. package/dist/handlers/emu4-policies.d.ts +2 -0
  143. package/dist/handlers/emu4-policies.js +408 -0
  144. package/dist/handlers/emu4-policies.js.map +1 -0
  145. package/dist/handlers/emu4-region.d.ts +2 -0
  146. package/dist/handlers/emu4-region.js +189 -0
  147. package/dist/handlers/emu4-region.js.map +1 -0
  148. package/dist/handlers/emu4-saml.d.ts +12 -0
  149. package/dist/handlers/emu4-saml.js +532 -0
  150. package/dist/handlers/emu4-saml.js.map +1 -0
  151. package/dist/handlers/emu4-shared.d.ts +44 -0
  152. package/dist/handlers/emu4-shared.js +90 -0
  153. package/dist/handlers/emu4-shared.js.map +1 -0
  154. package/dist/handlers/emu4-snapshots.d.ts +38 -0
  155. package/dist/handlers/emu4-snapshots.js +231 -0
  156. package/dist/handlers/emu4-snapshots.js.map +1 -0
  157. package/dist/handlers/emu4-sns.d.ts +9 -0
  158. package/dist/handlers/emu4-sns.js +21 -0
  159. package/dist/handlers/emu4-sns.js.map +1 -0
  160. package/dist/handlers/emu4-trust-binding.d.ts +27 -0
  161. package/dist/handlers/emu4-trust-binding.js +344 -0
  162. package/dist/handlers/emu4-trust-binding.js.map +1 -0
  163. package/dist/handlers/emu4-trust.d.ts +35 -0
  164. package/dist/handlers/emu4-trust.js +543 -0
  165. package/dist/handlers/emu4-trust.js.map +1 -0
  166. package/dist/handlers/federation.d.ts +2 -0
  167. package/dist/handlers/federation.js +481 -0
  168. package/dist/handlers/federation.js.map +1 -0
  169. package/dist/handlers/fga-engine.d.ts +78 -0
  170. package/dist/handlers/fga-engine.js +663 -0
  171. package/dist/handlers/fga-engine.js.map +1 -0
  172. package/dist/handlers/governance.d.ts +2 -0
  173. package/dist/handlers/governance.js +460 -0
  174. package/dist/handlers/governance.js.map +1 -0
  175. package/dist/handlers/hooks.js +17 -1
  176. package/dist/handlers/hooks.js.map +1 -1
  177. package/dist/handlers/index.d.ts +5 -1
  178. package/dist/handlers/index.js +45 -3
  179. package/dist/handlers/index.js.map +1 -1
  180. package/dist/handlers/keys.js +6 -1
  181. package/dist/handlers/keys.js.map +1 -1
  182. package/dist/handlers/machines.js +61 -8
  183. package/dist/handlers/machines.js.map +1 -1
  184. package/dist/handlers/messaging.d.ts +6 -0
  185. package/dist/handlers/messaging.js +893 -0
  186. package/dist/handlers/messaging.js.map +1 -0
  187. package/dist/handlers/oauth-adv.d.ts +191 -0
  188. package/dist/handlers/oauth-adv.js +1305 -0
  189. package/dist/handlers/oauth-adv.js.map +1 -0
  190. package/dist/handlers/oauth-flows.d.ts +52 -0
  191. package/dist/handlers/oauth-flows.js +480 -0
  192. package/dist/handlers/oauth-flows.js.map +1 -0
  193. package/dist/handlers/oauth.d.ts +96 -0
  194. package/dist/handlers/oauth.js +793 -0
  195. package/dist/handlers/oauth.js.map +1 -0
  196. package/dist/handlers/orgs.js +6 -0
  197. package/dist/handlers/orgs.js.map +1 -1
  198. package/dist/handlers/passkeys.d.ts +3 -0
  199. package/dist/handlers/passkeys.js +293 -0
  200. package/dist/handlers/passkeys.js.map +1 -0
  201. package/dist/handlers/platform.d.ts +20 -0
  202. package/dist/handlers/platform.js +472 -0
  203. package/dist/handlers/platform.js.map +1 -0
  204. package/dist/handlers/round2.d.ts +8 -0
  205. package/dist/handlers/round2.js +372 -0
  206. package/dist/handlers/round2.js.map +1 -0
  207. package/dist/handlers/scim.d.ts +4 -0
  208. package/dist/handlers/scim.js +595 -0
  209. package/dist/handlers/scim.js.map +1 -0
  210. package/dist/handlers/sessions.d.ts +9 -3
  211. package/dist/handlers/sessions.js +76 -38
  212. package/dist/handlers/sessions.js.map +1 -1
  213. package/dist/handlers/support.d.ts +20 -0
  214. package/dist/handlers/support.js +62 -0
  215. package/dist/handlers/support.js.map +1 -0
  216. package/dist/handlers/users.js +7 -0
  217. package/dist/handlers/users.js.map +1 -1
  218. package/dist/handlers/workflows.d.ts +2 -0
  219. package/dist/handlers/workflows.js +428 -0
  220. package/dist/handlers/workflows.js.map +1 -0
  221. package/dist/index.d.ts +8 -0
  222. package/dist/index.js +23 -1
  223. package/dist/index.js.map +1 -1
  224. package/dist/jwe.d.ts +18 -0
  225. package/dist/jwe.js +101 -0
  226. package/dist/jwe.js.map +1 -0
  227. package/dist/jws.d.ts +41 -0
  228. package/dist/jws.js +135 -0
  229. package/dist/jws.js.map +1 -0
  230. package/dist/messages.d.ts +7 -0
  231. package/dist/messages.js +8 -4
  232. package/dist/messages.js.map +1 -1
  233. package/dist/messaging-callbacks.d.ts +100 -0
  234. package/dist/messaging-callbacks.js +767 -0
  235. package/dist/messaging-callbacks.js.map +1 -0
  236. package/dist/openapi.js +41 -1
  237. package/dist/openapi.js.map +1 -1
  238. package/dist/parity-bin.js +3 -1
  239. package/dist/parity-bin.js.map +1 -1
  240. package/dist/parity-emu3-authz.d.ts +3 -0
  241. package/dist/parity-emu3-authz.js +61 -0
  242. package/dist/parity-emu3-authz.js.map +1 -0
  243. package/dist/parity-emu3-billing.d.ts +3 -0
  244. package/dist/parity-emu3-billing.js +25 -0
  245. package/dist/parity-emu3-billing.js.map +1 -0
  246. package/dist/parity-emu3-messaging.d.ts +3 -0
  247. package/dist/parity-emu3-messaging.js +27 -0
  248. package/dist/parity-emu3-messaging.js.map +1 -0
  249. package/dist/parity-emu3-misc.d.ts +2 -0
  250. package/dist/parity-emu3-misc.js +63 -0
  251. package/dist/parity-emu3-misc.js.map +1 -0
  252. package/dist/parity-emu3-oauth.d.ts +2 -0
  253. package/dist/parity-emu3-oauth.js +39 -0
  254. package/dist/parity-emu3-oauth.js.map +1 -0
  255. package/dist/parity-emu4-actions.d.ts +2 -0
  256. package/dist/parity-emu4-actions.js +38 -0
  257. package/dist/parity-emu4-actions.js.map +1 -0
  258. package/dist/parity-emu4-authz.d.ts +3 -0
  259. package/dist/parity-emu4-authz.js +68 -0
  260. package/dist/parity-emu4-authz.js.map +1 -0
  261. package/dist/parity-emu4-billing.d.ts +6 -0
  262. package/dist/parity-emu4-billing.js +44 -0
  263. package/dist/parity-emu4-billing.js.map +1 -0
  264. package/dist/parity-emu4-compliance.d.ts +2 -0
  265. package/dist/parity-emu4-compliance.js +92 -0
  266. package/dist/parity-emu4-compliance.js.map +1 -0
  267. package/dist/parity-emu4-federation.d.ts +2 -0
  268. package/dist/parity-emu4-federation.js +33 -0
  269. package/dist/parity-emu4-federation.js.map +1 -0
  270. package/dist/parity-emu4-messaging.d.ts +3 -0
  271. package/dist/parity-emu4-messaging.js +42 -0
  272. package/dist/parity-emu4-messaging.js.map +1 -0
  273. package/dist/parity-emu4-oauth.d.ts +2 -0
  274. package/dist/parity-emu4-oauth.js +55 -0
  275. package/dist/parity-emu4-oauth.js.map +1 -0
  276. package/dist/parity-emu4-trust.d.ts +2 -0
  277. package/dist/parity-emu4-trust.js +41 -0
  278. package/dist/parity-emu4-trust.js.map +1 -0
  279. package/dist/parity-routes.d.ts +14 -0
  280. package/dist/parity-routes.js +439 -0
  281. package/dist/parity-routes.js.map +1 -0
  282. package/dist/parity.d.ts +10 -3
  283. package/dist/parity.js +14 -5
  284. package/dist/parity.js.map +1 -1
  285. package/dist/sns-cert.d.ts +9 -0
  286. package/dist/sns-cert.js +101 -0
  287. package/dist/sns-cert.js.map +1 -0
  288. package/dist/tokens.d.ts +85 -3
  289. package/dist/tokens.js +196 -24
  290. package/dist/tokens.js.map +1 -1
  291. package/dist/webauthn-virtual.d.ts +70 -0
  292. package/dist/webauthn-virtual.js +186 -0
  293. package/dist/webauthn-virtual.js.map +1 -0
  294. package/dist/webhook-ext.d.ts +16 -0
  295. package/dist/webhook-ext.js +218 -0
  296. package/dist/webhook-ext.js.map +1 -0
  297. package/dist/webhooks.d.ts +3 -0
  298. package/dist/webhooks.js +37 -3
  299. package/dist/webhooks.js.map +1 -1
  300. package/package.json +2 -2
@@ -0,0 +1,561 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.relayCall = relayCall;
4
+ exports.connectionsHandlers = connectionsHandlers;
5
+ const crypto_util_1 = require("../crypto-util");
6
+ const errors_1 = require("../errors");
7
+ const store_1 = require("../store");
8
+ const support_1 = require("./support");
9
+ const util_1 = require("./util");
10
+ const emu4_actions_access_1 = require("./emu4-actions-access");
11
+ /**
12
+ * Connection history and recovery on the engine's own state: the per-connection activity log (what happened, never what was
13
+ * sent: method, host, status and timing only), persisted provider events with read-back and replay, hosted reconnect
14
+ * (settings, a one-time link, the public pages) and the exchange of a non-expiring Shopify token for an expiring one.
15
+ * Connections themselves are the generic CRUD layer's rows. The relay goes through the engine's `fetch` option, so it never
16
+ * reaches a real provider unless a test says so.
17
+ */
18
+ const RETENTION_MS = 30 * 86_400_000;
19
+ const ACTIVITY_KINDS = ['relay', 'token_read', 'refresh', 'health', 'provider_event', 'reconnect', 'token_exchange'];
20
+ const OUTCOMES = ['ok', 'error', 'blocked'];
21
+ const UNINSTALL = /(^|[/._-])(uninstall(ed)?|deauthori[sz]ed?|installation[._-]deleted|app[._/-]uninstalled)($|[/._-])/i;
22
+ const MAX_PAYLOAD = 256 * 1024;
23
+ const bad = (message, param) => new errors_1.EmulatorError('VALIDATION_FAILED', message, param);
24
+ const st = (0, support_1.engineState)((e) => ({ activity: new store_1.Table('connection_activity', e.clock.now, 'cact'), events: new store_1.Table('connection_provider_events', e.clock.now, 'cpev'), links: new Map(), settings: { enabled: false, link_ttl_hours: 168, return_url: null, providers: [] } }));
25
+ const connOf = (e, id) => {
26
+ const c = e.generic.get('connections')?.get(id);
27
+ if (!c)
28
+ throw (0, errors_1.notFound)('Connection');
29
+ return c;
30
+ };
31
+ const record = (e, connectionId, kind, outcome, extra = {}) => {
32
+ st(e).activity.insert({ connection_id: connectionId, kind, outcome, created_at: e.clock.now(), ...extra });
33
+ };
34
+ const activityView = (r) => ({ object: 'connection_activity', id: r.id, connection_id: r.connection_id, kind: r.kind, outcome: r.outcome, method: r.method ?? null, host: r.host ?? null, status: r.status ?? null, duration_ms: r.duration_ms ?? null, attempts: r.attempts ?? null, detail: r.detail ?? null, created_at: r.created_at });
35
+ const eventView = (r, payload) => ({ object: 'connection_provider_event', id: r.id, connection_id: r.connection_id, provider: r.provider, topic: r.topic, kind: r.kind, replayed: r.replay_count > 0, replay_count: r.replay_count, received_at: r.received_at, ...(payload ? { payload: r.payload } : {}) });
36
+ const sinceFilter = (e, v) => {
37
+ if (v === null)
38
+ return e.clock.now() - RETENTION_MS;
39
+ const n = /^\d+$/.test(v) ? Number(v) : Date.parse(v);
40
+ if (Number.isNaN(n))
41
+ throw bad('since must be epoch milliseconds or an ISO 8601 time.', 'since');
42
+ return Math.max(n, e.clock.now() - RETENTION_MS);
43
+ };
44
+ const hostAllowed = (host, allowed) => allowed.some((a) => (a.startsWith('*.') ? host.endsWith(a.slice(1)) && host.length > a.length - 1 : host === a));
45
+ const hashLink = async (t) => (0, crypto_util_1.bytesToB64url)(await (0, crypto_util_1.sha256)(t));
46
+ function announce(e, ev, replayed) {
47
+ e.webhooks.emit('connection.provider_event', { object: 'connection_provider_event', id: ev.id, connection_id: ev.connection_id, provider: ev.provider, topic: ev.topic, kind: ev.kind, ...(replayed ? { replayed: true, event_id: ev.id } : {}), received_at: ev.received_at });
48
+ }
49
+ // ── bulk import ──
50
+ const IMPORT_MAX_ROWS = 500;
51
+ const OWNER_TYPES = ['organization', 'user', 'external'];
52
+ const CREDENTIAL_KINDS = ['api_key', 'client_credentials', 'bearer', 'basic', 'custom_header', 'hmac_secret', 'jwt_bearer', 'oauth1', 'github_app_installation', 'admin_consent'];
53
+ class RowError extends Error {
54
+ code;
55
+ field;
56
+ constructor(code, message, field) {
57
+ super(message);
58
+ this.code = code;
59
+ this.field = field;
60
+ }
61
+ }
62
+ const rowStr = (v, field, max = 4096) => {
63
+ if (v === undefined || v === null)
64
+ return undefined;
65
+ if (typeof v !== 'string' || !v.trim() || v.length > max)
66
+ throw new RowError('invalid', `${field} must be a non-empty string of at most ${max} characters.`, field);
67
+ return v.trim();
68
+ };
69
+ const rowTime = (v, field) => {
70
+ if (v === undefined || v === null)
71
+ return null;
72
+ const t = typeof v === 'number' ? v : Date.parse(String(v));
73
+ if (!Number.isFinite(t))
74
+ throw new RowError('invalid', `${field} must be epoch milliseconds or an ISO 8601 time.`, field);
75
+ return t;
76
+ };
77
+ /** One import row, checked the way the real import checks it. */
78
+ function parseRow(row) {
79
+ const provider = rowStr(row.provider, 'provider', 64);
80
+ if (!provider || !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(provider))
81
+ throw new RowError('invalid', 'provider must be a provider key.', 'provider');
82
+ const o = (row.owner ?? {});
83
+ if (!OWNER_TYPES.includes(o.type) || typeof o.id !== 'string' || !o.id)
84
+ throw new RowError('invalid', 'owner must be {type: "organization" | "user" | "external", id}.', 'owner');
85
+ const externalId = rowStr(row.external_id, 'external_id', 255);
86
+ if (!externalId)
87
+ throw new RowError('external_id_required', 'external_id (the provider-side account id) is required: it is how a re-import finds the same connection.', 'external_id');
88
+ const kind = row.kind ?? 'oauth';
89
+ if (typeof kind !== 'string' || (kind !== 'oauth' && !CREDENTIAL_KINDS.includes(kind)))
90
+ throw new RowError('invalid', `kind must be one of oauth, ${CREDENTIAL_KINDS.join(', ')}.`, 'kind');
91
+ const refreshToken = rowStr(row.refresh_token, 'refresh_token');
92
+ const accessToken = rowStr(row.access_token, 'access_token', 16384);
93
+ if (kind !== 'oauth') {
94
+ if (refreshToken || accessToken || row.token_family !== undefined)
95
+ throw new RowError('invalid', `A ${kind} row takes credentials, not refresh_token, access_token or token_family.`, 'credentials');
96
+ if (!row.credentials || typeof row.credentials !== 'object' || Array.isArray(row.credentials))
97
+ throw new RowError('invalid', `credentials (the ${kind} secret fields) is required.`, 'credentials');
98
+ }
99
+ else if (!refreshToken && !accessToken)
100
+ throw new RowError('invalid', 'refresh_token (or at least an access_token) is required.', 'refresh_token');
101
+ else if (row.credentials !== undefined)
102
+ throw new RowError('invalid', 'credentials apply to a non-oauth kind; set kind.', 'credentials');
103
+ const expiresAt = rowTime(row.expires_at, 'expires_at');
104
+ const refreshExpiresAt = rowTime(row.refresh_token_expires_at, 'refresh_token_expires_at');
105
+ let configScopes;
106
+ if (kind === 'oauth' && row.config !== undefined && row.config !== null) {
107
+ if (typeof row.config !== 'object' || Array.isArray(row.config))
108
+ throw new RowError('invalid', 'config must be an object with scopes.', 'config');
109
+ const cs = row.config.scopes;
110
+ if (cs !== undefined) {
111
+ if (!Array.isArray(cs) || cs.length > 50 || cs.some((x) => typeof x !== 'string' || !x || /\s/.test(x) || x.length > 200))
112
+ throw new RowError('invalid', 'config.scopes must be a list of scope strings.', 'config.scopes');
113
+ configScopes = cs;
114
+ }
115
+ }
116
+ let scopes;
117
+ if (row.scopes !== undefined) {
118
+ const raw = typeof row.scopes === 'string' ? row.scopes.split(/[\s,]+/).filter(Boolean) : row.scopes;
119
+ if (!Array.isArray(raw) || raw.length > 200 || raw.some((x) => typeof x !== 'string' || !x || /\s/.test(x) || x.length > 300))
120
+ throw new RowError('invalid', 'scopes must be a list of scope strings.', 'scopes');
121
+ scopes = raw;
122
+ }
123
+ const metadata = {};
124
+ if (row.metadata !== undefined && row.metadata !== null) {
125
+ if (typeof row.metadata !== 'object' || Array.isArray(row.metadata))
126
+ throw new RowError('invalid', 'metadata must be an object of strings.', 'metadata');
127
+ for (const [k, v] of Object.entries(row.metadata)) {
128
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(k) || (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') || String(v).length > 1024)
129
+ throw new RowError('invalid', `metadata.${k} must be a short scalar.`, `metadata.${k}`);
130
+ metadata[k] = String(v);
131
+ }
132
+ }
133
+ const environment = row.environment ?? 'production';
134
+ if (environment !== 'production' && environment !== 'sandbox')
135
+ throw new RowError('invalid', 'environment must be production or sandbox.', 'environment');
136
+ if (row.host_params !== undefined && (typeof row.host_params !== 'object' || row.host_params === null || Array.isArray(row.host_params) || Object.values(row.host_params).some((v) => typeof v !== 'string')))
137
+ throw new RowError('invalid', 'host_params must be an object of strings.', 'host_params');
138
+ return { kind, provider, owner: { type: o.type, id: o.id }, externalId, refreshToken, accessToken, expiresAt, refreshExpiresAt, configScopes, scopes, metadata, environment, hostParams: row.host_params ?? {}, name: rowStr(row.name, 'name', 200) ?? provider, nameGiven: row.name != null, allowedHosts: Array.isArray(row.allowed_hosts) ? row.allowed_hosts.filter((h) => typeof h === 'string') : [], tokenFamily: rowStr(row.token_family, 'token_family', 128) };
139
+ }
140
+ const failedRow = (index, err) => {
141
+ const e = err;
142
+ const code = e instanceof RowError ? e.code : 'invalid';
143
+ return { index, status: 'failed', connection_id: null, validated: false, refreshed: false, error: { code, message: e.message || 'Could not import this row.', ...(e instanceof RowError && e.field ? { field: e.field } : {}) } };
144
+ };
145
+ /** One relayed provider call: allowed method and host only, answered through the engine's `fetch`; also the path action runs take. */
146
+ async function relayCall(e, conn, body) {
147
+ const method = (0, support_1.oneOf)(body, 'method', ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'], 'GET');
148
+ let url;
149
+ try {
150
+ url = new URL(String(body.url));
151
+ }
152
+ catch {
153
+ throw bad('url must be an absolute https URL.', 'url');
154
+ }
155
+ if (url.protocol !== 'https:' || url.username || url.password)
156
+ throw bad('url must be an https URL without credentials.', 'url');
157
+ const methods = conn.allowed_methods ?? ['GET'];
158
+ const refuse = (detail, param) => {
159
+ record(e, conn.id, 'relay', 'blocked', { method, host: url.host, detail });
160
+ throw new errors_1.EmulatorError('FORBIDDEN', detail, param);
161
+ };
162
+ if (conn.status === 'revoked')
163
+ refuse('This connection is revoked.', 'url');
164
+ if (!methods.includes(method))
165
+ refuse(`${method} is not in this connection's allowed_methods.`, 'method');
166
+ if (!hostAllowed(url.hostname, conn.allowed_hosts ?? []))
167
+ refuse('That host is not in this connection\'s allowed_hosts.', 'url');
168
+ const stubbed = (0, emu4_actions_access_1.matchProviderStub)(e, url.toString(), method);
169
+ if (!stubbed && !e.options.fetch) {
170
+ record(e, conn.id, 'relay', 'error', { method, host: url.host, status: null, duration_ms: 0, attempts: 1, detail: 'no fetch configured' });
171
+ throw new errors_1.EmulatorError('CONNECTION_UPSTREAM_ERROR', 'The emulator has no outbound fetch: pass the `fetch` option to answer relayed calls.');
172
+ }
173
+ const started = e.clock.now();
174
+ let res;
175
+ try {
176
+ res = stubbed ?? (await e.options.fetch(url.toString(), { method, headers: body.headers ?? {}, body: ['GET', 'HEAD'].includes(method) ? undefined : typeof body.body === 'string' ? body.body : body.body === undefined ? undefined : JSON.stringify(body.body) }));
177
+ }
178
+ catch (err) {
179
+ record(e, conn.id, 'relay', 'error', { method, host: url.host, status: null, duration_ms: e.clock.now() - started, attempts: 1, detail: 'network error' });
180
+ throw new errors_1.EmulatorError('CONNECTION_UPSTREAM_ERROR', `The provider could not be reached: ${err.message}`);
181
+ }
182
+ record(e, conn.id, 'relay', res.status < 400 ? 'ok' : 'error', { method, host: url.host, status: res.status, duration_ms: e.clock.now() - started, attempts: 1 });
183
+ return { object: 'connection_relay_response', connection_id: conn.id, status: res.status, headers: Object.fromEntries(res.headers), body: await res.text() };
184
+ }
185
+ function connectionsHandlers() {
186
+ const eventOf = (e, connectionId, id) => {
187
+ const ev = st(e).events.get(id);
188
+ if (!ev || ev.connection_id !== connectionId)
189
+ throw (0, errors_1.notFound)('Provider event');
190
+ return ev;
191
+ };
192
+ const eventList = (e, q, connectionId, c) => {
193
+ const since = sinceFilter(e, q.get('since'));
194
+ const rows = st(e).events.all().filter((r) => r.received_at >= since && (!connectionId || r.connection_id === connectionId) && (!q.get('connection_id') || r.connection_id === q.get('connection_id')) && (!q.get('provider') || r.provider === q.get('provider')) && (!q.get('topic') || r.topic === q.get('topic')));
195
+ const page = (0, store_1.paginate)(rows, (0, util_1.pageOf)(c));
196
+ return { object: 'list', data: page.data.map((r) => eventView(r, false)), has_more: page.has_more, next_cursor: page.next_cursor };
197
+ };
198
+ return [
199
+ // ── activity ──
200
+ {
201
+ method: 'GET',
202
+ pattern: '/v1/connections/:id/activity',
203
+ scope: 'organizations:read',
204
+ handler: (c) => {
205
+ const e = c.engine;
206
+ const conn = connOf(e, c.params.id);
207
+ const kind = c.query.get('kind');
208
+ const outcome = c.query.get('outcome');
209
+ if (kind && !ACTIVITY_KINDS.includes(kind))
210
+ throw bad(`kind must be one of: ${ACTIVITY_KINDS.join(', ')}.`, 'kind');
211
+ if (outcome && !OUTCOMES.includes(outcome))
212
+ throw bad(`outcome must be one of: ${OUTCOMES.join(', ')}.`, 'outcome');
213
+ const since = sinceFilter(e, c.query.get('since'));
214
+ const rows = st(e).activity.all().filter((r) => r.connection_id === conn.id && r.created_at >= since && (!kind || r.kind === kind) && (!outcome || r.outcome === outcome));
215
+ const page = (0, store_1.paginate)(rows, (0, util_1.pageOf)(c));
216
+ return { body: { object: 'list', data: page.data.map(activityView), has_more: page.has_more, next_cursor: page.next_cursor } };
217
+ },
218
+ },
219
+ {
220
+ // The relay: allowed method and host only; the log keeps method, host, status, duration and attempts, never the path, headers or body.
221
+ method: 'POST',
222
+ pattern: '/v1/connections/:id/relay',
223
+ scope: 'connections:relay',
224
+ handler: async (c) => {
225
+ const conn = connOf(c.engine, c.params.id);
226
+ (0, emu4_actions_access_1.assertConnectionAccess)(c.engine, conn.id, c.key, 'relay');
227
+ return { body: await relayCall(c.engine, conn, c.body) };
228
+ },
229
+ },
230
+ // ── persisted provider events ──
231
+ {
232
+ // The provider's announcement lands here (in production, signature-verified per provider): stored, announced, and an uninstall revokes the connection.
233
+ method: 'POST',
234
+ pattern: '/v1/connection_provider_events/:instanceId/:provider',
235
+ public: true,
236
+ handler: (c) => {
237
+ const e = c.engine;
238
+ if (c.params.instanceId !== e.instanceId)
239
+ throw (0, errors_1.notFound)('Instance');
240
+ const conn = connOf(e, c.body.connection_id);
241
+ if (conn.provider !== c.params.provider)
242
+ throw (0, errors_1.notFound)('Connection for that provider');
243
+ if (typeof c.body.topic !== 'string' || !c.body.topic || c.body.topic.length > 200)
244
+ throw bad('topic is required (up to 200 characters).', 'topic');
245
+ const payload = c.body.payload ?? {};
246
+ if (JSON.stringify(payload).length > MAX_PAYLOAD)
247
+ throw bad('payload can be at most 256 KB.', 'payload');
248
+ const kind = UNINSTALL.test(c.body.topic) ? 'uninstall' : 'event';
249
+ const ev = st(e).events.insert({ connection_id: conn.id, provider: conn.provider, topic: c.body.topic, kind, payload, replay_count: 0, received_at: e.clock.now() });
250
+ record(e, conn.id, 'provider_event', 'ok', { detail: c.body.topic });
251
+ announce(e, ev, false);
252
+ if (kind === 'uninstall' && conn.status !== 'revoked') {
253
+ conn.status = 'revoked';
254
+ record(e, conn.id, 'health', 'ok', { detail: 'revoked by the provider' });
255
+ e.webhooks.emit('connection.revoked', { object: 'connection', id: conn.id, provider: conn.provider, reason: 'provider_uninstall' });
256
+ }
257
+ return { status: 201, body: eventView(ev, false) };
258
+ },
259
+ },
260
+ { method: 'GET', pattern: '/v1/connections/:id/provider_events', scope: 'organizations:read', handler: (c) => ({ body: eventList(c.engine, c.query, connOf(c.engine, c.params.id).id, c) }) },
261
+ { method: 'GET', pattern: '/v1/connection_provider_event_log', scope: 'organizations:read', handler: (c) => ({ body: eventList(c.engine, c.query, null, c) }) },
262
+ {
263
+ method: 'GET',
264
+ pattern: '/v1/connections/:id/provider_events/:eventId',
265
+ scope: 'organizations:read',
266
+ handler: (c) => ({ body: eventView(eventOf(c.engine, connOf(c.engine, c.params.id).id, c.params.eventId), true) }),
267
+ },
268
+ {
269
+ // Re-announces only: a replayed uninstall never revokes a connection again.
270
+ method: 'POST',
271
+ pattern: '/v1/connections/:id/provider_events/:eventId/replay',
272
+ scope: 'organizations:write',
273
+ handler: (c) => {
274
+ const e = c.engine;
275
+ const ev = eventOf(e, connOf(e, c.params.id).id, c.params.eventId);
276
+ ev.replay_count++;
277
+ announce(e, ev, true);
278
+ return { body: eventView(ev, false) };
279
+ },
280
+ },
281
+ // ── hosted reconnect ──
282
+ { method: 'GET', pattern: '/v1/connection_reconnect_settings', scope: 'organizations:read', handler: (c) => ({ body: { object: 'connection_reconnect_settings', ...st(c.engine).settings } }) },
283
+ {
284
+ method: 'PUT',
285
+ pattern: '/v1/connection_reconnect_settings',
286
+ scope: 'organizations:write',
287
+ handler: (c) => {
288
+ const s = st(c.engine).settings;
289
+ const b = c.body;
290
+ const unknown = Object.keys(b).find((k) => !['enabled', 'link_ttl_hours', 'return_url', 'providers'].includes(k));
291
+ if (unknown)
292
+ throw bad(`Unknown field: ${unknown}.`, unknown);
293
+ const next = { ...s };
294
+ if (b.enabled !== undefined) {
295
+ if (typeof b.enabled !== 'boolean')
296
+ throw bad('enabled must be true or false.', 'enabled');
297
+ next.enabled = b.enabled;
298
+ }
299
+ if (b.link_ttl_hours !== undefined)
300
+ next.link_ttl_hours = (0, support_1.intIn)(b.link_ttl_hours, 'link_ttl_hours', 1, 720);
301
+ if (b.return_url !== undefined) {
302
+ if (b.return_url !== null) {
303
+ let u = null;
304
+ try {
305
+ u = new URL(b.return_url);
306
+ }
307
+ catch {
308
+ /* reported below */
309
+ }
310
+ if (!u || u.protocol !== 'https:' || u.username || u.password)
311
+ throw bad('return_url must be an https URL.', 'return_url');
312
+ }
313
+ next.return_url = b.return_url;
314
+ }
315
+ if (b.providers !== undefined) {
316
+ if (!Array.isArray(b.providers) || b.providers.length > 50 || b.providers.some((p) => typeof p !== 'string' || !p))
317
+ throw bad('providers is a list of up to 50 provider keys.', 'providers');
318
+ next.providers = [...new Set(b.providers)];
319
+ }
320
+ Object.assign(s, next);
321
+ return { body: { object: 'connection_reconnect_settings', ...s } };
322
+ },
323
+ },
324
+ {
325
+ // A one-time link for the owner to authorize again. Only a hash is stored; the URL is shown once. `send_email` mails the owner (your own mail provider).
326
+ method: 'POST',
327
+ pattern: '/v1/connections/:id/reconnect_link',
328
+ scope: 'organizations:write',
329
+ handler: async (c) => {
330
+ const e = c.engine;
331
+ const conn = connOf(e, c.params.id);
332
+ const s = st(e).settings;
333
+ if (c.body.send_email !== undefined && typeof c.body.send_email !== 'boolean')
334
+ throw bad('send_email must be a boolean.', 'send_email');
335
+ const ttl = c.body.ttl_hours === undefined ? s.link_ttl_hours : (0, support_1.intIn)(c.body.ttl_hours, 'ttl_hours', 1, 720);
336
+ if (!s.enabled)
337
+ throw new errors_1.EmulatorError('CONFLICT', 'Hosted reconnect is off. Turn it on with PUT /v1/connection_reconnect_settings.');
338
+ if (conn.kind !== 'oauth')
339
+ throw bad('Only an oauth connection is authorized again with the provider.', 'kind');
340
+ if (conn.status === 'revoked')
341
+ throw new errors_1.EmulatorError('CONFLICT', 'This connection was revoked; connect it again with POST /v1/connections/{id}/connect.');
342
+ if (s.providers.length && !s.providers.includes(conn.provider))
343
+ throw bad(`Hosted reconnect is not enabled for ${conn.provider}.`, 'provider');
344
+ let recipients = [];
345
+ if (c.body.send_email === true) {
346
+ const user = conn.owner?.type === 'user' ? e.db.users.get(conn.owner.id) : undefined;
347
+ const primary = user?.emails?.find((m) => m.id === user?.primary_email_address_id && m.verified);
348
+ if (!primary)
349
+ throw bad('This connection has no email recipient (a user owner needs a verified address).', 'send_email');
350
+ recipients = [primary.email_address];
351
+ }
352
+ const token = (0, store_1.randomToken)(32);
353
+ const expiresAt = e.clock.now() + ttl * 3_600_000;
354
+ st(e).links.set(await hashLink(token), { connectionId: conn.id, expiresAt, started: false });
355
+ const url = `${e.issuer}/v1/connection_reconnect/${token}`;
356
+ for (const to of recipients)
357
+ e.messages.send({ channel: 'email', to, subject: `Reconnect your ${conn.provider} connection`, link: url, meta: { template: 'connection_reconnect' } });
358
+ record(e, conn.id, 'reconnect', 'ok', { detail: 'link issued' });
359
+ return { status: 201, body: { object: 'connection_reconnect_link', connection_id: conn.id, url, expires_at: expiresAt, emailed: recipients.length } };
360
+ },
361
+ },
362
+ {
363
+ // Public. Looking at the page spends nothing (a mail scanner may open it). Every bad link answers the same.
364
+ method: 'GET',
365
+ pattern: '/v1/connection_reconnect/:token',
366
+ public: true,
367
+ handler: async (c) => {
368
+ const e = c.engine;
369
+ const link = st(e).links.get(await hashLink(c.params.token ?? ''));
370
+ if (!link || link.expiresAt <= e.clock.now() || link.started)
371
+ throw bad('This link is not valid.', 'token');
372
+ const conn = connOf(e, link.connectionId);
373
+ return { headers: { 'cache-control': 'no-store' }, body: { object: 'connection_reconnect_page', connection: { id: conn.id, provider: conn.provider, name: conn.name ?? null }, expires_at: link.expiresAt } };
374
+ },
375
+ },
376
+ {
377
+ method: 'GET',
378
+ pattern: '/v1/connection_reconnect/:token/start',
379
+ public: true,
380
+ handler: async (c) => {
381
+ const e = c.engine;
382
+ const link = st(e).links.get(await hashLink(c.params.token ?? ''));
383
+ if (!link || link.expiresAt <= e.clock.now() || link.started)
384
+ throw bad('This link is not valid.', 'token');
385
+ link.started = true;
386
+ const conn = connOf(e, link.connectionId);
387
+ record(e, conn.id, 'reconnect', 'ok', { detail: 'started' });
388
+ const back = st(e).settings.return_url ?? `${e.issuer}/v1/connection_reconnect_done`;
389
+ return { status: 302, headers: { location: `${e.issuer}/__emulator/provider/${encodeURIComponent(conn.provider)}/authorize?connection_id=${conn.id}&return=${encodeURIComponent(back)}`, 'cache-control': 'no-store' } };
390
+ },
391
+ },
392
+ { method: 'GET', pattern: '/v1/connection_reconnect_done', public: true, handler: () => ({ headers: { 'cache-control': 'no-store' }, body: { object: 'connection_reconnect_done', ok: true } }) },
393
+ // ── bulk import: tokens a customer already holds, so no one reconnects ──
394
+ {
395
+ method: 'POST',
396
+ pattern: '/v1/connections/import',
397
+ scope: 'connections:import',
398
+ handler: async (c) => {
399
+ const e = c.engine;
400
+ const b = c.body;
401
+ if (!Array.isArray(b.connections) || b.connections.length === 0 || b.connections.length > IMPORT_MAX_ROWS)
402
+ throw bad(`connections must be 1 to ${IMPORT_MAX_ROWS} rows.`, 'connections');
403
+ for (const k of ['validate', 'dry_run', 'verify', 'exchange_expiring'])
404
+ if (b[k] !== undefined && typeof b[k] !== 'boolean')
405
+ throw bad(`${k} must be a boolean.`, k);
406
+ for (const k of ['dry_run', 'verify']) {
407
+ const qv = c.query.get(k);
408
+ if (qv !== null && qv !== 'true' && qv !== 'false')
409
+ throw bad(`${k} must be true or false.`, k);
410
+ }
411
+ const validate = b.validate !== false;
412
+ const exchange = b.exchange_expiring === true;
413
+ const dryRun = b.dry_run === true || c.query.get('dry_run') === 'true';
414
+ const table = () => {
415
+ let t = e.generic.get('connections');
416
+ if (!t)
417
+ e.generic.set('connections', (t = new store_1.Table('connections', e.clock.now, 'connection')));
418
+ return t;
419
+ };
420
+ const seen = new Map();
421
+ const leaders = new Map();
422
+ const out = [];
423
+ for (const [index, raw] of b.connections.entries()) {
424
+ try {
425
+ const p = parseRow(raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {});
426
+ const key = [p.owner.type, p.owner.id, p.provider, p.environment, p.externalId].join('|');
427
+ if (seen.has(key))
428
+ throw new RowError('duplicate_in_batch', `The same account appears in row ${seen.get(key)}.`, 'external_id');
429
+ seen.set(key, index);
430
+ if (p.owner.type === 'organization' && !e.db.organizations.get(p.owner.id))
431
+ throw new RowError('owner_not_found', 'Unknown organization.', 'owner');
432
+ if (p.owner.type === 'user' && !e.db.users.get(p.owner.id))
433
+ throw new RowError('owner_not_found', 'Unknown user.', 'owner');
434
+ // Rows of one token family share the leader's grant; a follower needs a leader that imported.
435
+ const famKey = p.tokenFamily ? [p.owner.type, p.owner.id, p.provider, p.environment, p.tokenFamily].join('|') : undefined;
436
+ const leader = famKey ? leaders.get(famKey) : undefined;
437
+ if (famKey && leader === undefined)
438
+ leaders.set(famKey, index);
439
+ if (leader !== undefined && out[leader]?.status === 'failed')
440
+ throw new RowError('family_leader_failed', `The row sharing this token set (row ${leader}) did not import.`, 'token_family');
441
+ const all = table().all();
442
+ const prior = all.find((x) => x.owner?.type === p.owner.type && x.owner?.id === p.owner.id && x.provider === p.provider && (x.environment ?? 'production') === p.environment && x.external_id === p.externalId);
443
+ const taken = all.find((x) => x.provider === p.provider && (x.environment ?? 'production') === p.environment && x.external_id === p.externalId && !(x.owner?.type === p.owner.type && x.owner?.id === p.owner.id));
444
+ if (taken)
445
+ throw new RowError('external_id_taken', `That provider account is already connected under another owner (connection ${taken.id}).`, 'external_id');
446
+ // shortcut: the emulator holds no provider clients, so a refresh token is never spent at a provider: validated and refreshed stay false and a dry run reports ok_unverified.
447
+ const base = { index, validated: false, refreshed: false, error: null };
448
+ if (dryRun) {
449
+ out.push({ ...base, status: prior ? 'would_update' : 'would_create', connection_id: prior?.id ?? null, check: 'ok_unverified' });
450
+ continue;
451
+ }
452
+ let tokens = { access: p.accessToken, refresh: p.refreshToken, expiresAt: p.expiresAt, expiring: false };
453
+ let exchanged;
454
+ let exchangeError;
455
+ // A non-expiring Shopify offline token (an access token and no refresh token) is swapped for an expiring pair. Opt in; a refusal keeps the original token.
456
+ if (exchange && p.kind === 'oauth' && p.provider === 'shopify' && tokens.access && !tokens.refresh) {
457
+ const shop = String(p.hostParams.shop ?? '');
458
+ const host = shop.includes('.') ? shop : `${shop}.myshopify.com`;
459
+ if (!shop || !/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i.test(host) || !e.options.fetch) {
460
+ exchanged = false;
461
+ exchangeError = 'exchange_unavailable';
462
+ }
463
+ else {
464
+ try {
465
+ const res = await e.options.fetch(`https://${host}/admin/oauth/access_token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', expiring: 1 }) });
466
+ const json = await res.json().catch(() => null);
467
+ if (res.ok && json && typeof json.access_token === 'string') {
468
+ tokens = { access: json.access_token, refresh: typeof json.refresh_token === 'string' ? json.refresh_token : undefined, expiresAt: e.clock.now() + (Number(json.expires_in) || 3600) * 1000, expiring: true };
469
+ exchanged = true;
470
+ }
471
+ else {
472
+ exchanged = false;
473
+ exchangeError = typeof json?.error === 'string' ? json.error : 'exchange_unavailable';
474
+ }
475
+ }
476
+ catch {
477
+ exchanged = false;
478
+ exchangeError = 'exchange_unavailable';
479
+ }
480
+ }
481
+ }
482
+ const now = e.clock.now();
483
+ let row = prior;
484
+ if (!row) {
485
+ const made = await e.handle(new Request(`${e.issuer}/v1/connections`, { method: 'POST', headers: { authorization: c.request.headers.get('authorization') ?? '', 'content-type': 'application/json' }, body: JSON.stringify({ owner: { type: p.owner.type, id: p.owner.id }, kind: p.kind, provider: p.provider, name: p.name, allowed_hosts: p.allowedHosts, config: p.configScopes ? { scopes: p.configScopes } : {}, environment: p.environment, host_params: p.hostParams }) }));
486
+ const created = await made.json().catch(() => null);
487
+ if (made.status !== 201 || !created?.id)
488
+ throw new RowError('invalid', created?.errors?.[0]?.message ?? 'Could not import this row.', created?.errors?.[0]?.param);
489
+ row = table().get(created.id);
490
+ }
491
+ else {
492
+ if (p.nameGiven)
493
+ row.name = p.name;
494
+ if (p.configScopes)
495
+ row.config = { ...(row.config ?? {}), scopes: p.configScopes };
496
+ row.host_params = p.hostParams;
497
+ }
498
+ Object.assign(row, {
499
+ external_id: p.externalId,
500
+ metadata: { ...(prior?.metadata ?? {}), ...p.metadata },
501
+ status: 'connected',
502
+ connected_at: row.connected_at ?? now,
503
+ updated_at: now,
504
+ ...(p.kind === 'oauth' ? { scopes: p.scopes ?? row.scopes ?? [], expires_at: tokens.access ? tokens.expiresAt : 0, has_refresh_token: tokens.refresh !== undefined || (leader !== undefined && table().get(String(out[leader]?.connection_id))?.has_refresh_token === true), expiring: tokens.expiring, refresh_token_expires_at: p.refreshExpiresAt ?? null, ...(p.tokenFamily ? { token_family: p.tokenFamily } : {}) } : { has_secret: true }),
505
+ });
506
+ out.push({ ...base, status: prior ? 'updated' : 'created', connection_id: row.id, ...(exchanged !== undefined ? { exchanged, ...(exchangeError ? { exchange_error: exchangeError } : {}) } : {}) });
507
+ }
508
+ catch (err) {
509
+ out.push(failedRow(index, err));
510
+ }
511
+ }
512
+ if (dryRun)
513
+ for (const r of out)
514
+ r.check ??= r.error?.code === 'refresh_token_rejected' ? 'refresh_refused' : r.error?.code === 'provider_unavailable' ? 'provider_unavailable' : r.error ? 'invalid_row' : 'ok_unverified';
515
+ const count = (x) => out.filter((r) => r.status === x).length;
516
+ return { body: { object: 'connection_import', total: out.length, dry_run: dryRun, validated: validate, created: count('created'), updated: count('updated'), would_create: count('would_create'), would_update: count('would_update'), failed: count('failed'), data: out } };
517
+ },
518
+ },
519
+ // ── Shopify: non-expiring offline token to an expiring one ──
520
+ {
521
+ method: 'POST',
522
+ pattern: '/v1/connections/:id/token/exchange',
523
+ scope: 'connections:token',
524
+ handler: async (c) => {
525
+ const e = c.engine;
526
+ const conn = connOf(e, c.params.id);
527
+ if (conn.provider !== 'shopify')
528
+ throw bad('Only a Shopify connection has a token to exchange.', 'provider');
529
+ if (conn.status === 'revoked')
530
+ throw new errors_1.EmulatorError('CONFLICT', 'This connection is revoked.');
531
+ if (conn.expiring === true)
532
+ throw new errors_1.EmulatorError('CONFLICT', 'This connection already holds an expiring token.');
533
+ const shop = String(conn.host_params?.shop ?? conn.config?.shop ?? (conn.allowed_hosts ?? [])[0] ?? '');
534
+ const host = shop.includes('.') ? shop : `${shop}.myshopify.com`;
535
+ // The app secret goes only to the shop's own host.
536
+ if (!/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i.test(host))
537
+ throw bad('The exchange only runs against a *.myshopify.com shop.', 'host');
538
+ if (!e.options.fetch)
539
+ throw new errors_1.EmulatorError('OAUTH_EXCHANGE_FAILED', 'The emulator has no outbound fetch: pass the `fetch` option to play the Shopify token endpoint.');
540
+ let res;
541
+ try {
542
+ res = await e.options.fetch(`https://${host}/admin/oauth/access_token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', expiring: 1 }) });
543
+ }
544
+ catch {
545
+ throw new errors_1.EmulatorError('OAUTH_EXCHANGE_FAILED', 'The shop could not be reached; the token is unchanged.');
546
+ }
547
+ const json = res.ok ? await res.json().catch(() => null) : null;
548
+ if (!json || typeof json.access_token !== 'string') {
549
+ record(e, conn.id, 'token_exchange', 'error', { host, status: res.status });
550
+ throw new errors_1.EmulatorError('OAUTH_EXCHANGE_FAILED', 'Shopify refused the exchange; the token is unchanged.', undefined, { meta: { provider_status: res.status } });
551
+ }
552
+ conn.expiring = true;
553
+ conn.has_refresh_token = typeof json.refresh_token === 'string';
554
+ conn.updated_at = e.clock.now();
555
+ record(e, conn.id, 'token_exchange', 'ok', { host, status: res.status });
556
+ return { body: { object: 'connection_token_exchange', connection_id: conn.id, exchanged: true, expiring: true, has_refresh_token: conn.has_refresh_token, expires_in: Number(json.expires_in) || null } };
557
+ },
558
+ },
559
+ ];
560
+ }
561
+ //# sourceMappingURL=connections.js.map