domain0 0.1.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.
package/README.md ADDED
@@ -0,0 +1,629 @@
1
+ # Domain0 TypeScript SDK
2
+
3
+ This package contains Domain0's type-safe HTTP client,
4
+ Zod contracts, and dependency-free embeddable connection UI.
5
+ Node.js consumers require a currently supported Node.js 22 or newer release;
6
+ browser consumers need standards-compliant `fetch`, Web Crypto, and `<dialog>`.
7
+
8
+ The hosted platform uses two explicit trust boundaries. Keep the long-lived
9
+ API key on the integrating server, then give the browser only short-lived,
10
+ exact-origin access to one connection:
11
+
12
+ ```ts
13
+ import { createDomain0PlatformClient } from 'domain0'
14
+
15
+ const platform = createDomain0PlatformClient({
16
+ baseUrl: 'https://domain0.dev/api/domain0/',
17
+ apiKey: process.env.DOMAIN0_API_KEY!,
18
+ })
19
+
20
+ const { connection } = await platform.createConnection({
21
+ intent: {
22
+ domain: 'customer.example',
23
+ records: [{ host: 'app', type: 'CNAME', value: 'edge.example.com' }],
24
+ },
25
+ })
26
+ const access = await platform.issueConnectionToken(connection.id, {
27
+ origin: 'https://app.example.com',
28
+ })
29
+ ```
30
+
31
+ Use `forwardDomain0ConnectionRequest()` from `domain0/server` in a same-origin
32
+ catch-all route. It forwards only browser-safe connection routes, strips
33
+ cookies and API keys, caps request bodies, and preserves the exact origin.
34
+ The browser constructs `createDomain0Client()` against that local route and
35
+ opens `domain0.connectDomain()` with the returned connection ID.
36
+
37
+ Webhook receivers can import the strict schema-v1 contract separately. The
38
+ verifier authenticates untouched request bytes before parsing them:
39
+
40
+ ```ts
41
+ import { verifyDomain0Webhook } from 'domain0/contracts'
42
+
43
+ const event = await verifyDomain0Webhook({
44
+ body: new Uint8Array(await request.arrayBuffer()),
45
+ headers: request.headers,
46
+ secretKeyring: { 'webhook-v1': decoded32ByteSecret },
47
+ })
48
+
49
+ await processOnceInTransaction(event.eventId, () => applyEvent(event))
50
+ ```
51
+
52
+ Keep the idempotency record and business change in one database transaction.
53
+ Return 2xx when an event ID was already processed.
54
+
55
+ ```ts
56
+ import { createDomain0Client } from 'domain0'
57
+
58
+ const client = createDomain0Client({
59
+ baseUrl: 'https://domain0.example.com',
60
+ token: async () => obtainShortLivedConnectionToken(),
61
+ })
62
+
63
+ const connection = await client.getConnection(connectionId)
64
+
65
+ const providerHealth = await client.getProviderHealth()
66
+ for (const provider of providerHealth.providers) {
67
+ if (!provider.enabled) {
68
+ console.info(provider.providerName, provider.reason)
69
+ }
70
+ }
71
+
72
+ const currentDns = await client.checkRecords({
73
+ domain: 'example.com',
74
+ records: [{ host: 'www', type: 'CNAME', value: 'edge.example.net', ttl: 300 }],
75
+ })
76
+
77
+ const support = await client.checkDomain({
78
+ domain: 'example.com',
79
+ records: [{ host: 'www', type: 'CNAME', value: 'edge.example.net' }],
80
+ checkConflicts: true,
81
+ })
82
+
83
+ await client.authorizeWithCredential(connectionId, {
84
+ providerId: 'cloudflare',
85
+ credentials: {
86
+ kind: 'api_token',
87
+ token: obtainProviderToken(),
88
+ },
89
+ commandId: crypto.randomUUID(),
90
+ })
91
+
92
+ await client.cancelConnection(connectionId, {
93
+ commandId: crypto.randomUUID(),
94
+ })
95
+
96
+ if (connection.connection.state === 'failed_retryable') {
97
+ await client.retryConnection(connectionId, {
98
+ commandId: crypto.randomUUID(),
99
+ })
100
+ }
101
+ ```
102
+
103
+ An application-authenticated server can provision several exact-domain
104
+ connections with one replay-safe command. A target uses either direct records
105
+ or the conditional record shape—never both:
106
+
107
+ ```ts
108
+ const created = await applicationClient.createConnectionFlow({
109
+ applicationId: 'application-1',
110
+ tenantId: 'tenant-1',
111
+ commandId: crypto.randomUUID(),
112
+ targets: [
113
+ {
114
+ domain: 'example.com',
115
+ records: [{ host: '@', type: 'A', value: '192.0.2.10', ttl: 300 }],
116
+ },
117
+ {
118
+ domain: 'docs.example.net',
119
+ conditionalRecords: {
120
+ domain: [{ host: '@', type: 'CNAME', value: 'fallback.example.net' }],
121
+ subDomain: [{ host: '@', type: 'CNAME', value: 'edge.example.net' }],
122
+ rootNS: [{ host: '@', type: 'NS', value: 'ns1.example.net' }],
123
+ },
124
+ },
125
+ ],
126
+ })
127
+
128
+ await domain0.connectDomains({
129
+ client: browserConnectionClient,
130
+ flowId: created.flow.id,
131
+ })
132
+ ```
133
+
134
+ `connectDomains()` reuses the same single-domain controller for one child at a
135
+ time and announces fixed “Domain N of total” progress. Closing does not delete
136
+ server state; reopening the flow ID reloads its durable child references.
137
+ Domain0 derives the registrable-root boundary and resolves `rootNS` only after
138
+ the selected provider's catalogued capability is `supported`. Unverified or
139
+ unsupported providers fall back to `subDomain`, then `domain`; callers cannot
140
+ override either decision.
141
+
142
+ An application-authenticated server can create a time-bounded handoff for an
143
+ existing connection. The URL fragment is not sent in HTTP requests or ordinary
144
+ access logs:
145
+
146
+ ```ts
147
+ import { createSharedFlowUrl, loadSharedFlow } from 'domain0'
148
+
149
+ const shared = await applicationClient.createSharedFlow({
150
+ connectionId,
151
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
152
+ })
153
+ const link = createSharedFlowUrl('https://app.example.com/connect', shared.token)
154
+
155
+ const loaded = await loadSharedFlow({
156
+ baseUrl: 'https://domain0.example.com',
157
+ url: link,
158
+ origin: window.location.origin,
159
+ })
160
+ await loaded.client.getConnection(loaded.connectionId)
161
+ ```
162
+
163
+ `mountDomain0SharedFlow()` combines resolution with the same dependency-free
164
+ connection UI. Shared-flow storage contains only ownership identifiers, a
165
+ SHA-256 token digest, and timestamps—never the bearer token, DNS configuration,
166
+ or provider credentials. Invalid and expired tokens are indistinguishable.
167
+
168
+ Every request is validated before transmission and every successful or error
169
+ response is parsed at runtime. The SDK rejects non-HTTPS base URLs except
170
+ loopback development addresses. Base URLs cannot contain user information,
171
+ query parameters, or fragments, and bearer tokens must match RFC 6750's ASCII
172
+ token grammar before `fetch` is called. Keep application API tokens on the server;
173
+ browsers should receive only short-lived, exact-origin, connection-scoped
174
+ tokens. Credential shapes are a provider-discriminated Zod union, so an
175
+ unsupported provider or credential kind fails before a request is sent.
176
+ `getProviderHealth()` returns every reviewed provider and matches Entri's
177
+ `enabled` compatibility field while adding strict `status`, `reason`,
178
+ `observedAt`, and `refreshAfter` semantics. Enabled means that this Domain0
179
+ deployment exposes a verified non-manual setup path. It is not an upstream
180
+ uptime probe and does not promote an unverified provider.
181
+ `checkRecords()` is an application-token-only, read-only authoritative DNS
182
+ check. It does not create a connection, write provider state, or treat a
183
+ recursive resolver answer as propagation proof; each result reports how many
184
+ authoritative nameservers returned the requested value.
185
+ `checkDomain()` has the same application-token-only boundary. It reports
186
+ `delegated`, `undelegated`, or `unknown` DNS state; provider candidates;
187
+ deployment-backed setup availability; and optional conflict analysis. A
188
+ `partial` analysis means at least one authoritative nameserver could not
189
+ complete every required query, so callers must not treat the result as proof
190
+ that no conflict exists. Domain0 deliberately does not expose `registered` or
191
+ `expired` booleans from DNS evidence because delegation cannot prove either.
192
+ The same union covers API tokens, access-key pairs, username/password,
193
+ account/token, username/token, OVH AK/AS/CK, TransIP PEM keys, temporary AWS
194
+ sessions with explicit expiry, and provider-scoped cPanel credentials.
195
+ Cancellation is an idempotent, typed operation for non-terminal connections;
196
+ it does not reverse DNS records that a provider may already have applied.
197
+ Retry does not blindly repeat a provider mutation. It restores the exact
198
+ pre-failure checkpoint, after which the integrating application or UI starts a
199
+ fresh snapshot or reconciliation attempt explicitly.
200
+
201
+ SPF conflict handling is explicit and typed when preparing a plan. `merge` is
202
+ the safe default and combines unique mechanisms, choosing `~all` when existing
203
+ and requested terminal policies disagree. `replace` uses the requested SPF
204
+ record as-is, marks the change destructive, emits a plan warning, and still
205
+ requires the separate destructive confirmation step:
206
+
207
+ ```ts
208
+ await client.preparePlan(connectionId, {
209
+ conflictPolicy: 'preserve',
210
+ spfPolicy: 'replace',
211
+ commandId: crypto.randomUUID(),
212
+ })
213
+ ```
214
+
215
+ Advanced DMARC options are attached only to a valid `_dmarc` TXT record. They
216
+ are strict application configuration, not arbitrary UI input:
217
+
218
+ ```ts
219
+ await applicationClient.createConnection({
220
+ applicationId: 'application-1',
221
+ tenantId: 'tenant-1',
222
+ commandId: crypto.randomUUID(),
223
+ intent: {
224
+ domain: 'mail.example.com',
225
+ records: [{
226
+ host: '_dmarc',
227
+ type: 'TXT',
228
+ value: 'v=DMARC1; p=none',
229
+ ttl: 300,
230
+ advancedDmarcOptions: {
231
+ inheritRootDmarc: true,
232
+ overrideTags: { p: 'quarantine' },
233
+ removeTags: ['pct'],
234
+ addTagsIfNotExist: { aspf: 's' },
235
+ },
236
+ }],
237
+ },
238
+ })
239
+ ```
240
+
241
+ Planning preserves this requested intent and returns `effectiveIntent` after
242
+ reading existing provider records and, when requested, the registrable root's
243
+ public DMARC TXT record. Guided manual setup resolves the same policy from
244
+ authoritative public DNS. The UI always displays the effective records that
245
+ propagation verification will check. If an existing or inherited DMARC policy
246
+ is invalid or ambiguous, Domain0 uses the supplied valid `value` unchanged
247
+ instead of partially rewriting it.
248
+
249
+ Existing-record checks are a separate strict policy on the requested intent:
250
+
251
+ ```ts
252
+ await applicationClient.createConnection({
253
+ applicationId: 'application-1',
254
+ tenantId: 'tenant-1',
255
+ commandId: crypto.randomUUID(),
256
+ intent: {
257
+ domain: 'example.com',
258
+ existingRecordPolicy: {
259
+ validateDmarc: true,
260
+ validateCAA: true,
261
+ },
262
+ records: [
263
+ { host: '_dmarc', type: 'TXT', value: 'v=DMARC1; p=reject' },
264
+ { host: '@', type: 'CAA', value: '0 issue letsencrypt.org' },
265
+ ],
266
+ },
267
+ })
268
+ ```
269
+
270
+ `validateDmarc` preserves an exactly-one valid DMARC policy at the requested
271
+ host. `validateCAA` includes a requested CAA record only when that host already
272
+ has CAA. Both default to `false`. If every requested record is suppressed, the
273
+ response contains `effectiveIntent.records: []`; the UI explains that no DNS
274
+ changes are required and verification completes without querying DNS. The same
275
+ policy may be supplied at the top level of `createConnectionFlow()` and is
276
+ persisted on every child connection.
277
+
278
+ Every provider descriptor includes a validated HTTPS `referenceUrl`. The
279
+ embeddable UI opens it in a new tab with an explicit accessible label and
280
+ `noopener noreferrer`. This link helps users find provider-specific material;
281
+ it is not a claim that the provider has passed automatic conformance.
282
+
283
+ `provider.capabilities.rootNSModification` is always one of `supported`,
284
+ `unsupported`, or `unverified`. Domain0 exposes the assessment but never uses
285
+ it to resolve records in the browser. Conditional intent is resolved by the
286
+ backend when the provider is selected, preventing a caller from claiming a
287
+ capability the provider has not proved.
288
+
289
+ The framework-neutral UI exposes fully typed `onStepChange`, `onSuccess`, and
290
+ `onClose` lifecycle callbacks. Step events are deduplicated, success is emitted
291
+ once per open modal session, and close events distinguish the close button,
292
+ Escape, programmatic close, and destruction. The lower-level
293
+ `onConnectionChange` observer is revision-aware and does not repeat an
294
+ unchanged connection.
295
+
296
+ For an Entri-style high-level lifecycle, use the singleton `domain0` export or
297
+ create an isolated instance with `createDomain0()`. `connectDomain()` mounts
298
+ and opens the flow immediately, `close()` closes the active modal,
299
+ `destroy()` also removes its DOM root, and a later connection replaces the
300
+ previous one without leaving duplicate roots. `load()` is an asynchronous
301
+ preflight for API parity; the npm package is already bundled when imported.
302
+
303
+ ```ts
304
+ import { createDomain0Client, domain0 } from 'domain0'
305
+
306
+ await domain0.load()
307
+
308
+ const client = createDomain0Client({
309
+ baseUrl: 'https://domain0.example.com',
310
+ token: async () => obtainShortLivedConnectionToken(),
311
+ })
312
+
313
+ await domain0.connectDomain({ client, connectionId })
314
+
315
+ // Later, if the host application needs to dismiss the active flow:
316
+ domain0.close()
317
+ ```
318
+
319
+ ### DKIM email-provider guidance
320
+
321
+ The lower-level client exposes a connection-scoped, read-only query. The
322
+ backend loads the tenant-owned connection and derives its domain; callers can
323
+ only add bounded selectors to inspect:
324
+
325
+ ```ts
326
+ import { DkimSelectorSchema } from 'domain0'
327
+
328
+ const guidance = await client.getDkimGuidance(connectionId, {
329
+ selectors: [DkimSelectorSchema.parse('mail-2026')],
330
+ })
331
+
332
+ if (guidance.detectionStatus === 'detected') {
333
+ console.info(guidance.provider.name, guidance.dkimDns.status)
334
+ }
335
+ ```
336
+
337
+ Set `enableDkim` to render the same guidance only after the connection becomes
338
+ active. Known account-specific selectors can be supplied explicitly:
339
+
340
+ ```ts
341
+ await domain0.connectDomain({
342
+ client,
343
+ connectionId,
344
+ enableDkim: true,
345
+ dkimSelectors: [DkimSelectorSchema.parse('mail-2026')],
346
+ onDkimSetupDocumentationClick: ({ provider }) => {
347
+ analytics.track('dkim_guide_opened', { provider: provider.id })
348
+ },
349
+ })
350
+ ```
351
+
352
+ MX evidence is bounded to Google Workspace, Microsoft 365, and Zoho Mail.
353
+ Documentation URLs are a fixed discriminated union of their official guides,
354
+ not network-provided links. `record_observed` means only that a TXT or CNAME
355
+ was observed for a checked selector. `no_record_observed` is equally narrow:
356
+ it does not prove DKIM is disabled because selectors can differ. The UI states
357
+ that provider-admin status must be checked and never reports signing as enabled
358
+ or disabled.
359
+
360
+ ### Locale and fallback contract
361
+
362
+ Every connection entry point accepts a strict `locale`. The Zod-derived
363
+ `Domain0Locale` contract mirrors Entri Connect's current identifiers:
364
+ `en`, `es`, `pt`, `pt-br`, `pt-pt`, `fr`, `it`, `de`, `nl`, `pl`, `tr`,
365
+ `ja`, `da`, and `sv`. The default is `en`; `pt` uses Brazilian Portuguese
366
+ formatting (`pt-BR`). Unknown or region-expanded values such as `en-US` are
367
+ rejected instead of being guessed.
368
+
369
+ ```ts
370
+ await domain0.connectDomain({
371
+ client,
372
+ connectionId,
373
+ locale: 'de',
374
+ })
375
+ ```
376
+
377
+ Domain0 currently ships complete English, Spanish, Brazilian Portuguese,
378
+ European Portuguese, French, and German message catalogs. The
379
+ other accepted Entri identifiers use the English built-in catalog until their
380
+ native catalog is complete. This fallback is exposed by
381
+ `createDomain0Localizer(locale).translatedLocale`, so hosts and tests can
382
+ observe it instead of assuming a translation exists. Each mounted controller
383
+ owns its immutable localizer, writes the requested BCP 47 language tag and
384
+ direction to its root, uses `Intl` for numbers and visible dates, and preserves
385
+ machine-readable ISO values in native `<time datetime>` elements. Provider
386
+ detection explanations, plan warnings, API failures, transport failures, and
387
+ protocol failures are rendered from exhaustive typed mappings; raw server or
388
+ adapter messages remain diagnostic data and never become visible copy. The current
389
+ Entri locale set is left-to-right; the renderer nevertheless uses logical CSS
390
+ alignment so a future RTL locale does not require a layout rewrite.
391
+
392
+ ### Typed browser events
393
+
394
+ Every lifecycle event is available through the strict Zod-discriminated
395
+ `onEvent` stream. The existing focused callbacks remain available:
396
+
397
+ ```ts
398
+ await domain0.connectDomain({
399
+ client,
400
+ connectionId,
401
+ onEvent: (event) => {
402
+ switch (event.type) {
403
+ case 'success':
404
+ case 'close':
405
+ case 'step_change':
406
+ case 'manual_setup_documentation_click':
407
+ case 'dkim_setup_documentation_click':
408
+ case 'request_close':
409
+ case 'shared_flow_sent':
410
+ analytics.track(event.type, event)
411
+ break
412
+ }
413
+ },
414
+ onRequestClose: async () => {
415
+ if (await confirmExit()) {
416
+ domain0.close()
417
+ }
418
+ },
419
+ })
420
+ ```
421
+
422
+ Providing `onRequestClose` intercepts only the in-widget Close button. The
423
+ modal remains open until the host closes it, and `onClose` fires only after the
424
+ real close. Manual-guide events fire only from the manual instructions screen.
425
+
426
+ To display the manual screen's **Copy secure setup link** action, provide a
427
+ `sharedFlowGateway.create` adapter that calls an application-authenticated host
428
+ endpoint. It returns `{ sharedFlowId, url, expiresAt }`; Domain0 validates the
429
+ capability-fragment URL and expiry, copies it, then emits `shared_flow_sent`.
430
+ Application tokens must never be placed in this browser gateway.
431
+
432
+ `manualSetupDocumentation` replaces the manual screen's generic provider guide
433
+ with a bounded, credential-free HTTPS application guide. It renders as a native
434
+ anchor with descriptive visible text, explicit new-tab wording, and
435
+ `noopener noreferrer`; activation still emits
436
+ `manual_setup_documentation_click`:
437
+
438
+ ```ts
439
+ await domain0.connectDomain({
440
+ client,
441
+ connectionId,
442
+ manualSetupDocumentation: 'https://docs.example.com/dns/setup',
443
+ })
444
+ ```
445
+
446
+ Set `whiteLabel.copy.manuallyScreen.disableManualSetupDocumentationLink` to
447
+ replace navigation with a native **Get step-by-step DNS help** action. The
448
+ action is rendered only when `onEvent` or
449
+ `onManualSetupDocumentationClick` can handle it, so the UI never presents an
450
+ inert link-shaped control.
451
+
452
+ ### White-label theme and copy
453
+
454
+ `connectDomain()` and `loadSharedFlow()` accept the same `whiteLabel` input.
455
+ The exported `Domain0WhiteLabelSchema` is the runtime source of truth and
456
+ `Domain0WhiteLabelInput` is inferred from it. Theme values are semantic tokens,
457
+ not raw CSS:
458
+
459
+ ```ts
460
+ await domain0.connectDomain({
461
+ client,
462
+ connectionId,
463
+ applicationName: 'Example Hosting',
464
+ whiteLabel: {
465
+ logo: 'https://assets.example.com/logo.svg',
466
+ logoBackgroundColor: '#FFFFFF',
467
+ removeLogoBorder: false,
468
+ hideCompanyLogo: false,
469
+ hideCompanyName: false,
470
+ theme: {
471
+ colorMode: 'system',
472
+ light: {
473
+ primary: '#003366',
474
+ onPrimary: '#FFFFFF',
475
+ link: '#003366',
476
+ },
477
+ dark: {
478
+ primary: '#DBEAFE',
479
+ },
480
+ widthPx: 720,
481
+ dialogRadiusPx: 16,
482
+ buttonRadiusPx: 6,
483
+ inputRadiusPx: 6,
484
+ fontFamily: 'Inter, system-ui, sans-serif',
485
+ fontWeight: 400,
486
+ boldFontWeight: 650,
487
+ },
488
+ copy: {
489
+ initialSubtitle: {
490
+ en: 'Connect DNS without leaving this application.',
491
+ de: 'Verbinden Sie DNS, ohne diese Anwendung zu verlassen.',
492
+ },
493
+ providerLoginMessage: {
494
+ en: 'Continue securely with {PROVIDER}.',
495
+ de: 'Sicher mit {PROVIDER} fortfahren.',
496
+ },
497
+ successTitle: {
498
+ en: '{DOMAIN} is ready',
499
+ de: '{DOMAIN} ist bereit',
500
+ },
501
+ successDescription: {
502
+ en: 'Required DNS records for {DOMAIN} are live.',
503
+ de: 'Die erforderlichen DNS-Einträge für {DOMAIN} sind aktiv.',
504
+ },
505
+ successButton: { en: 'Done', de: 'Fertig' },
506
+ manuallyScreen: {
507
+ disableManualSetupDocumentationLink: false,
508
+ stepByStepGuide: {
509
+ en: 'Open our DNS setup guide',
510
+ de: 'Unsere DNS-Einrichtungsanleitung öffnen',
511
+ },
512
+ },
513
+ },
514
+ removeShareLogin: false,
515
+ skipCongratulationsScreen: false,
516
+ customProperties: {
517
+ manualConfiguration: { disableScreen: false },
518
+ providerLogin: {
519
+ forwardLink: { disable: false },
520
+ gotoManualLink: { disable: false },
521
+ },
522
+ },
523
+ },
524
+ })
525
+ ```
526
+
527
+ Each effective light and dark palette must keep body, secondary, link, error,
528
+ and button text at least 4.5:1 against its paired surface. Control boundaries
529
+ and focus indicators must reach 3:1. Colors are opaque `#RRGGBB` values;
530
+ the backdrop is `#RRGGBBAA`. Dimensions are bounded numbers and the font is a
531
+ validated local system-font stack, so the UI never evaluates caller-provided
532
+ CSS or fetches a font. Localized copy requires an `en` value, falls back to it
533
+ when the active locale is missing, is inserted only with `textContent`, rejects
534
+ HTML and control characters, and supports only `{PROVIDER}` or `{DOMAIN}` in
535
+ the fields that document those placeholders. A plain string remains a
536
+ backward-compatible shorthand for `{ en: value }`.
537
+
538
+ The behavior names above match Entri's current public configuration where
539
+ Domain0 has an equivalent safe action:
540
+
541
+ - `removeShareLogin` and `providerLogin.forwardLink.disable` suppress the
542
+ manual screen's shared-link handoff;
543
+ - `providerLogin.gotoManualLink.disable` removes entry into guided manual
544
+ setup and renders an explicit explanation;
545
+ - `manualConfiguration.disableScreen` blocks an already persisted
546
+ `manual_required` screen without rendering DNS records, provider guides, or
547
+ sharing; it does not rewrite or advance connection state; and
548
+ - `skipCongratulationsScreen` emits `step_change(active)` and `success`, closes
549
+ with reason `success`, and restores the host invoker without rendering the
550
+ success screen.
551
+
552
+ The combined `Domain0ConnectConfigurationSchema` rejects forced-manual mode
553
+ when manual entry or its screen is disabled, and rejects `enableDkim` with a
554
+ skipped success screen because that screen owns the DKIM guidance. Unknown
555
+ controls are rejected. In particular, Domain0 does not support Entri's
556
+ `existingRecords.disableScreen`: change-plan confirmation and the separate
557
+ destructive acknowledgement are never auto-confirmed.
558
+
559
+ `copy.manuallyScreen.stepByStepGuide` customizes the visible guide-link or
560
+ host-action label with the same strict locale-keyed English fallback as other
561
+ copy. Domain0 accepts plain text only and deliberately rejects Entri's embedded
562
+ `<link>` markup; native anchor/button semantics remain renderer-owned.
563
+
564
+ `logo` accepts only a bounded HTTPS URL without embedded credentials.
565
+ `hideCompanyLogo: true` prevents creation of the image element, so no logo
566
+ request occurs. A visible logo uses `referrerPolicy="no-referrer"`; this hides
567
+ the application URL but cannot hide the user's IP address or user agent from
568
+ the logo host. Self-host the asset on the application origin when that
569
+ disclosure is unacceptable. `applicationName` supplies visible company text
570
+ and the logo's accessible alternative when the name itself is hidden.
571
+ `logoBackgroundColor`, `removeLogoBorder`, `hideCompanyLogo`, and
572
+ `hideCompanyName` match Entri's current new-UI fields.
573
+
574
+ External font loading and native catalogs beyond English, Spanish, Brazilian
575
+ Portuguese, European Portuguese, French, and German remain
576
+ outside this contract pending their separate privacy and translation gates.
577
+
578
+ ### Forced manual setup
579
+
580
+ Use the typed setup policy when the host product must always show manual DNS
581
+ instructions, even for a provider with an automatic adapter:
582
+
583
+ ```ts
584
+ await domain0.connectDomain({
585
+ client,
586
+ connectionId,
587
+ setupPolicy: { mode: 'manual' },
588
+ })
589
+ ```
590
+
591
+ Domain0 already receives the exact domain and DNS intent from the server-created
592
+ connection, so it does not collect a duplicate `prefilledDomain` in the browser.
593
+ After provider confirmation, the UI calls the existing
594
+ `startManualConfiguration()` use case and renders the exact record table. It
595
+ does not call OAuth, accept provider credentials, prepare a plan, or apply DNS.
596
+ The user still explicitly reports completion and Domain0 still queries every
597
+ authoritative nameserver before activation.
598
+
599
+ Forced manual mode affects only a connection that has not entered an automatic
600
+ workflow. If an existing connection is already authorizing, planning, applying,
601
+ or recovering from an automatic provider timeout, the UI blocks that workflow
602
+ and asks the user to cancel and create a new manual connection. It never rewinds
603
+ or reinterprets persisted connection state.
604
+
605
+ The application token remains on the integrating server. The browser lifecycle
606
+ receives only a connection-scoped client, or resolves an opaque shared-flow
607
+ fragment directly:
608
+
609
+ ```ts
610
+ await domain0.loadSharedFlow({
611
+ apiBaseUrl: 'https://domain0.example.com',
612
+ url: window.location.href,
613
+ })
614
+ ```
615
+
616
+ For a provider whose evidence state is `domain_connect`, pass the integrating
617
+ application's onboarded template as `domainConnectTemplate`. The backend
618
+ checks support for that exact template and returns a reviewed provider handoff;
619
+ the UI never treats the handoff or user return as propagation success.
620
+
621
+ The package name is `domain0` and its license is Apache-2.0. Releases are
622
+ versioned through reviewed Changesets pull requests and published from the
623
+ verified GitHub Actions artifact with npm Trusted Publishing.
624
+ `bun run test:browser` builds the package and exercises native modal keyboard
625
+ behavior, dark mode, and 320 CSS-pixel reflow in pinned Chromium, Firefox, and
626
+ WebKit, including the high-level lifecycle; Forced Colors is emulated in
627
+ Chromium. Run the Domain0 repository's
628
+ remaining real-platform and assistive-technology checklist before treating a
629
+ packed SDK as accessibility-verified.