@owlmeans/viable-common 0.0.46 → 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 (153) hide show
  1. package/README.md +163 -14
  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 +122 -30
  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/production/types.d.ts +65 -0
  54. package/build/connect/production/types.d.ts.map +1 -0
  55. package/build/connect/production/types.js +2 -0
  56. package/build/connect/production/types.js.map +1 -0
  57. package/build/connect/project/types.d.ts +23 -16
  58. package/build/connect/project/types.d.ts.map +1 -1
  59. package/build/connect/references/types.d.ts +424 -2
  60. package/build/connect/references/types.d.ts.map +1 -1
  61. package/build/connect/references.d.ts.map +1 -1
  62. package/build/connect/references.js +122 -0
  63. package/build/connect/references.js.map +1 -1
  64. package/build/connect/schemas.d.ts +65 -2
  65. package/build/connect/schemas.d.ts.map +1 -1
  66. package/build/connect/schemas.js +306 -1
  67. package/build/connect/schemas.js.map +1 -1
  68. package/build/connect/settings/types.d.ts +0 -25
  69. package/build/connect/settings/types.d.ts.map +1 -1
  70. package/build/connect/slot/types.d.ts +24 -0
  71. package/build/connect/slot/types.d.ts.map +1 -0
  72. package/build/connect/slot/types.js +2 -0
  73. package/build/connect/slot/types.js.map +1 -0
  74. package/build/convert/record/types.d.ts +0 -9
  75. package/build/convert/record/types.d.ts.map +1 -1
  76. package/build/convert/schemas.d.ts +2 -5
  77. package/build/convert/schemas.d.ts.map +1 -1
  78. package/build/convert/schemas.js +2 -15
  79. package/build/convert/schemas.js.map +1 -1
  80. package/build/iam-console/consts.d.ts +13 -0
  81. package/build/iam-console/consts.d.ts.map +1 -0
  82. package/build/iam-console/consts.js +13 -0
  83. package/build/iam-console/consts.js.map +1 -0
  84. package/build/iam-console/consts.local.d.ts +123 -0
  85. package/build/iam-console/consts.local.d.ts.map +1 -0
  86. package/build/iam-console/consts.local.js +50 -0
  87. package/build/iam-console/consts.local.js.map +1 -0
  88. package/build/iam-console/index.d.ts +4 -0
  89. package/build/iam-console/index.d.ts.map +1 -0
  90. package/build/iam-console/index.js +3 -0
  91. package/build/iam-console/index.js.map +1 -0
  92. package/build/iam-console/schemas.d.ts +38 -0
  93. package/build/iam-console/schemas.d.ts.map +1 -0
  94. package/build/iam-console/schemas.js +217 -0
  95. package/build/iam-console/schemas.js.map +1 -0
  96. package/build/iam-console/types.d.ts +140 -0
  97. package/build/iam-console/types.d.ts.map +1 -0
  98. package/build/iam-console/types.js +2 -0
  99. package/build/iam-console/types.js.map +1 -0
  100. package/build/index.d.ts +1 -0
  101. package/build/index.d.ts.map +1 -1
  102. package/build/index.js +1 -0
  103. package/build/index.js.map +1 -1
  104. package/build/legal/consts.d.ts +33 -2
  105. package/build/legal/consts.d.ts.map +1 -1
  106. package/build/legal/consts.js +11 -3
  107. package/build/legal/consts.js.map +1 -1
  108. package/build/legal/types.d.ts +8 -1
  109. package/build/legal/types.d.ts.map +1 -1
  110. package/build/planning/schemas.d.ts.map +1 -1
  111. package/build/planning/schemas.js +0 -1
  112. package/build/planning/schemas.js.map +1 -1
  113. package/build/planning/types.d.ts +0 -2
  114. package/build/planning/types.d.ts.map +1 -1
  115. package/package.json +11 -2
  116. package/src/connect/account/types.ts +91 -0
  117. package/src/connect/branding/types.ts +28 -4
  118. package/src/connect/call/types.ts +31 -0
  119. package/src/connect/config/types.ts +51 -0
  120. package/src/connect/consts.ts +400 -1
  121. package/src/connect/conversion/types.ts +25 -0
  122. package/src/connect/entrypoints/types.ts +22 -7
  123. package/src/connect/entrypoints.ts +592 -15
  124. package/src/connect/errors.ts +18 -0
  125. package/src/connect/feed/types.ts +76 -0
  126. package/src/connect/files/types.ts +38 -0
  127. package/src/connect/git/types.ts +187 -0
  128. package/src/connect/iam/types.ts +28 -0
  129. package/src/connect/index.ts +9 -0
  130. package/src/connect/production/types.ts +71 -0
  131. package/src/connect/project/types.ts +25 -12
  132. package/src/connect/references/types.ts +166 -5
  133. package/src/connect/references.ts +210 -5
  134. package/src/connect/schemas.ts +366 -5
  135. package/src/connect/settings/types.ts +0 -25
  136. package/src/connect/slot/types.ts +24 -0
  137. package/src/convert/record/types.ts +0 -11
  138. package/src/convert/schemas.ts +3 -19
  139. package/src/iam-console/consts.local.ts +64 -0
  140. package/src/iam-console/consts.ts +15 -0
  141. package/src/iam-console/index.ts +3 -0
  142. package/src/iam-console/schemas.ts +251 -0
  143. package/src/iam-console/types.ts +165 -0
  144. package/src/index.ts +1 -0
  145. package/src/legal/consts.ts +13 -5
  146. package/src/legal/types.ts +12 -1
  147. package/src/planning/schemas.ts +0 -1
  148. package/src/planning/types.ts +0 -2
  149. package/tests/connect-convert.spec.ts +6 -2
  150. package/tests/connect-entrypoints.spec.ts +554 -16
  151. package/tests/error-status.spec.ts +2 -1
  152. package/tests/iam-console.spec.ts +89 -0
  153. 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
  })