@owlmeans/viable-common 0.0.45 → 0.0.47

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 (180) hide show
  1. package/README.md +163 -13
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-presentation/SKILL.md +1 -1
  4. package/agent-meta/skills/viable-common/SKILL.md +132 -23
  5. package/build/connect/account/types.d.ts +81 -0
  6. package/build/connect/account/types.d.ts.map +1 -0
  7. package/build/connect/account/types.js +2 -0
  8. package/build/connect/account/types.js.map +1 -0
  9. package/build/connect/branding/types.d.ts +26 -4
  10. package/build/connect/branding/types.d.ts.map +1 -1
  11. package/build/connect/call/types.d.ts +28 -0
  12. package/build/connect/call/types.d.ts.map +1 -0
  13. package/build/connect/call/types.js +2 -0
  14. package/build/connect/call/types.js.map +1 -0
  15. package/build/connect/config/types.d.ts +45 -0
  16. package/build/connect/config/types.d.ts.map +1 -0
  17. package/build/connect/config/types.js +2 -0
  18. package/build/connect/config/types.js.map +1 -0
  19. package/build/connect/consts.d.ts +383 -1
  20. package/build/connect/consts.d.ts.map +1 -1
  21. package/build/connect/consts.js +387 -1
  22. package/build/connect/consts.js.map +1 -1
  23. package/build/connect/conversion/types.d.ts +24 -0
  24. package/build/connect/conversion/types.d.ts.map +1 -1
  25. package/build/connect/entrypoints/types.d.ts +22 -13
  26. package/build/connect/entrypoints/types.d.ts.map +1 -1
  27. package/build/connect/entrypoints.d.ts +441 -8
  28. package/build/connect/entrypoints.d.ts.map +1 -1
  29. package/build/connect/entrypoints.js +292 -9
  30. package/build/connect/entrypoints.js.map +1 -1
  31. package/build/connect/errors.d.ts +12 -0
  32. package/build/connect/errors.d.ts.map +1 -1
  33. package/build/connect/errors.js +16 -0
  34. package/build/connect/errors.js.map +1 -1
  35. package/build/connect/feed/types.d.ts +71 -0
  36. package/build/connect/feed/types.d.ts.map +1 -0
  37. package/build/connect/feed/types.js +2 -0
  38. package/build/connect/feed/types.js.map +1 -0
  39. package/build/connect/files/types.d.ts +34 -0
  40. package/build/connect/files/types.d.ts.map +1 -0
  41. package/build/connect/files/types.js +2 -0
  42. package/build/connect/files/types.js.map +1 -0
  43. package/build/connect/git/types.d.ts +170 -0
  44. package/build/connect/git/types.d.ts.map +1 -0
  45. package/build/connect/git/types.js +14 -0
  46. package/build/connect/git/types.js.map +1 -0
  47. package/build/connect/iam/types.d.ts +25 -0
  48. package/build/connect/iam/types.d.ts.map +1 -0
  49. package/build/connect/iam/types.js +8 -0
  50. package/build/connect/iam/types.js.map +1 -0
  51. package/build/connect/index.d.ts +9 -0
  52. package/build/connect/index.d.ts.map +1 -1
  53. package/build/connect/ops.d.ts +189 -0
  54. package/build/connect/ops.d.ts.map +1 -0
  55. package/build/connect/ops.js +2 -0
  56. package/build/connect/ops.js.map +1 -0
  57. package/build/connect/production/types.d.ts +65 -0
  58. package/build/connect/production/types.d.ts.map +1 -0
  59. package/build/connect/production/types.js +2 -0
  60. package/build/connect/production/types.js.map +1 -0
  61. package/build/connect/project/types.d.ts +23 -16
  62. package/build/connect/project/types.d.ts.map +1 -1
  63. package/build/connect/references/types.d.ts +424 -2
  64. package/build/connect/references/types.d.ts.map +1 -1
  65. package/build/connect/references.d.ts.map +1 -1
  66. package/build/connect/references.js +122 -0
  67. package/build/connect/references.js.map +1 -1
  68. package/build/connect/schemas.d.ts +65 -2
  69. package/build/connect/schemas.d.ts.map +1 -1
  70. package/build/connect/schemas.js +306 -1
  71. package/build/connect/schemas.js.map +1 -1
  72. package/build/connect/settings/types.d.ts +0 -25
  73. package/build/connect/settings/types.d.ts.map +1 -1
  74. package/build/connect/slot/types.d.ts +24 -0
  75. package/build/connect/slot/types.d.ts.map +1 -0
  76. package/build/connect/slot/types.js +2 -0
  77. package/build/connect/slot/types.js.map +1 -0
  78. package/build/connect/types.d.ts +453 -0
  79. package/build/connect/types.d.ts.map +1 -0
  80. package/build/connect/types.js +2 -0
  81. package/build/connect/types.js.map +1 -0
  82. package/build/convert/record/types.d.ts +0 -9
  83. package/build/convert/record/types.d.ts.map +1 -1
  84. package/build/convert/schemas.d.ts +2 -5
  85. package/build/convert/schemas.d.ts.map +1 -1
  86. package/build/convert/schemas.js +2 -15
  87. package/build/convert/schemas.js.map +1 -1
  88. package/build/iam-console/consts.d.ts +13 -0
  89. package/build/iam-console/consts.d.ts.map +1 -0
  90. package/build/iam-console/consts.js +13 -0
  91. package/build/iam-console/consts.js.map +1 -0
  92. package/build/iam-console/consts.local.d.ts +123 -0
  93. package/build/iam-console/consts.local.d.ts.map +1 -0
  94. package/build/iam-console/consts.local.js +50 -0
  95. package/build/iam-console/consts.local.js.map +1 -0
  96. package/build/iam-console/index.d.ts +4 -0
  97. package/build/iam-console/index.d.ts.map +1 -0
  98. package/build/iam-console/index.js +3 -0
  99. package/build/iam-console/index.js.map +1 -0
  100. package/build/iam-console/schemas.d.ts +38 -0
  101. package/build/iam-console/schemas.d.ts.map +1 -0
  102. package/build/iam-console/schemas.js +217 -0
  103. package/build/iam-console/schemas.js.map +1 -0
  104. package/build/iam-console/types.d.ts +140 -0
  105. package/build/iam-console/types.d.ts.map +1 -0
  106. package/build/iam-console/types.js +2 -0
  107. package/build/iam-console/types.js.map +1 -0
  108. package/build/index.d.ts +2 -0
  109. package/build/index.d.ts.map +1 -1
  110. package/build/index.js +2 -0
  111. package/build/index.js.map +1 -1
  112. package/build/intent/flow.d.ts +17 -0
  113. package/build/intent/flow.d.ts.map +1 -0
  114. package/build/intent/flow.js +65 -0
  115. package/build/intent/flow.js.map +1 -0
  116. package/build/legal/consts.d.ts +67 -0
  117. package/build/legal/consts.d.ts.map +1 -0
  118. package/build/legal/consts.js +24 -0
  119. package/build/legal/consts.js.map +1 -0
  120. package/build/legal/index.d.ts +3 -0
  121. package/build/legal/index.d.ts.map +1 -0
  122. package/build/legal/index.js +2 -0
  123. package/build/legal/index.js.map +1 -0
  124. package/build/legal/types.d.ts +17 -0
  125. package/build/legal/types.d.ts.map +1 -0
  126. package/build/legal/types.js +2 -0
  127. package/build/legal/types.js.map +1 -0
  128. package/build/planning/flows.d.ts +34 -0
  129. package/build/planning/flows.d.ts.map +1 -0
  130. package/build/planning/flows.js +174 -0
  131. package/build/planning/flows.js.map +1 -0
  132. package/build/planning/schemas.d.ts.map +1 -1
  133. package/build/planning/schemas.js +0 -1
  134. package/build/planning/schemas.js.map +1 -1
  135. package/build/planning/types.d.ts +0 -2
  136. package/build/planning/types.d.ts.map +1 -1
  137. package/build/skills/roles.d.ts +43 -0
  138. package/build/skills/roles.d.ts.map +1 -0
  139. package/build/skills/roles.js +44 -0
  140. package/build/skills/roles.js.map +1 -0
  141. package/package.json +19 -3
  142. package/src/connect/account/types.ts +91 -0
  143. package/src/connect/branding/types.ts +28 -4
  144. package/src/connect/call/types.ts +31 -0
  145. package/src/connect/config/types.ts +51 -0
  146. package/src/connect/consts.ts +400 -1
  147. package/src/connect/conversion/types.ts +25 -0
  148. package/src/connect/entrypoints/types.ts +22 -7
  149. package/src/connect/entrypoints.ts +592 -15
  150. package/src/connect/errors.ts +18 -0
  151. package/src/connect/feed/types.ts +76 -0
  152. package/src/connect/files/types.ts +38 -0
  153. package/src/connect/git/types.ts +187 -0
  154. package/src/connect/iam/types.ts +28 -0
  155. package/src/connect/index.ts +9 -0
  156. package/src/connect/production/types.ts +71 -0
  157. package/src/connect/project/types.ts +25 -12
  158. package/src/connect/references/types.ts +166 -5
  159. package/src/connect/references.ts +210 -5
  160. package/src/connect/schemas.ts +366 -5
  161. package/src/connect/settings/types.ts +0 -25
  162. package/src/connect/slot/types.ts +24 -0
  163. package/src/convert/record/types.ts +0 -11
  164. package/src/convert/schemas.ts +3 -19
  165. package/src/iam-console/consts.local.ts +64 -0
  166. package/src/iam-console/consts.ts +15 -0
  167. package/src/iam-console/index.ts +3 -0
  168. package/src/iam-console/schemas.ts +251 -0
  169. package/src/iam-console/types.ts +165 -0
  170. package/src/index.ts +2 -0
  171. package/src/legal/consts.ts +25 -0
  172. package/src/legal/index.ts +2 -0
  173. package/src/legal/types.ts +22 -0
  174. package/src/planning/schemas.ts +0 -1
  175. package/src/planning/types.ts +0 -2
  176. package/tests/connect-convert.spec.ts +6 -2
  177. package/tests/connect-entrypoints.spec.ts +554 -16
  178. package/tests/error-status.spec.ts +2 -1
  179. package/tests/iam-console.spec.ts +89 -0
  180. package/tests/planning.spec.ts +8 -0
