@fabrika/app 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,386 @@
1
+ // `ctx.connect`: the consumer side of a connector. A call validates its input against the vendored snapshot, posts it
2
+ // to the owner's export procedure with the edge key as the bearer, and answers a typed outcome. A deadline bounds the
3
+ // call and its retries; the answer is validated against the vendored output schema, so drift shows on the first call.
4
+
5
+ import {
6
+ API_KEY_PREFIX,
7
+ type ConnectVariables,
8
+ connectVariables,
9
+ type ContractArtifact,
10
+ type ExportDescription,
11
+ type ImportArtifact,
12
+ type SchemaShape,
13
+ } from '@fabrika/auth-core'
14
+ import { z } from 'zod/v4'
15
+ import { type ConnectSnapshot, describeSnapshot } from '../contract/connect'
16
+ import { CONNECT_LIMITS, CONNECT_PREFIX, exportBase, joinPath } from '../contract/defaults'
17
+ import { ContractError } from '../contract/problems'
18
+ import { declaredPath, schemaFromShape } from '../contract/schema-from-shape'
19
+ import { readBoundedBytes } from './bounded'
20
+ import { isActiveRequest, type RequestFrame } from './capability'
21
+
22
+ export type ConnectFailure = 'denied' | 'invalid_input' | 'unavailable' | 'timeout' | 'contract_violation'
23
+
24
+ /** `unavailable` and `timeout` are never reported as `denied`: "we could not ask" is not "no". */
25
+ export type ConnectResult<T> =
26
+ | { readonly outcome: 'ok'; readonly value: T }
27
+ | { readonly outcome: ConnectFailure; readonly message: string }
28
+
29
+ /** How a request reaches the owner's proxy: `fetch` over HTTPS, or a service binding to its proxy Worker. */
30
+ export type ConnectFetch = (request: Request) => Promise<Response>
31
+
32
+ /** Every import's variables and binding are named under it (`connectVariables`, `@fabrika/auth-core`). */
33
+ export const CONNECT_BINDING_PREFIX = 'FABRIKA_CONNECT_'
34
+
35
+ export { type ConnectVariables, connectVariables }
36
+
37
+ /**
38
+ * One procedure as the connector validates it: zod schemas built by the framework from the snapshot's described
39
+ * shapes, never the application's schema objects, so nothing the application changes after `boot` reaches them.
40
+ */
41
+ interface CompiledProcedure {
42
+ readonly input: z.ZodType | null
43
+ readonly inputShape: SchemaShape | null
44
+ readonly output: z.ZodType
45
+ readonly outputShape: SchemaShape
46
+ }
47
+
48
+ export interface ConnectorOptions {
49
+ /** The import's name in the contract, for the log line of a contract violation. */
50
+ readonly name: string
51
+ readonly export: string
52
+ readonly version: number
53
+ readonly origin: string
54
+ readonly key: string
55
+ readonly fetch: ConnectFetch
56
+ readonly timeoutMs: number
57
+ /** The imported procedures of the checked description, by path. */
58
+ readonly description: ExportDescription
59
+ readonly procedures: readonly string[]
60
+ }
61
+
62
+ type Attempt = ConnectResult<unknown> & { readonly retry: boolean }
63
+
64
+ function failure(outcome: ConnectFailure, message: string, retry = false): Attempt {
65
+ return { outcome, message, retry }
66
+ }
67
+
68
+ /** `application/json`, with or without parameters, is the only media type an export answers in. */
69
+ export function isJsonMediaType(header: string | null): boolean {
70
+ return header?.split(';')[0]?.trim().toLowerCase() === 'application/json'
71
+ }
72
+
73
+ async function discard(response: Response): Promise<void> {
74
+ await response.body?.cancel().catch(() => {})
75
+ }
76
+
77
+ /** One import's calls. A message names only what the framework wrote; the edge key is cut out of it regardless. */
78
+ export class Connector {
79
+ readonly #options: ConnectorOptions
80
+ readonly #procedures: ReadonlyMap<string, CompiledProcedure>
81
+
82
+ constructor(options: ConnectorOptions) {
83
+ this.#options = options
84
+ const procedures = new Map<string, CompiledProcedure>()
85
+ for (const path of options.procedures) {
86
+ const described = Object.hasOwn(options.description.procedures, path) ? options.description.procedures[path] : undefined
87
+ if (described !== undefined) {
88
+ procedures.set(path, {
89
+ input: described.input === null ? null : schemaFromShape(described.input),
90
+ inputShape: described.input,
91
+ output: schemaFromShape(described.output),
92
+ outputShape: described.output,
93
+ })
94
+ }
95
+ }
96
+ this.#procedures = procedures
97
+ }
98
+
99
+ async call(path: string, input: unknown): Promise<ConnectResult<unknown>> {
100
+ const procedure = this.#procedures.get(path)
101
+ if (procedure === undefined) {
102
+ return { outcome: 'invalid_input', message: 'not an imported procedure' }
103
+ }
104
+ const parsed = procedure.input === null ? null : z.safeParse(procedure.input, input ?? {})
105
+ if (parsed !== null && !parsed.success) {
106
+ const issue = parsed.error.issues[0]
107
+ const at = procedure.inputShape === null || issue === undefined ? '(root)' : declaredPath(procedure.inputShape, issue.path)
108
+ return { outcome: 'invalid_input', message: this.#redact(`the input breaks the vendored input schema at ${at} (${issue?.code ?? 'invalid'})`) }
109
+ }
110
+ const body = JSON.stringify(parsed === null ? {} : parsed.data)
111
+ const deadline = Date.now() + this.#options.timeoutMs
112
+ let last: Attempt = failure('timeout', `no answer within ${this.#options.timeoutMs} ms`)
113
+ for (let attempt = 0; attempt <= CONNECT_LIMITS.retries; attempt++) {
114
+ const remaining = deadline - Date.now()
115
+ if (remaining <= 0) {
116
+ break
117
+ }
118
+ // The remaining time is shared by the attempts still allowed, so a hung first try leaves room for a retry.
119
+ last = await this.#attempt(path, body, procedure, Math.max(1, Math.floor(remaining / (CONNECT_LIMITS.retries + 1 - attempt))))
120
+ if (!last.retry) {
121
+ break
122
+ }
123
+ }
124
+ const message = this.#redact(last.outcome === 'ok' ? '' : last.message)
125
+ if (last.outcome === 'contract_violation') {
126
+ console.error(`fabrika: connect '${this.#options.name}.${path}': contract_violation: ${message}`)
127
+ }
128
+ return last.outcome === 'ok' ? { outcome: 'ok', value: last.value } : { outcome: last.outcome, message }
129
+ }
130
+
131
+ /** Defence in depth: no framework message quotes the answer, and the edge key is cut out of every one anyway. */
132
+ #redact(message: string): string {
133
+ return message.replaceAll(this.#options.key, '[edge key]')
134
+ }
135
+
136
+ /** One request under its own deadline; the deadline aborts the fetch and cancels the body reader. */
137
+ async #attempt(path: string, body: string, procedure: CompiledProcedure, timeoutMs: number): Promise<Attempt> {
138
+ const { origin, key } = this.#options
139
+ const url = new URL(joinPath(CONNECT_PREFIX, `${exportBase(this.#options.export, this.#options.version)}.${path}`), origin)
140
+ const controller = new AbortController()
141
+ const timedOut = failure('timeout', `no answer within ${timeoutMs} ms`, true)
142
+ const expired = new Promise<Attempt>((resolve) => controller.signal.addEventListener('abort', () => resolve(timedOut), { once: true }))
143
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
144
+ try {
145
+ const request = new Request(url, {
146
+ method: 'POST',
147
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
148
+ body,
149
+ signal: controller.signal,
150
+ redirect: 'manual',
151
+ })
152
+ const exchanged = this.#exchange(request, procedure, controller.signal).catch(() =>
153
+ controller.signal.aborted ? timedOut : failure('unavailable', 'the owner could not be reached', true)
154
+ )
155
+ return await Promise.race([exchanged, expired])
156
+ } finally {
157
+ clearTimeout(timer)
158
+ // Whatever is still reading or waiting after the answer — or the deadline — stops here.
159
+ controller.abort()
160
+ }
161
+ }
162
+
163
+ async #exchange(request: Request, procedure: CompiledProcedure, signal: AbortSignal): Promise<Attempt> {
164
+ const response = await this.#options.fetch(request)
165
+ const status = response.status
166
+ if (signal.aborted || status !== 200) {
167
+ await discard(response)
168
+ }
169
+ if (signal.aborted) {
170
+ return failure('timeout', 'no answer before the deadline', true)
171
+ }
172
+ if (status === 200) {
173
+ return await this.#answer(response, procedure, signal)
174
+ }
175
+ if (status >= 300 && status < 400) {
176
+ return failure('unavailable', `the owner answered ${status}, a redirect, which a connector never follows`)
177
+ }
178
+ if (status === 400) {
179
+ return failure('invalid_input', 'the owner refused the input')
180
+ }
181
+ if (status === 401 || status === 403 || status === 404) {
182
+ return failure('denied', `the owner answered ${status}`)
183
+ }
184
+ if (status === 408 || status === 429 || status >= 500) {
185
+ return failure('unavailable', `the owner answered ${status}`, true)
186
+ }
187
+ return failure('contract_violation', `the owner answered ${status}, which no export procedure answers`)
188
+ }
189
+
190
+ async #answer(response: Response, procedure: CompiledProcedure, signal: AbortSignal): Promise<Attempt> {
191
+ if (!isJsonMediaType(response.headers.get('Content-Type'))) {
192
+ await discard(response)
193
+ return failure('contract_violation', 'the answer is not application/json')
194
+ }
195
+ const bytes = await readBoundedBytes(response.body, {
196
+ maxBytes: CONNECT_LIMITS.maxAnswerBytes,
197
+ declaredLength: response.headers.get('Content-Length'),
198
+ signal,
199
+ })
200
+ if (bytes === null) {
201
+ return failure('contract_violation', `the answer is larger than ${CONNECT_LIMITS.maxAnswerBytes} bytes`)
202
+ }
203
+ let answer: unknown
204
+ try {
205
+ answer = JSON.parse(new TextDecoder().decode(bytes))
206
+ } catch {
207
+ return failure('contract_violation', 'the answer is not JSON')
208
+ }
209
+ const result = typeof answer === 'object' && answer !== null && 'result' in answer ? answer.result : undefined
210
+ const parsed = z.safeParse(procedure.output, result)
211
+ if (!parsed.success) {
212
+ const issue = parsed.error.issues[0]
213
+ const at = issue === undefined ? '(root)' : declaredPath(procedure.outputShape, issue.path)
214
+ return failure('contract_violation', `the answer breaks the vendored output schema at ${at} (${issue?.code ?? 'invalid'})`)
215
+ }
216
+ return { outcome: 'ok', value: parsed.data, retry: false }
217
+ }
218
+ }
219
+
220
+ /** `ctx.connect` of one request: each import its endpoint's `usedBy` names, with the procedures the contract imports. */
221
+ export type ConnectContextValue = Readonly<Record<string, Readonly<Record<string, (input?: unknown) => Promise<ConnectResult<unknown>>>>>>
222
+
223
+ /** One import as an endpoint may use it. */
224
+ export interface ImportWire {
225
+ readonly name: string
226
+ readonly connector: Connector
227
+ readonly procedures: readonly string[]
228
+ readonly usedBy: ReadonlySet<string>
229
+ }
230
+
231
+ const REFUSED: ConnectResult<never> = Object.freeze({
232
+ outcome: 'denied',
233
+ message: 'ctx.connect works only inside the request that received it, in a handler the import names in usedBy',
234
+ })
235
+
236
+ /**
237
+ * The `ctx.connect` of one request, bound to its frame. A method kept past its request, called from another request,
238
+ * from an export's (read-only) request or from an endpoint the import does not name is `denied`, and sends nothing.
239
+ */
240
+ export function connectContext(imports: readonly ImportWire[], frame: RequestFrame): ConnectContextValue {
241
+ const call = (wire: ImportWire, path: string, input: unknown): Promise<ConnectResult<unknown>> => {
242
+ const endpoint = frame.endpoint
243
+ if (!isActiveRequest(frame) || frame.ended || frame.readOnly || endpoint === null || !wire.usedBy.has(endpoint)) {
244
+ return Promise.resolve(REFUSED)
245
+ }
246
+ return wire.connector.call(path, input)
247
+ }
248
+ return Object.freeze(Object.fromEntries(imports.map((wire) => [
249
+ wire.name,
250
+ Object.freeze(Object.fromEntries(wire.procedures.map((path) => [path, (input?: unknown) => call(wire, path, input)]))),
251
+ ])))
252
+ }
253
+
254
+ export interface ImportWiring {
255
+ readonly artifact: ContractArtifact
256
+ readonly snapshots: Readonly<Record<string, ConnectSnapshot>>
257
+ /** The captured `FABRIKA_*` variables, which carry each import's URL and edge key. */
258
+ readonly framework: Readonly<Record<string, string>>
259
+ /** Cloudflare service bindings to owners' proxy Workers, by binding name; any other import uses `fallback`. */
260
+ readonly bindings: Readonly<Record<string, ConnectFetch>>
261
+ readonly fallback: ConnectFetch
262
+ }
263
+
264
+ /**
265
+ * The imports each endpoint may use. Each import must come with the snapshot its digest names, unedited, and with its
266
+ * owner's URL and edge key; otherwise `problems` says why. What a connector validates against is compiled here, once,
267
+ * from the checked description, before the application's `deps` run.
268
+ */
269
+ export function wireImports(wiring: ImportWiring, problems: string[]): ReadonlyMap<string, readonly ImportWire[]> {
270
+ const { artifact, snapshots, framework } = wiring
271
+ const byEndpoint = new Map<string, ImportWire[]>()
272
+ const bindings = new Map<string, string>()
273
+ for (const name of Object.keys(snapshots)) {
274
+ if (!Object.hasOwn(artifact.imports, name)) {
275
+ problems.push(`a snapshot is passed as '${name}', and the contract imports nothing of that name`)
276
+ }
277
+ }
278
+ for (const [name, declared] of Object.entries(artifact.imports)) {
279
+ const where = `import '${name}'`
280
+ const snapshot = Object.hasOwn(snapshots, name) ? snapshots[name] : undefined
281
+ if (snapshot === undefined) {
282
+ problems.push(
283
+ `${where} has no snapshot; pass it in defineApp(vocabulary, tables, { imports: { ${name}: { snapshot, procedures } } }), from fabrika/connect/`,
284
+ )
285
+ continue
286
+ }
287
+ const description = checkedDescription(where, declared, snapshot, problems)
288
+ if (description === null) {
289
+ continue
290
+ }
291
+ const variables = connectVariables(name)
292
+ const other = bindings.get(variables.binding)
293
+ if (other !== undefined) {
294
+ problems.push(`${where} and import '${other}' both read ${variables.binding}_*; rename one of them`)
295
+ continue
296
+ }
297
+ bindings.set(variables.binding, name)
298
+ const origin = readOrigin(variables.url, framework[variables.url], problems)
299
+ const key = framework[variables.key] ?? ''
300
+ if (!key.startsWith(API_KEY_PREFIX)) {
301
+ problems.push(`${variables.key} is not set to a ${API_KEY_PREFIX} key; the platform writes the key the owner's proxy admits ${where} with`)
302
+ }
303
+ if (origin === null || !key.startsWith(API_KEY_PREFIX)) {
304
+ continue
305
+ }
306
+ const procedures = Object.freeze([...declared.procedures])
307
+ const wire: ImportWire = Object.freeze({
308
+ name,
309
+ procedures,
310
+ usedBy: new Set(declared.usedBy),
311
+ connector: new Connector({
312
+ name,
313
+ export: declared.export,
314
+ version: declared.version,
315
+ origin,
316
+ key,
317
+ fetch: Object.hasOwn(wiring.bindings, variables.binding) ? (wiring.bindings[variables.binding] ?? wiring.fallback) : wiring.fallback,
318
+ timeoutMs: declared.timeoutMs ?? CONNECT_LIMITS.defaultTimeoutMs,
319
+ description,
320
+ procedures,
321
+ }),
322
+ })
323
+ for (const id of declared.usedBy) {
324
+ byEndpoint.set(id, [...(byEndpoint.get(id) ?? []), wire])
325
+ }
326
+ }
327
+ return byEndpoint
328
+ }
329
+
330
+ function deepFreeze<T>(value: T): T {
331
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
332
+ Object.freeze(value)
333
+ for (const child of Object.values(value)) {
334
+ deepFreeze(child)
335
+ }
336
+ }
337
+ return value
338
+ }
339
+
340
+ /** The snapshot's description, deep-frozen and independent of the snapshot object, when it is the import's. */
341
+ function checkedDescription(where: string, declared: ImportArtifact, snapshot: ConnectSnapshot, problems: string[]): ExportDescription | null {
342
+ const named = `${snapshot.app}/${snapshot.export}@${snapshot.version}`
343
+ if (snapshot.app !== declared.app || snapshot.export !== declared.export || snapshot.version !== declared.version) {
344
+ problems.push(`${where} imports ${declared.app}/${declared.export}@${declared.version}, and its snapshot is ${named}`)
345
+ return null
346
+ }
347
+ if (snapshot.digest !== declared.digest) {
348
+ problems.push(`${where} names digest ${declared.digest}, and its snapshot is ${snapshot.digest}; import the digest from the snapshot`)
349
+ return null
350
+ }
351
+ let description: ExportDescription
352
+ try {
353
+ description = describeSnapshot(snapshot)
354
+ } catch (error) {
355
+ problems.push(...(error instanceof ContractError ? error.problems : [`the snapshot ${named} does not describe`]))
356
+ return null
357
+ }
358
+ if (description.digest !== declared.digest) {
359
+ problems.push(`the snapshot ${named} was edited after fabrika connect wrote it; run fabrika connect pull`)
360
+ return null
361
+ }
362
+ const missing = declared.procedures.filter((path) => !Object.hasOwn(description.procedures, path))
363
+ if (missing.length > 0) {
364
+ problems.push(`${where} imports ${missing.join(', ')}, which ${named} does not export`)
365
+ return null
366
+ }
367
+ return deepFreeze(description)
368
+ }
369
+
370
+ /** The owner's origin; the value is never quoted, since an operator may paste a URL with credentials in it. */
371
+ function readOrigin(name: string, raw: string | undefined, problems: string[]): string | null {
372
+ if (raw === undefined || raw.trim() === '') {
373
+ problems.push(`${name} is not set; the platform writes the origin of the owner's proxy`)
374
+ return null
375
+ }
376
+ try {
377
+ const url = new URL(raw.trim())
378
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && url.username === '' && url.password === '') {
379
+ return url.origin
380
+ }
381
+ } catch {
382
+ // Reported below.
383
+ }
384
+ problems.push(`${name} is not an http(s) origin without credentials`)
385
+ return null
386
+ }