@@ -3,10 +3,21 @@ import Ajv from 'ajv'
3
3
  import { aliasOf, protocols } from '@owlmeans/entrypoint'
4
4
  import { DEFAULT_GUARD } from '@owlmeans/auth-common'
5
5
  import { RouteMethod, RouteProtocols } from '@owlmeans/route'
6
- import { connect, CONNECT_BRANDING_GOOGLE_TAG_MAX } from '../src/connect/consts.js'
7
6
  import {
8
- ConnectKitApplyBodySchema, ConnectKitApplyResultSchema, ConnectKitDescribeSchema, ConnectProjectBrandingSaveSchema,
9
- PlanningKitViewSchema,
7
+ connect, CONNECT_BRANDING_GOOGLE_TAG_MAX, CONNECT_CALL_ACCEPT_MS, CONNECT_CALL_COLLECT_WAIT_SEC,
8
+ CONNECT_CALL_HEADER, CONNECT_CALL_LOST_MS, CONNECT_CONFIG_SAVE_MAX, CONNECT_FILE_PATH_MAX, CONNECT_PRIVACY_KEYS_MAX,
9
+ CONNECT_REDIRECTS_MAX, CONNECT_TOKEN_ID_MAX, ConnectCallState, ConnectPaidGate,
10
+ CONNECT_FEED_LIMIT_MAX, CONNECT_FEED_START, CONNECT_FEED_WAIT_MAX_SEC, ConnectFeedDetail, ConnectFeedKind,
11
+ } from '../src/connect/consts.js'
12
+ import {
13
+ ConnectAccessTokenParamsSchema, ConnectIntentPickupBodySchema, ConnectLlmBodySchema, ConnectPrivacyWithdrawBodySchema,
14
+ ConnectProjectLlmBodySchema, ConnectBrandingCreditBodySchema, ConnectCallCollectParamsSchema, ConnectCallCollectQuerySchema, ConnectConfigSaveBodySchema,
15
+ ConnectOrganizationBrandingSaveSchema, ConnectScopeQuerySchema, ConnectCallPendingSchema, ConnectCallResultSchema,
16
+ ConnectConvertProceedBodySchema, ConnectCreateBodySchema, ConnectFileMetaQuerySchema, ConnectFileQuerySchema,
17
+ ConnectFileSaveBodySchema, ConnectGitCommitBodySchema, ConnectGitRevertBodySchema, ConnectGithubBranchQuerySchema,
18
+ ConnectGithubLinkBodySchema, ConnectGithubPublishBodySchema, ConnectGithubRepoQuerySchema, ConnectKitApplyBodySchema,
19
+ ConnectProductionDomainBodySchema, ConnectProductionRedirectsBodySchema, ConnectKitApplyResultSchema, ConnectKitDescribeSchema, ConnectProjectBrandingSaveSchema,
20
+ PlanningKitViewSchema, ConnectActivityQuerySchema, ConnectFeedQuerySchema,
10
21
  } from '../src/connect/schemas.js'
11
22
  import { connectProtocols } from '../src/connect/entrypoints.js'
12
23
  import { connectRef } from '../src/connect/references.js'
@@ -21,8 +32,27 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
21
32
  expect(tree.convert.proceed.alias).toBe(connect.convert.proceed)
22
33
  expect(tree.inquiry.answer.alias).toBe(connect.inquiry.answer)
23
34
  expect(tree.files.list.alias).toBe(connect.files.list)
24
- expect(protocols(tree)).toHaveLength(28)
25
- expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(28)
35
+ expect(tree.account.base.alias).toBe(connect.account.base)
36
+ expect(tree.project.destroy.alias).toBe(connect.project.destroy)
37
+ expect(tree.project.unlock.alias).toBe(connect.project.unlock)
38
+ expect(tree.files.save.alias).toBe(connect.files.save)
39
+ expect(tree.sandbox.run.alias).toBe(connect.sandbox.run)
40
+ expect(tree.slot.list.alias).toBe(connect.slot.list)
41
+ expect(tree.config.get.alias).toBe(connect.config.get)
42
+ expect(tree.account.branding.save.alias).toBe(connect.account.branding.save)
43
+ expect(tree.account.llm.set.alias).toBe(connect.account.llm.set)
44
+ expect(tree.account.tokens.revoke.alias).toBe(connect.account.tokens.revoke)
45
+ expect(tree.account.privacy.withdraw.alias).toBe(connect.account.privacy.withdraw)
46
+ expect(tree.account.intent.pickup.alias).toBe(connect.account.intent.pickup)
47
+ expect(tree.project.llm.set.alias).toBe(connect.project.llm.set)
48
+ expect(tree.git.status.alias).toBe(connect.git.status)
49
+ expect(tree.github.authorize.alias).toBe(connect.github.authorize)
50
+ expect(tree.production.publish.alias).toBe(connect.production.publish)
51
+ expect(tree.production.auth.get.alias).toBe(connect.production.auth.get)
52
+ expect(tree.iam.grants.assign.alias).toBe(connect.iam.grants.assign)
53
+ expect(tree.account.iam.users.alias).toBe(connect.account.iam.users)
54
+ expect(protocols(tree)).toHaveLength(107)
55
+ expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(107)
26
56
  })
27
57
 
28
58
  test('declares exactly the routes a connector calls — every alias, reference and path, and no socket', () => {
@@ -31,21 +61,56 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
31
61
  .map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`).sort()
32
62
 
33
63
  expect(addresses).toEqual([
34
- 'GET /convert/:id', 'GET /convert/:id/check', 'GET /pipeline/:id/:runId', 'GET /project',
35
- 'GET /project/:id/branding', 'GET /project/:id/files', 'GET /project/:id/kits', 'GET /project/:id/status',
36
- 'GET /project/:id/story/:storyId/status', 'GET /session/:sessionId/ops',
64
+ 'DELETE /project/:id', 'DELETE /project/:id/files/content',
65
+ 'GET /access-tokens', 'GET /branding', 'GET /call/:callId', 'GET /convert/:id', 'GET /convert/:id/check',
66
+ 'GET /iam/users', 'GET /llm', 'GET /notifications', 'GET /pipeline/:id/:runId',
67
+ 'GET /privacy', 'GET /project', 'GET /project/:id/activity',
68
+ 'GET /project/:id/branding', 'GET /project/:id/config', 'GET /project/:id/files', 'GET /project/:id/files/changes',
69
+ 'GET /project/:id/files/content',
70
+ 'GET /project/:id/files/meta', 'GET /project/:id/git', 'GET /project/:id/git/log', 'GET /project/:id/github/branches',
71
+ 'GET /project/:id/github/repos', 'GET /project/:id/iam/grants', 'GET /project/:id/iam/organizations',
72
+ 'GET /project/:id/iam/organizations/:entitySlug/groups', 'GET /project/:id/iam/organizations/:entitySlug/groups/:group/members',
73
+ 'GET /project/:id/iam/organizations/:entitySlug/members', 'GET /project/:id/iam/permissions', 'GET /project/:id/iam/users',
74
+ 'GET /project/:id/kits', 'GET /project/:id/llm', 'GET /project/:id/production',
75
+ 'GET /project/:id/production/auth', 'GET /project/:id/status',
76
+ 'GET /project/:id/story/:storyId/status', 'GET /session/:sessionId/ops', 'GET /slot',
77
+ 'POST /access-tokens/:id/revoke', 'POST /branding', 'POST /branding/backfill',
37
78
  'POST /convert', 'POST /convert/:id/proceed', 'POST /convert/:id/purge', 'POST /convert/:id/start',
38
- 'POST /pipeline/:id/:runId/resume', 'POST /project', 'POST /project/:id/branding',
39
- 'POST /project/:id/confirm', 'POST /project/:id/inquiry/:inquiryId', 'POST /project/:id/kits', 'POST /project/:id/modify',
40
- 'POST /project/:id/reinit', 'POST /project/attach', 'POST /session', 'POST /session/:sessionId/close',
41
- 'POST /session/:sessionId/ops/:opId', 'POST /session/delegated',
79
+ 'POST /intent/pickup', 'POST /llm',
80
+ 'POST /pipeline/:id/:runId/resume', 'POST /privacy/withdraw', 'POST /project', 'POST /project/:id/branding',
81
+ 'POST /project/:id/branding/copy-defaults', 'POST /project/:id/branding/credit',
82
+ 'POST /project/:id/config', 'POST /project/:id/config/recollect', 'POST /project/:id/confirm', 'POST /project/:id/files/content',
83
+ 'POST /project/:id/git/commit', 'POST /project/:id/git/discard', 'POST /project/:id/git/revert',
84
+ 'POST /project/:id/github/authorize', 'POST /project/:id/github/disconnect', 'POST /project/:id/github/link',
85
+ 'POST /project/:id/github/publish', 'POST /project/:id/github/pull', 'POST /project/:id/github/push',
86
+ 'POST /project/:id/iam/grants', 'POST /project/:id/iam/grants/revoke',
87
+ 'POST /project/:id/iam/organizations/:entitySlug', 'POST /project/:id/iam/organizations/:entitySlug/groups',
88
+ 'POST /project/:id/iam/organizations/:entitySlug/groups/:group',
89
+ 'POST /project/:id/iam/organizations/:entitySlug/groups/:group/members',
90
+ 'POST /project/:id/iam/organizations/:entitySlug/groups/:group/members/remove',
91
+ 'POST /project/:id/iam/organizations/:entitySlug/groups/:group/remove',
92
+ 'POST /project/:id/iam/organizations/:entitySlug/members',
93
+ 'POST /project/:id/iam/organizations/:entitySlug/members/:profileId',
94
+ 'POST /project/:id/iam/organizations/:entitySlug/members/:profileId/remove',
95
+ 'POST /project/:id/iam/permissions/default', 'POST /project/:id/iam/users', 'POST /project/:id/iam/users/:profileId',
96
+ 'POST /project/:id/iam/users/:profileId/remove',
97
+ 'POST /project/:id/inquiry/:inquiryId',
98
+ 'POST /project/:id/kits', 'POST /project/:id/llm', 'POST /project/:id/modify',
99
+ 'POST /project/:id/production/auth/redirects', 'POST /project/:id/production/domain/attach',
100
+ 'POST /project/:id/production/domain/detach', 'POST /project/:id/production/domain/verify',
101
+ 'POST /project/:id/production/publish', 'POST /project/:id/production/restart', 'POST /project/:id/production/stop',
102
+ 'POST /project/:id/reinit', 'POST /project/:id/rename',
103
+ 'POST /project/:id/sandbox/rebuild', 'POST /project/:id/sandbox/restart', 'POST /project/:id/sandbox/run',
104
+ 'POST /project/:id/sandbox/stop', 'POST /project/:id/unlock', 'POST /project/attach', 'POST /session',
105
+ 'POST /session/:sessionId/close', 'POST /session/:sessionId/ops/:opId', 'POST /session/delegated',
42
106
  ])
43
107
  expect(declared.every(protocol => protocol.route.route.protocol !== RouteProtocols.SOCKET)).toBe(true)
44
108
  // The alias table, the tree and the typed references name the same routes.
45
109
  const leaves = (value: object): string[] => Object.values(value).flatMap(entry =>
46
110
  typeof entry === 'string' ? [entry] : 'alias' in entry ? [entry.alias as string] : leaves(entry))
47
111
  expect(leaves(connect).sort()).toEqual(declared.map(protocol => protocol.alias).sort())
48
- expect(leaves(connectRef).sort()).toEqual(declared.map(protocol => protocol.alias).filter(alias => alias !== connect.base).sort())
112
+ const bases = [connect.base, connect.account.base]
113
+ expect(leaves(connectRef).sort()).toEqual(declared.map(protocol => protocol.alias).filter(alias => !bases.includes(alias)).sort())
49
114
  })
50
115
 
51
116
  test('reads and saves a project\'s branding at one path, under the owned base and no paid gate', () => {
@@ -65,17 +130,92 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
65
130
  expect(save.route.route.method).toBe(RouteMethod.POST)
66
131
  expect(aliasOf(get.route.route.parent)).toBe(connect.base)
67
132
  expect(aliasOf(save.route.route.parent)).toBe(connect.base)
68
- // The paid local-LLM gate sits on the delegated session alone; branding carries no gate of its
69
- // own and inherits only the base's guard and ownership gate.
133
+ // The paid local-LLM gate sits on the delegated session and the two preference writes alone; the
134
+ // text settings carry no gate of their own and inherit only the base's guard and ownership gate.
70
135
  expect(paid.session.openDelegated.gate?.alias).toBe('test-paid-llm')
71
136
  expect(protocols(paid).filter(protocol => protocol.gate?.alias === 'test-paid-llm'))
72
- .toEqual([paid.session.openDelegated])
137
+ .toEqual([paid.account.llm.set, paid.session.openDelegated, paid.project.llm.set])
73
138
  expect(get.gate).toBeUndefined()
74
139
  expect(save.gate).toBeUndefined()
75
140
  expect(connectRef.project.branding.save.alias).toBe(connect.project.branding.save)
76
141
  expect(connectRef.project.branding.get.alias).toBe(connect.project.branding.get)
77
142
  })
78
143
 
144
+ test('the credit switch and the organization defaults copy hang under the project\'s branding path', () => {
145
+ const { credit, copyDefaults } = tree.project.branding
146
+
147
+ expect([credit.route.route.path, copyDefaults.route.route.path])
148
+ .toEqual(['/project/:id/branding/credit', '/project/:id/branding/copy-defaults'])
149
+ expect([credit.route.route.method, copyDefaults.route.route.method]).toEqual([RouteMethod.POST, RouteMethod.POST])
150
+ expect([aliasOf(credit.route.route.parent), aliasOf(copyDefaults.route.route.parent)]).toEqual([connect.base, connect.base])
151
+ expect([connectRef.project.branding.credit.alias, connectRef.project.branding.copyDefaults.alias])
152
+ .toEqual([connect.project.branding.credit, connect.project.branding.copyDefaults])
153
+ const body = new Ajv({ strict: false }).compile(ConnectBrandingCreditBodySchema)
154
+ expect(body({ hideCredit: true })).toBe(true)
155
+ expect(body({})).toBe(false)
156
+ expect(body({ hideCredit: 'yes' })).toBe(false)
157
+ })
158
+
159
+ test('the configuration routes sit under the owned base, no paid gate; a scope is preview or production', () => {
160
+ const paid = connectProtocols({
161
+ guard: DEFAULT_GUARD,
162
+ gate: { alias: 'test-gate', params: ['owner'] },
163
+ paid: Object.fromEntries(Object.values(ConnectPaidGate).map(kind => [kind, { alias: `gate-${kind}`, params: [kind] }])),
164
+ })
165
+ const { get, save, recollect } = paid.config
166
+
167
+ expect([get, save, recollect].map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`))
168
+ .toEqual(['GET /project/:id/config', 'POST /project/:id/config', 'POST /project/:id/config/recollect'])
169
+ expect([get, save, recollect].map(protocol => aliasOf(protocol.route.route.parent))).toEqual([connect.base, connect.base, connect.base])
170
+ expect([get, save, recollect].map(protocol => protocol.gate)).toEqual([undefined, undefined, undefined])
171
+ expect(connectRef.config.save.alias).toBe(connect.config.save)
172
+
173
+ const scope = new Ajv({ strict: false }).compile(ConnectScopeQuerySchema)
174
+ expect(scope({})).toBe(true)
175
+ expect(scope({ scope: 'production' })).toBe(true)
176
+ expect(scope({ scope: 'ephemeral' })).toBe(true)
177
+ expect(scope({ scope: null })).toBe(true)
178
+ expect(scope({ scope: 'local' })).toBe(false)
179
+ })
180
+
181
+ test('a configuration save is a closed patch of environment variable names and values', () => {
182
+ const validate = new Ajv({ strict: false }).compile(ConnectConfigSaveBodySchema)
183
+
184
+ expect(validate({})).toBe(true)
185
+ expect(validate({ backend: [{ name: 'STRIPE_KEY', value: 'sk_test' }], frontend: [{ name: 'PUBLIC_URL', value: '' }] })).toBe(true)
186
+ expect(validate({ backend: [{ name: '1BAD', value: 'x' }] })).toBe(false)
187
+ expect(validate({ backend: [{ name: 'WITH-DASH', value: 'x' }] })).toBe(false)
188
+ expect(validate({ backend: [{ name: 'KEY' }] })).toBe(false)
189
+ expect(validate({ backend: [{ name: 'KEY', value: 'x', set: true }] })).toBe(false)
190
+ expect(validate({ secrets: [] })).toBe(false)
191
+ const many = Array.from({ length: CONNECT_CONFIG_SAVE_MAX + 1 }, (_, at) => ({ name: `K${at}`, value: '' }))
192
+ expect(validate({ frontend: many })).toBe(false)
193
+ })
194
+
195
+ test('the organization defaults are read, patched and backfilled under the account base', () => {
196
+ const { get, save, backfill } = tree.account.branding
197
+
198
+ expect([get, save, backfill].map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`))
199
+ .toEqual(['GET /branding', 'POST /branding', 'POST /branding/backfill'])
200
+ expect([get, save, backfill].map(protocol => aliasOf(protocol.route.route.parent)))
201
+ .toEqual([connect.account.base, connect.account.base, connect.account.base])
202
+ expect(connectRef.account.branding.backfill.alias).toBe(connect.account.branding.backfill)
203
+ const validate = new Ajv({ strict: false }).compile(ConnectOrganizationBrandingSaveSchema)
204
+ expect(validate({ organizationName: 'Acme' })).toBe(true)
205
+ expect(validate({ organizationName: '' })).toBe(false)
206
+ expect(validate({ copyright: '© Acme', termsUrl: '/terms' })).toBe(false)
207
+ })
208
+
209
+ test('a create names the caller\'s unattached delegated session, or none', () => {
210
+ const create = new Ajv({ strict: false }).compile(ConnectCreateBodySchema)
211
+
212
+ expect(create({ prompt: 'A booking app' })).toBe(true)
213
+ expect(create({ prompt: 'A booking app', target: 'local', sessionId: 'session-1' })).toBe(true)
214
+ expect(create({ prompt: 'A booking app', sessionId: null })).toBe(true)
215
+ expect(create({ prompt: 'A booking app', sessionId: '' })).toBe(false)
216
+ expect(create({ prompt: 'A booking app', llmMode: 'local' })).toBe(false)
217
+ })
218
+
79
219
  test('the branding save body is a patch of strings that can never carry the credit', () => {
80
220
  const validate = new Ajv({ strict: false }).compile(ConnectProjectBrandingSaveSchema)
81
221
 
@@ -83,6 +223,7 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
83
223
  expect(validate({ googleTag: 'GTM-ABCD123', termsUrl: '/terms', privacyUrl: '' })).toBe(true)
84
224
  expect(validate({ copyright: '' })).toBe(false)
85
225
  expect(validate({ hideCredit: true })).toBe(false)
226
+ expect(validate({ credit: { hidden: true } })).toBe(false)
86
227
  expect(validate({ googleTag: 'G-'.padEnd(CONNECT_BRANDING_GOOGLE_TAG_MAX + 1, 'X') })).toBe(false)
87
228
  })
88
229
 
@@ -141,4 +282,401 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
141
282
  expect(result({ applied: ['task'], skipped: ['bug'], warnings: [] })).toBe(true)
142
283
  expect(result({ applied: ['task'], skipped: [] })).toBe(false)
143
284
  })
285
+
286
+ test('collects a delegated write by its call id: one GET under the base guard and no paid gate', () => {
287
+ const paid = connectProtocols({
288
+ guard: DEFAULT_GUARD,
289
+ gate: { alias: 'test-gate', params: ['owner'] },
290
+ localLlm: { alias: 'test-paid-llm', params: ['cap'] },
291
+ })
292
+ const collect = paid.call.collect
293
+
294
+ expect(collect.alias).toBe(connect.call.collect)
295
+ expect(collect.route.route.path).toBe('/call/:callId')
296
+ expect(collect.route.route.method).toBe(RouteMethod.GET)
297
+ expect(aliasOf(collect.route.route.parent)).toBe(connect.base)
298
+ expect(collect.gate).toBeUndefined()
299
+ expect(connectRef.call.collect.alias).toBe(connect.call.collect)
300
+ // The timing contract the platform and the connector both read.
301
+ expect(CONNECT_CALL_HEADER).toBe('x-viable-call')
302
+ expect([CONNECT_CALL_ACCEPT_MS, CONNECT_CALL_COLLECT_WAIT_SEC, CONNECT_CALL_LOST_MS]).toEqual([20_000, 25, 90_000])
303
+ expect(Object.values(ConnectCallState)).toEqual(['pending', 'settled', 'lost'])
304
+ })
305
+
306
+ test('the pending answer, the collect params and query, and the result are closed shapes', () => {
307
+ const ajv = new Ajv({ strict: false })
308
+ const pending = ajv.compile(ConnectCallPendingSchema)
309
+ const params = ajv.compile(ConnectCallCollectParamsSchema)
310
+ const query = ajv.compile(ConnectCallCollectQuerySchema)
311
+ const result = ajv.compile(ConnectCallResultSchema)
312
+ const id = '0b6f6c1e-8d4c-4a57-9f2a-3c1d2e4f5a6b'
313
+
314
+ expect(pending({ pending: id })).toBe(true)
315
+ expect(pending({ pending: '' })).toBe(false)
316
+ expect(pending({ pending: id, value: 1 })).toBe(false)
317
+ expect(params({ callId: id })).toBe(true)
318
+ expect(params({})).toBe(false)
319
+ expect(query({})).toBe(true)
320
+ expect(query({ wait: CONNECT_CALL_COLLECT_WAIT_SEC })).toBe(true)
321
+ expect(query({ wait: null })).toBe(true)
322
+ expect(query({ wait: CONNECT_CALL_COLLECT_WAIT_SEC + 1 })).toBe(false)
323
+ expect(result({ state: 'pending' })).toBe(true)
324
+ // Any JSON as the value — an object, an array, a scalar, null.
325
+ for (const value of [{ project: { id: 'p1' } }, ['a'], 'text', 3, null]) {
326
+ expect(result({ state: 'settled', outcome: 'ok', value })).toBe(true)
327
+ }
328
+ expect(result({ state: 'settled', error: 'ViableConnectResilientError|||viable-connect:x|||' })).toBe(true)
329
+ expect(result({ state: 'lost' })).toBe(true)
330
+ expect(result({ state: 'done' })).toBe(false)
331
+ expect(result({ state: 'settled', extra: true })).toBe(false)
332
+ })
333
+
334
+ test('the account base is a root of its own, under the account gate, never a child of the project base', () => {
335
+ const gated = connectProtocols({
336
+ guard: DEFAULT_GUARD,
337
+ gate: { alias: 'test-gate', params: ['owner'] },
338
+ accountGate: { alias: 'test-gate', params: ['account'] },
339
+ })
340
+
341
+ expect(gated.account.base.alias).toBe(connect.account.base)
342
+ expect(gated.account.base.route.route.path).toBe('/connect/account')
343
+ expect(gated.account.base.route.route.parent).toBeUndefined()
344
+ expect(gated.account.base.route.route.method).toBeUndefined()
345
+ expect(gated.account.base.gate).toEqual({ alias: 'test-gate', params: ['account'] })
346
+ expect(gated.account.base.guards).toEqual([DEFAULT_GUARD])
347
+ expect(gated.base.gate).toEqual({ alias: 'test-gate', params: ['owner'] })
348
+ // Without an account gate the base still carries the guard.
349
+ expect(tree.account.base.gate).toBeUndefined()
350
+ expect(tree.account.base.guards).toEqual([DEFAULT_GUARD])
351
+ })
352
+
353
+ test('the paid map injects each gate by kind, and the deprecated local-LLM option still reads', () => {
354
+ const paid = Object.fromEntries(Object.values(ConnectPaidGate)
355
+ .map(kind => [kind, { alias: `gate-${kind}`, params: [`param:${kind}`] }]))
356
+ const mapped = connectProtocols({ guard: DEFAULT_GUARD, paid })
357
+
358
+ expect(Object.values(ConnectPaidGate)).toEqual([
359
+ 'local-llm', 'whitelabel', 'custom-domain', 'production-standalone', 'published-sites',
360
+ ])
361
+ expect(mapped.session.openDelegated.gate).toEqual({ alias: 'gate-local-llm', params: ['param:local-llm'] })
362
+ // Only the routes that spend something carry a payment gate.
363
+ expect(protocols(mapped).filter(protocol => protocol.gate?.alias.startsWith('gate-')).map(protocol => protocol.alias))
364
+ .toEqual([
365
+ connect.account.llm.set, connect.session.openDelegated, connect.project.llm.set, connect.project.branding.credit,
366
+ connect.production.publish, connect.production.domain.attach, connect.production.auth.get,
367
+ connect.production.auth.redirects,
368
+ ])
369
+ expect([mapped.account.llm.set.gate, mapped.project.llm.set.gate]).toEqual([
370
+ { alias: 'gate-local-llm', params: ['param:local-llm'] }, { alias: 'gate-local-llm', params: ['param:local-llm'] },
371
+ ])
372
+ expect(mapped.project.branding.credit.gate).toEqual({ alias: 'gate-whitelabel', params: ['param:whitelabel'] })
373
+ expect([
374
+ mapped.production.publish.gate, mapped.production.domain.attach.gate, mapped.production.auth.get.gate,
375
+ mapped.production.auth.redirects.gate,
376
+ ]).toEqual([
377
+ { alias: 'gate-published-sites', params: ['param:published-sites'] },
378
+ { alias: 'gate-custom-domain', params: ['param:custom-domain'] },
379
+ { alias: 'gate-production-standalone', params: ['param:production-standalone'] },
380
+ { alias: 'gate-production-standalone', params: ['param:production-standalone'] },
381
+ ])
382
+ // A deployment that names no white-label gate leaves the credit switch under ownership alone.
383
+ expect(connectProtocols({ guard: DEFAULT_GUARD }).project.branding.credit.gate).toBeUndefined()
384
+
385
+ const legacy = connectProtocols({ guard: DEFAULT_GUARD, localLlm: { alias: 'legacy', params: ['cap'] } })
386
+ expect(legacy.session.openDelegated.gate).toEqual({ alias: 'legacy', params: ['cap'] })
387
+ // The map wins over the deprecated option.
388
+ const both = connectProtocols({
389
+ guard: DEFAULT_GUARD, localLlm: { alias: 'legacy', params: ['cap'] },
390
+ paid: { [ConnectPaidGate.LocalLlm]: { alias: 'mapped', params: ['cap'] } },
391
+ })
392
+ expect(both.session.openDelegated.gate?.alias).toBe('mapped')
393
+ expect(connectProtocols({ guard: DEFAULT_GUARD }).session.openDelegated.gate).toBeUndefined()
394
+ })
395
+
396
+ test('deletes and unlocks a project under the owned base, no payment gate', () => {
397
+ const paid = connectProtocols({
398
+ guard: DEFAULT_GUARD,
399
+ gate: { alias: 'test-gate', params: ['owner'] },
400
+ localLlm: { alias: 'test-paid-llm', params: ['cap'] },
401
+ })
402
+ const { destroy, unlock } = paid.project
403
+
404
+ expect([destroy.route.route.path, unlock.route.route.path]).toEqual(['/project/:id', '/project/:id/unlock'])
405
+ expect([destroy.route.route.method, unlock.route.route.method]).toEqual([RouteMethod.DELETE, RouteMethod.POST])
406
+ expect([aliasOf(destroy.route.route.parent), aliasOf(unlock.route.route.parent)]).toEqual([connect.base, connect.base])
407
+ expect([destroy.gate, unlock.gate]).toEqual([undefined, undefined])
408
+ expect([connectRef.project.destroy.alias, connectRef.project.unlock.alias])
409
+ .toEqual([connect.project.destroy, connect.project.unlock])
410
+ })
411
+
412
+ test('a proceed carries the person\'s edits as an optional closed patch', () => {
413
+ const validate = new Ajv({ strict: false }).compile(ConnectConvertProceedBodySchema)
414
+
415
+ expect(validate({ decision: 'extract' })).toBe(true)
416
+ expect(validate({ decision: 'extract', update: { name: 'Shop', specification: '# Spec' } })).toBe(true)
417
+ expect(validate({ decision: 'extract', update: {} })).toBe(true)
418
+ expect(validate({ decision: 'extract', update: null })).toBe(true)
419
+ expect(validate({ decision: 'extract', update: { name: '' } })).toBe(false)
420
+ // The alias is not part of the patch: the preview host and the slot were composed from it.
421
+ expect(validate({ decision: 'extract', update: { alias: 'other' } })).toBe(false)
422
+ expect(validate({ decision: 'extract', update: { title: 'Shop' } })).toBe(false)
423
+ })
424
+
425
+ test('reads, writes and deletes one file at one path, lists metadata, and controls the preview — owned base, no paid gate', () => {
426
+ const paid = connectProtocols({
427
+ guard: DEFAULT_GUARD,
428
+ gate: { alias: 'test-gate', params: ['owner'] },
429
+ paid: Object.fromEntries(Object.values(ConnectPaidGate).map(kind => [kind, { alias: `gate-${kind}`, params: [kind] }])),
430
+ })
431
+ const { get, save, remove, meta } = paid.files
432
+ const { run, restart, stop, rebuild } = paid.sandbox
433
+ const leaves = [get, save, remove, meta, run, restart, stop, rebuild, paid.slot.list]
434
+
435
+ expect([get, save, remove].map(protocol => protocol.route.route.path))
436
+ .toEqual(['/project/:id/files/content', '/project/:id/files/content', '/project/:id/files/content'])
437
+ expect([get, save, remove, meta].map(protocol => protocol.route.route.method))
438
+ .toEqual([RouteMethod.GET, RouteMethod.POST, RouteMethod.DELETE, RouteMethod.GET])
439
+ expect(meta.route.route.path).toBe('/project/:id/files/meta')
440
+ expect([run, restart, stop, rebuild].map(protocol => protocol.route.route.path)).toEqual([
441
+ '/project/:id/sandbox/run', '/project/:id/sandbox/restart', '/project/:id/sandbox/stop', '/project/:id/sandbox/rebuild',
442
+ ])
443
+ expect(paid.slot.list.route.route.path).toBe('/slot')
444
+ expect(paid.slot.list.route.route.method).toBe(RouteMethod.GET)
445
+ expect(leaves.map(protocol => aliasOf(protocol.route.route.parent))).toEqual(leaves.map(() => connect.base))
446
+ expect(leaves.map(protocol => protocol.gate)).toEqual(leaves.map(() => undefined))
447
+ expect([connectRef.files.save.alias, connectRef.sandbox.rebuild.alias, connectRef.slot.list.alias])
448
+ .toEqual([connect.files.save, connect.sandbox.rebuild, connect.slot.list])
449
+ })
450
+
451
+ test('the file query, the write body and the metadata query are closed shapes', () => {
452
+ const ajv = new Ajv({ strict: false })
453
+ const query = ajv.compile(ConnectFileQuerySchema)
454
+ const body = ajv.compile(ConnectFileSaveBodySchema)
455
+ const meta = ajv.compile(ConnectFileMetaQuerySchema)
456
+
457
+ expect(query({ path: 'sources/web/src/app.tsx' })).toBe(true)
458
+ expect(query({ path: '' })).toBe(false)
459
+ expect(query({ path: 'x'.repeat(CONNECT_FILE_PATH_MAX + 1) })).toBe(false)
460
+ expect(query({})).toBe(false)
461
+ expect(body({ path: 'README.md', content: '' })).toBe(true)
462
+ expect(body({ path: 'README.md' })).toBe(false)
463
+ expect(body({ path: 'README.md', content: 'x', mode: 'append' })).toBe(false)
464
+ expect(meta({ kind: 'stories' })).toBe(true)
465
+ expect(meta({ kind: 'meta', category: 'ux' })).toBe(true)
466
+ expect(meta({ kind: 'meta', category: null })).toBe(true)
467
+ expect(meta({ kind: 'sources' })).toBe(false)
468
+ expect(meta({ kind: 'all', category: 'design' })).toBe(false)
469
+ })
470
+ test('the inference preference: the person\'s under the account base, the project\'s under the owned base — read free, set paid', () => {
471
+ const paid = connectProtocols({
472
+ guard: DEFAULT_GUARD,
473
+ gate: { alias: 'test-gate', params: ['owner'] },
474
+ accountGate: { alias: 'test-gate', params: ['account'] },
475
+ localLlm: { alias: 'test-paid-llm', params: ['cap'] },
476
+ })
477
+ const { account, project } = paid
478
+
479
+ expect([account.llm.get, account.llm.set, project.llm.get, project.llm.set]
480
+ .map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`))
481
+ .toEqual(['GET /llm', 'POST /llm', 'GET /project/:id/llm', 'POST /project/:id/llm'])
482
+ expect([account.llm.get, account.llm.set].map(protocol => aliasOf(protocol.route.route.parent)))
483
+ .toEqual([connect.account.base, connect.account.base])
484
+ expect([project.llm.get, project.llm.set].map(protocol => aliasOf(protocol.route.route.parent)))
485
+ .toEqual([connect.base, connect.base])
486
+ expect([account.llm.get.gate, project.llm.get.gate]).toEqual([undefined, undefined])
487
+ expect([account.llm.set.gate?.alias, project.llm.set.gate?.alias]).toEqual(['test-paid-llm', 'test-paid-llm'])
488
+ expect([connectRef.account.llm.set.alias, connectRef.project.llm.set.alias])
489
+ .toEqual([connect.account.llm.set, connect.project.llm.set])
490
+
491
+ const ajv = new Ajv({ strict: false })
492
+ const accountBody = ajv.compile(ConnectLlmBodySchema)
493
+ const projectBody = ajv.compile(ConnectProjectLlmBodySchema)
494
+ expect(accountBody({ llmMode: 'local' })).toBe(true)
495
+ expect(accountBody({ llmMode: null })).toBe(false)
496
+ expect(projectBody({ llmMode: 'cloud' })).toBe(true)
497
+ // `null` inherits the person's preference — it is a value, not an omission.
498
+ expect(projectBody({ llmMode: null })).toBe(true)
499
+ expect(projectBody({})).toBe(false)
500
+ expect(projectBody({ llmMode: 'inherit' })).toBe(false)
501
+ })
502
+
503
+ test('access tokens are listed and revoked under the account base, never minted, and never under the browser\'s /tokens mount', () => {
504
+ const { list, revoke } = tree.account.tokens
505
+
506
+ expect(Object.keys(tree.account.tokens)).toEqual(['list', 'revoke'])
507
+ expect(Object.keys(connect.account.tokens)).toEqual(['list', 'revoke'])
508
+ expect([list, revoke].map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`))
509
+ .toEqual(['GET /access-tokens', 'POST /access-tokens/:id/revoke'])
510
+ expect([list, revoke].map(protocol => aliasOf(protocol.route.route.parent))).toEqual([connect.account.base, connect.account.base])
511
+ const params = new Ajv({ strict: false }).compile(ConnectAccessTokenParamsSchema)
512
+ expect(params({ id: 'token-1' })).toBe(true)
513
+ expect(params({ id: '' })).toBe(false)
514
+ expect(params({ id: 't'.repeat(CONNECT_TOKEN_ID_MAX + 1) })).toBe(false)
515
+ expect(params({ id: 'token-1', name: 'x' })).toBe(false)
516
+ })
517
+
518
+ test('marketing consents are read and withdrawn under the account base — a body that cannot grant', () => {
519
+ const { status, withdraw } = tree.account.privacy
520
+
521
+ expect(Object.keys(tree.account.privacy)).toEqual(['status', 'withdraw'])
522
+ expect([status, withdraw].map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`))
523
+ .toEqual(['GET /privacy', 'POST /privacy/withdraw'])
524
+ expect([status, withdraw].map(protocol => aliasOf(protocol.route.route.parent))).toEqual([connect.account.base, connect.account.base])
525
+ const body = new Ajv({ strict: false }).compile(ConnectPrivacyWithdrawBodySchema)
526
+ expect(body({ keys: ['marketing.email'] })).toBe(true)
527
+ expect(body({ keys: [] })).toBe(false)
528
+ expect(body({ keys: ['marketing.email', 'marketing.email'] })).toBe(false)
529
+ expect(body({ keys: Array.from({ length: CONNECT_PRIVACY_KEYS_MAX + 1 }, (_, at) => `k${at}`) })).toBe(false)
530
+ expect(body({ keys: ['marketing.email'], granted: true })).toBe(false)
531
+ expect(body({ decisions: [{ key: 'marketing.email', granted: true }] })).toBe(false)
532
+ })
533
+
534
+ test('a stashed prompt is collected under the account base with the guest pickup\'s reference shape', () => {
535
+ const { pickup } = tree.account.intent
536
+
537
+ expect(`${pickup.route.route.method!.toUpperCase()} ${pickup.route.route.path}`).toBe('POST /intent/pickup')
538
+ expect(aliasOf(pickup.route.route.parent)).toBe(connect.account.base)
539
+ const body = new Ajv({ strict: false }).compile(ConnectIntentPickupBodySchema)
540
+ expect(body({ ref: 'A'.repeat(24) })).toBe(true)
541
+ expect(body({ ref: '0'.repeat(24) })).toBe(false)
542
+ expect(body({ ref: 'A'.repeat(23) })).toBe(false)
543
+ expect(body({ ref: 'A'.repeat(24), prompt: 'x' })).toBe(false)
544
+ })
545
+
546
+ test('git and GitHub sit under the owned base, no paid gate, and nothing completes a GitHub authorization', () => {
547
+ const paid = connectProtocols({
548
+ guard: DEFAULT_GUARD,
549
+ gate: { alias: 'test-gate', params: ['owner'] },
550
+ paid: Object.fromEntries(Object.values(ConnectPaidGate).map(kind => [kind, { alias: `gate-${kind}`, params: [kind] }])),
551
+ })
552
+ const routes = [...Object.values(paid.git), ...Object.values(paid.github)]
553
+
554
+ expect(routes.map(protocol => aliasOf(protocol.route.route.parent))).toEqual(routes.map(() => connect.base))
555
+ expect(routes.map(protocol => protocol.gate)).toEqual(routes.map(() => undefined))
556
+ expect(Object.keys(connect.git)).toEqual(['status', 'log', 'commit', 'discard', 'revert'])
557
+ expect(Object.keys(connect.github))
558
+ .toEqual(['authorize', 'publish', 'push', 'pull', 'disconnect', 'repos', 'branches', 'link'])
559
+ // Authorized here, completed only by the web application GitHub returns to.
560
+ const everyAlias = [...Object.values(connect.git), ...Object.values(connect.github)]
561
+ expect(everyAlias.filter(alias => /complete|callback|token/.test(alias))).toEqual([])
562
+ expect(routes.filter(protocol => /complete|callback/.test(protocol.route.route.path)).map(protocol => protocol.alias)).toEqual([])
563
+ expect([connectRef.git.status.alias, connectRef.github.authorize.alias, connectRef.github.link.alias])
564
+ .toEqual([connect.git.status, connect.github.authorize, connect.github.link])
565
+
566
+ const ajv = new Ajv({ strict: false })
567
+ const commit = ajv.compile(ConnectGitCommitBodySchema)
568
+ expect(commit({ message: 'feat: x' })).toBe(true)
569
+ expect(commit({ message: '' })).toBe(false)
570
+ expect(commit({ message: 'x'.repeat(201) })).toBe(false)
571
+ const revert = ajv.compile(ConnectGitRevertBodySchema)
572
+ expect(revert({ hash: 'abc1234' })).toBe(true)
573
+ expect(revert({ hash: 'abc12' })).toBe(false)
574
+ expect(revert({ hash: '--hard' })).toBe(false)
575
+ const publish = ajv.compile(ConnectGithubPublishBodySchema)
576
+ expect(publish({})).toBe(true)
577
+ expect(publish({ repoName: 'shop', private: false })).toBe(true)
578
+ expect(publish({ existing: { owner: 'acme', repo: 'shop' } })).toBe(true)
579
+ expect(publish({ existing: { owner: 'acme' } })).toBe(false)
580
+ // No field a credential could travel in.
581
+ expect(publish({ token: 'ghp_x' })).toBe(false)
582
+ const repos = ajv.compile(ConnectGithubRepoQuerySchema)
583
+ expect(repos({ page: 2, search: 'shop' })).toBe(true)
584
+ expect(repos({ page: 0 })).toBe(false)
585
+ const branches = ajv.compile(ConnectGithubBranchQuerySchema)
586
+ expect(branches({ owner: 'acme', repo: 'shop' })).toBe(true)
587
+ expect(branches({ owner: 'acme' })).toBe(false)
588
+ const link = ajv.compile(ConnectGithubLinkBodySchema)
589
+ expect(link({ owner: 'acme', repo: 'shop', branch: 'main' })).toBe(true)
590
+ expect(link({ owner: 'acme', repo: 'shop', branch: '' })).toBe(false)
591
+ })
592
+ test('the production workload sits under the owned base, each route with exactly its browser twin\'s payment kind', () => {
593
+ const paid = connectProtocols({
594
+ guard: DEFAULT_GUARD,
595
+ gate: { alias: 'test-gate', params: ['owner'] },
596
+ paid: Object.fromEntries(Object.values(ConnectPaidGate).map(kind => [kind, { alias: `gate-${kind}`, params: [kind] }])),
597
+ })
598
+ const { production } = paid
599
+ const routes = [
600
+ production.status, production.publish, production.restart, production.stop,
601
+ production.domain.attach, production.domain.verify, production.domain.detach,
602
+ production.auth.get, production.auth.redirects,
603
+ ]
604
+
605
+ expect(routes.map(protocol => aliasOf(protocol.route.route.parent))).toEqual(routes.map(() => connect.base))
606
+ expect(routes.map(protocol => protocol.gate?.alias)).toEqual([
607
+ undefined, 'gate-published-sites', undefined, undefined,
608
+ 'gate-custom-domain', undefined, undefined,
609
+ 'gate-production-standalone', 'gate-production-standalone',
610
+ ])
611
+ expect(routes.map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`)).toEqual([
612
+ 'GET /project/:id/production', 'POST /project/:id/production/publish', 'POST /project/:id/production/restart',
613
+ 'POST /project/:id/production/stop', 'POST /project/:id/production/domain/attach',
614
+ 'POST /project/:id/production/domain/verify', 'POST /project/:id/production/domain/detach',
615
+ 'GET /project/:id/production/auth', 'POST /project/:id/production/auth/redirects',
616
+ ])
617
+ // No route reveals the sign-in's secret, and there is no run verb: a stopped site comes back by restart.
618
+ const everyAlias = routes.map(protocol => protocol.alias)
619
+ expect(everyAlias.filter(alias => /secret|reveal|:run$/.test(alias))).toEqual([])
620
+ expect([connectRef.production.publish.alias, connectRef.production.domain.attach.alias, connectRef.production.auth.get.alias])
621
+ .toEqual([connect.production.publish, connect.production.domain.attach, connect.production.auth.get])
622
+
623
+ const ajv = new Ajv({ strict: false })
624
+ const domain = ajv.compile(ConnectProductionDomainBodySchema)
625
+ expect(domain({ domain: 'shop.example.com' })).toBe(true)
626
+ expect(domain({ domain: 'localhost' })).toBe(false)
627
+ expect(domain({ domain: 'https://shop.example.com' })).toBe(false)
628
+ expect(domain({ domain: 'shop.example.com', provider: 'x' })).toBe(false)
629
+ const redirects = ajv.compile(ConnectProductionRedirectsBodySchema)
630
+ expect(redirects({ redirects: [] })).toBe(true)
631
+ expect(redirects({ redirects: ['https://self.example.com/dispatcher'] })).toBe(true)
632
+ expect(redirects({ redirects: Array.from({ length: CONNECT_REDIRECTS_MAX + 1 }, (_, i) => `https://h${i}.test`) })).toBe(false)
633
+ expect(redirects({ redirects: ['x'.repeat(2049)] })).toBe(false)
634
+ expect(redirects({})).toBe(false)
635
+ })
636
+
637
+ test('the three feeds are GET reads by cursor — the project\'s under the owned base, the notices under the account base, no paid gate', () => {
638
+ const paid = connectProtocols({
639
+ guard: DEFAULT_GUARD,
640
+ gate: { alias: 'test-gate', params: ['owner'] },
641
+ accountGate: { alias: 'test-gate', params: ['account'] },
642
+ paid: Object.fromEntries(Object.values(ConnectPaidGate).map(kind => [kind, { alias: `gate-${kind}`, params: [kind] }])),
643
+ })
644
+ const routes = [paid.project.activity, paid.account.notifications, paid.files.changes]
645
+
646
+ expect(routes.map(protocol => protocol.alias))
647
+ .toEqual([connect.project.activity, connect.account.notifications, connect.files.changes])
648
+ expect(routes.map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`))
649
+ .toEqual(['GET /project/:id/activity', 'GET /notifications', 'GET /project/:id/files/changes'])
650
+ expect(routes.map(protocol => aliasOf(protocol.route.route.parent)))
651
+ .toEqual([connect.base, connect.account.base, connect.base])
652
+ expect(routes.map(protocol => protocol.gate)).toEqual([undefined, undefined, undefined])
653
+ // Cursor polling, never a socket — the connector holds no connection open.
654
+ expect(routes.every(protocol => protocol.route.route.protocol !== RouteProtocols.SOCKET)).toBe(true)
655
+ expect([connectRef.project.activity.alias, connectRef.account.notifications.alias, connectRef.files.changes.alias])
656
+ .toEqual([connect.project.activity, connect.account.notifications, connect.files.changes])
657
+ expect([CONNECT_FEED_WAIT_MAX_SEC, CONNECT_FEED_LIMIT_MAX, CONNECT_FEED_START]).toEqual([20, 200, '0-0'])
658
+ expect(Object.values(ConnectFeedDetail)).toEqual(['progress', 'thinking'])
659
+ expect(Object.values(ConnectFeedKind)).toContain('thinking')
660
+ })
661
+
662
+ test('a feed query is a cursor, a bounded limit and a wait of at most twenty seconds', () => {
663
+ const ajv = new Ajv({ strict: false })
664
+ const query = ajv.compile(ConnectFeedQuerySchema)
665
+ const activity = ajv.compile(ConnectActivityQuerySchema)
666
+
667
+ expect(query({})).toBe(true)
668
+ expect(query({ after: '1759800000000-0', limit: 10, wait: CONNECT_FEED_WAIT_MAX_SEC })).toBe(true)
669
+ expect(query({ after: CONNECT_FEED_START })).toBe(true)
670
+ expect(query({ after: null, limit: null, wait: null })).toBe(true)
671
+ expect(query({ wait: CONNECT_FEED_WAIT_MAX_SEC + 1 })).toBe(false)
672
+ expect(query({ limit: 0 })).toBe(false)
673
+ expect(query({ limit: CONNECT_FEED_LIMIT_MAX + 1 })).toBe(false)
674
+ expect(query({ after: '$' })).toBe(false)
675
+ expect(query({ after: '+' })).toBe(false)
676
+ expect(query({ after: '1-0', detail: 'thinking' })).toBe(false)
677
+ expect(activity({ detail: 'thinking' })).toBe(true)
678
+ expect(activity({ detail: null })).toBe(true)
679
+ expect(activity({ detail: 'everything' })).toBe(false)
680
+ expect(activity({ after: '1-0', wait: 5, detail: 'progress' })).toBe(true)
681
+ })
144
682
  })