arcane-os 0.23.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0
4
+
5
+ - Read nonsecret mail settings from `arcane.config.json.mail` and provider keys
6
+ from `.arcane.env.json.mail`. Keep existing root keys, exact named profiles,
7
+ and TLS path settings working without migrating or rewriting either file.
8
+ - Let explicit CLI/API options override configuration, replace origin lists,
9
+ and apply listener defaults after file settings. Share configured profile,
10
+ sender and provider deadlines with `mail send` without requiring TLS paths.
11
+ - Preserve unrelated settings during credential updates and remove both selected
12
+ key representations on explicit deletion. Document configuration precedence,
13
+ origin rejection, startup, platform behavior and the purpose-gate review.
14
+
3
15
  ## 0.23.0
4
16
 
5
17
  - Rename the mail configuration file to `.arcane.env.json`. Upgrade existing
package/README.md CHANGED
@@ -19,15 +19,17 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.23.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.24.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
26
- The [mail gateway](docs/reference/mail.md) serves HTTPS with HTTP/2 on port 4433,
27
- using certificate paths from `.arcane.env.json` in the command's working
28
- directory, and defaults browser mail to
29
- `/v1/mail` on the current domain. Multiple applications can share
30
- one server with explicit allowed origins. Subscription verification is disabled
26
+ The [mail gateway](docs/reference/mail.md) serves HTTPS with HTTP/2 on port 4433
27
+ by default. Configure its host, port, origin list, certificate paths, and other
28
+ mail settings in `arcane.config.json.mail`; keep provider keys in the ignored
29
+ `.arcane.env.json.mail`. Both files belong in the command's working directory.
30
+ Existing root credential, profile, and TLS settings remain supported. Browser
31
+ mail defaults to `/v1/mail` on the current domain, and multiple applications can
32
+ share one server with explicit allowed origins. Subscription verification is disabled
31
33
  until a `verifySubscription` callback is configured; that callback receives the
32
34
  application name and bearer subscription key before each provider attempt.
33
35
  See the [method and action gate report](docs/reviews/mail-server-purpose-review.md)
@@ -272,31 +272,78 @@ committed acceptance result.
272
272
 
273
273
  ## Operate the CLI and gateway
274
274
 
275
- Create `.arcane.env.json` in the directory from which the mail command runs, then fill
276
- in the provider key and the HTTPS certificate paths:
275
+ Keep app-supplied SDK settings under named capability members, starting with
276
+ `mail`. Put the nonsecret mail settings in `arcane.config.json` in the directory
277
+ from which the command runs:
277
278
 
278
279
  ```json
279
280
  {
280
- "RESEND_API_KEY": "",
281
- "MAIL_TLS_CERT_PATH": "",
282
- "MAIL_TLS_KEY_PATH": ""
281
+ "mail": {
282
+ "host": "0.0.0.0",
283
+ "port": 4433,
284
+ "origins": [
285
+ "https://dragons.example",
286
+ "https://www.dragons.example"
287
+ ],
288
+ "profile": "mail",
289
+ "certPath": "certificates/fullchain.pem",
290
+ "keyPath": "certificates/private-key.pem"
291
+ }
292
+ }
293
+ ```
294
+
295
+ Put the Resend provider key in the separate `.arcane.env.json`:
296
+
297
+ ```json
298
+ {
299
+ "mail": {
300
+ "apiKey": ""
301
+ }
283
302
  }
284
303
  ```
285
304
 
286
305
  The SDK repository ignores `.arcane.env.json`. Keep the same entry in a consuming
287
- project's `.gitignore`. This is a JSON configuration file; the mail commands
288
- read it directly without copying its contents into `process.env`.
306
+ project's `.gitignore`. `arcane.config.json` contains settings suitable for source
307
+ control; its certificate fields contain file paths, never PEM contents or provider
308
+ keys. Both files are read directly as JSON without copying values into
309
+ `process.env`. Other top-level capability members remain untouched. The portable
310
+ browser `arcane-os/mail` import does not read these Node-side files.
289
311
 
290
- The default profile is `mail`, which selects top-level `RESEND_API_KEY`.
291
- The explicit `--profile mail` form selects the same setting. Other profile names
292
- select exact entries under `MAIL_PROFILES`:
312
+ The supported `arcane.config.json.mail` fields are:
313
+
314
+ | Field | Type | Purpose and default |
315
+ | --- | --- | --- |
316
+ | `host` | string | Listener bind address; defaults to `0.0.0.0`. |
317
+ | `port` | integer | Listener port; defaults to `4433`. Explicit `0` selects an available port. |
318
+ | `origins` | string array | Exact allowed browser origins. An absent or empty list uses the current request authority as described below. |
319
+ | `profile` | string | Resend credential profile for send/serve; defaults to `mail`. |
320
+ | `from` | string | Optional shared sender override. Omit it to retain each report's sender or provider template default. |
321
+ | `appId` | string | Optional server event label; does not restrict incoming application names. |
322
+ | `recipientAllowlist` | string array | Optional allowed recipients; absent or empty means unrestricted recipients. |
323
+ | `errorRecipients` | string array | Error-report fallback recipients; defaults to the effective recipient allowlist. An explicit empty array supplies no fallback. |
324
+ | `bodyTimeoutMs` | integer or null | Optional request-body deadline in milliseconds; absent or null adds no deadline. |
325
+ | `providerTimeoutMs` | integer or null | Optional provider deadline in milliseconds; absent or null adds no deadline. |
326
+ | `retryableDelayMs` | positive integer | Retry guidance in a retryable result; defaults to `1000`. It does not schedule a retry. |
327
+ | `certPath` | string | PEM certificate-chain file path, required for gateway HTTPS. |
328
+ | `keyPath` | string | PEM private-key file path, required for gateway HTTPS. |
329
+
330
+ Resend is the supported provider; no provider selector is needed. Keep callbacks,
331
+ injected providers, `fetchImpl`, `onEvent`, `requestIdFactory`,
332
+ `verifySubscription`, and `AbortSignal` values in programmatic options. They are
333
+ runtime inputs, not JSON settings. Reports and their idempotency keys remain
334
+ inputs to each send operation.
335
+
336
+ The default credential profile `mail` selects `.arcane.env.json.mail.apiKey`.
337
+ Other profile names select exact entries in `.arcane.env.json.mail.profiles`:
293
338
 
294
339
  ```json
295
340
  {
296
- "RESEND_API_KEY": "",
297
- "MAIL_PROFILES": {
298
- "another-provider-account": {
299
- "RESEND_API_KEY": ""
341
+ "mail": {
342
+ "apiKey": "",
343
+ "profiles": {
344
+ "another-provider-account": {
345
+ "apiKey": ""
346
+ }
300
347
  }
301
348
  }
302
349
  }
@@ -304,19 +351,50 @@ select exact entries under `MAIL_PROFILES`:
304
351
 
305
352
  An absent named profile does not fall back to the default key. The profile
306
353
  selects Resend provider credentials; it is separate from the incoming
307
- application name and subscriber key.
354
+ application name and subscriber key. Existing top-level `RESEND_API_KEY` and
355
+ `MAIL_PROFILES[profile].RESEND_API_KEY` remain supported. A nested selected
356
+ `apiKey` takes precedence when the property exists, including null or an empty
357
+ string, which means the selected key is absent. Only an absent nested key
358
+ property permits fallback to the corresponding legacy key.
308
359
 
309
- Programmatic operations resolve `.arcane.env.json` from `options.cwd`, then
360
+ Programmatic operations resolve both files from `options.cwd`, then
310
361
  `options.workspaceRoot`, then `process.cwd()`, choosing the first supplied
311
362
  directory. The CLI uses its invocation directory. There is no upward directory
312
363
  search or dependency on a Windows installation directory or temporary-directory
313
- environment variable.
364
+ environment variable. Missing files are optional configuration sources; send and
365
+ serve still report their missing required values before attempting delivery or
366
+ binding. Malformed or unreadable files produce an error.
367
+
368
+ Configuration precedence is explicit:
369
+
370
+ 1. A CLI/API option overrides its file setting when its value is not `undefined`.
371
+ An explicit null retains the option's existing meaning; it does not select
372
+ the file value again.
373
+ 2. `arcane.config.json.mail` supplies nonsecret settings absent from those options.
374
+ 3. Legacy `.arcane.env.json` root `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH`
375
+ supply certificate paths absent from the selected options and config member.
376
+ 4. Remaining settings use the defaults above.
377
+
378
+ The existing programmatic aliases `origin`, `allowTo`, `errorTo`, and
379
+ `requestTimeout` take precedence over their corresponding canonical options
380
+ `origins`, `recipientAllowlist`, `errorRecipients`, and `providerTimeoutMs` when
381
+ both are supplied. The CLI continues to expose `--origin`, `--allow-to`, and
382
+ `--request-timeout`. `origin` accepts a string or an array; the recipient aliases
383
+ accept address arrays or comma-separated strings. Lists replace the lower-priority
384
+ list completely. The configuration reader does not concatenate or deduplicate
385
+ lists, rewrite case, or automatically add local addresses.
386
+
387
+ Use the JSON `origins` array for multiple origins. Repeating the current
388
+ `--origin` option keeps only its last value; there is no `--origins` CLI option.
389
+ The CLI leaves omitted host, port, and send/serve profile options unset until
390
+ configuration resolves, so its defaults do not mask file settings.
314
391
 
315
392
  Keep the configuration in the deployment directory even when the SDK is nested
316
393
  below it:
317
394
 
318
395
  ```text
319
396
  my-site/
397
+ ├── arcane.config.json
320
398
  ├── .arcane.env.json
321
399
  └── arcane-os-sdk/
322
400
  └── bin/arcane.mjs
@@ -325,13 +403,15 @@ my-site/
325
403
  Run from `my-site`, for example:
326
404
 
327
405
  ```sh
328
- node ./arcane-os-sdk/bin/arcane.mjs mail serve --port 4433
406
+ node ./arcane-os-sdk/bin/arcane.mjs mail serve
329
407
  ```
330
408
 
331
409
  The SDK directory does not choose the configuration location. When upgrading
332
410
  from SDK 0.22.1 or earlier, rename the existing `.env.json` to
333
411
  `.arcane.env.json` in the invocation directory, preserving its contents.
334
- The loader reads only `.arcane.env.json`.
412
+ The secret loader reads only `.arcane.env.json`; it does not read the old filename.
413
+ Existing root key, profile, and TLS fields can remain in that file. Adopting the
414
+ capability members does not perform an automatic rewrite or migration.
335
415
 
336
416
  The existing key commands manage the same file:
337
417
 
@@ -343,8 +423,14 @@ arcane mail key delete
343
423
 
344
424
  `key set` prompts with hidden input. `--secret-stdin` is the explicit
345
425
  non-interactive alternative and rejects a TTY. Each command accepts an optional
346
- profile argument, defaulting to `mail`. Set and delete preserve other JSON
347
- settings and profiles; status reports existence without returning the key.
426
+ profile argument, defaulting to `mail` independently of `arcane.config.json.mail.profile`.
427
+ Set and delete preserve other JSON settings and profiles; status reports
428
+ existence without returning the key. A new credential is written to the nested
429
+ mail member. An existing legacy credential is updated at its existing location
430
+ unless the selected nested key property exists, in which case set updates that
431
+ nested property. Delete removes both representations of only the selected key,
432
+ so an older key cannot reappear through fallback. Other settings and profile
433
+ containers remain intact.
348
434
  Results identify `storage: '.arcane.env.json'`. An already-absent deletion succeeds
349
435
  with `exists: false`.
350
436
 
@@ -379,19 +465,28 @@ cancellation after the provider attempt begins is returned as an ambiguous
379
465
  nonzero outcome because the provider may already have accepted the request.
380
466
  Cancellation before the attempt exits 130 without sending.
381
467
  For both CLI mail operations, `--request-timeout` accepts 1 through 2147483647
382
- milliseconds, the Node timer range. When omitted, the SDK adds no provider
383
- deadline.
468
+ milliseconds, the Node timer range. The same range applies to configured body
469
+ and provider deadlines. When no provider timeout is selected in options or
470
+ configuration, the SDK adds no provider deadline.
471
+
472
+ `mail send` consumes the selected profile, sender, provider timeout, and retry
473
+ guidance from the same configuration. It does not require gateway TLS paths.
474
+ Send and serve read each required JSON file once, concurrently when both are
475
+ needed, before consuming their settings. An injected `readCredential` remains
476
+ the credential owner and reads once. With that injection, send reads only
477
+ `arcane.config.json`; serve also reads `.arcane.env.json` for legacy TLS paths
478
+ without interpreting its unused file credential.
384
479
 
385
480
  Start the gateway:
386
481
 
387
482
  ```text
388
- npm exec -- arcane mail serve --profile mail --host 0.0.0.0 --port 4433
483
+ npm exec -- arcane mail serve
389
484
  ```
390
485
 
391
- The default listener is `0.0.0.0:4433`; `--host` and `--port` select its bind
392
- address and port. The server can serve callers from multiple domains on the
393
- same machine. Route the page's `/v1/mail` to this listener, or configure an
394
- explicit shared endpoint in the caller.
486
+ The default listener is `0.0.0.0:4433`; `mail.host` and `mail.port` select its
487
+ configured bind address and port, and explicit `--host` / `--port` override them.
488
+ The server can serve callers from multiple domains on the same machine.
489
+ Configure the caller's endpoint to reach that listener.
395
490
 
396
491
  `mail serve` uses HTTPS with HTTP/2 on that selected port. The published
397
492
  `node-http-server` PEM API owns TLS and negotiates HTTP/2 or HTTP/1.1 on the
@@ -399,25 +494,29 @@ same listener. It creates no additional plain-HTTP listener. The returned URL
399
494
  uses `https://`; `0.0.0.0` is the bind address, so callers use the deployed
400
495
  domain, for example `https://mail.example.com:4433/v1/mail`.
401
496
 
402
- Set `MAIL_TLS_CERT_PATH` to the PEM certificate chain and `MAIL_TLS_KEY_PATH`
403
- to its PEM private-key file. These top-level settings belong to the listener
404
- and apply regardless of the selected provider profile. Relative paths resolve
405
- from the directory containing `.arcane.env.json`; absolute paths are also accepted.
497
+ Set `arcane.config.json.mail.certPath` to the PEM certificate chain and
498
+ `mail.keyPath` to its PEM private-key file. These settings belong to the listener
499
+ and apply regardless of the selected provider profile. The legacy root
500
+ `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH` fields in `.arcane.env.json` remain
501
+ fallbacks. Relative certificate paths resolve from the selected configuration
502
+ directory, including explicit programmatic path options; absolute paths are also accepted.
406
503
  The certificate must cover the hostname callers use. One certificate may
407
504
  cover multiple names; the gateway does not require one certificate per calling
408
505
  application. Keep private-key files outside tracked source, such as in the
409
506
  already-ignored `.arcane/` directory or an existing host certificate directory.
410
507
 
411
- Startup reads the JSON configuration once, reports missing TLS settings before
508
+ Startup reads each JSON file once, reports missing TLS settings before
412
509
  binding, and lets the TLS owner report unreadable or unusable PEM files. It
413
510
  does not generate certificates, modify system trust, or add a renewal watcher.
511
+ No JSON configuration file is reread for an incoming HTTP request.
414
512
  Restart the gateway after the configured certificate files are renewed.
415
513
  The same Node file and TLS APIs are used on Windows, Linux, and macOS; Android
416
514
  requires a compatible Node host and accessible configuration and certificate
417
515
  paths. These platform contracts are separate from actual platform execution.
418
516
 
419
517
  The public `createToolchain().mail({action: 'serve', ...options})` operation
420
- uses the same JSON certificate pair. Internally, `startResendMailServer` accepts
518
+ uses the same settings with explicit options taking precedence. Internally,
519
+ `startResendMailServer` accepts
421
520
  `certPath` and `keyPath` and retains its existing HTTP behavior when neither
422
521
  is supplied. That internal function is not an npm package export.
423
522
 
@@ -429,18 +528,25 @@ The existing listener remains running; this launch does not retry or select
429
528
  another port.
430
529
 
431
530
  `--app` is an optional server event label and does not restrict incoming
432
- application names. `--from` is an optional shared sender override; omit it to
433
- preserve each report's sender or its provider template's default.
434
- `--origin` selects an exact allowed caller origin; the
435
- programmatic `origin` option also accepts an array for multiple origins. With
436
- no origins configured, the gateway accepts an Origin matching its request
531
+ application names. `--from` and `mail.from` are optional shared sender overrides;
532
+ omit both to preserve each report's sender or its provider template's default.
533
+ `mail.origins` supplies an array of exact allowed caller origins. An explicit
534
+ `--origin` replaces that array; the programmatic `origin` alias also accepts an
535
+ array. With no origins configured, the gateway accepts an Origin matching its request
437
536
  authority (`:authority` for HTTP/2, `Host` for HTTP/1.1) using HTTP or HTTPS.
438
- Requests without Origin continue normally.
537
+ An Origin outside the configured list or current-authority default receives
538
+ `403 mail_origin_not_allowed`. Origin strings are compared exactly: include the
539
+ scheme and any nondefault port, with no path or trailing slash. There are no
540
+ wildcards, subdomain expansion, normalization, or loopback exceptions.
541
+ Requests without an `Origin` header continue normally. This is a declared-origin
542
+ CORS list, not a client-IP or connecting-machine allowlist; ordinary
543
+ server-to-server requests commonly omit Origin.
439
544
  Cross-origin preflight permits `Content-Type`, `Idempotency-Key`, `X-Mail-App`,
440
- and `Authorization`. This is origin configuration, not a loopback policy.
545
+ and `Authorization`.
441
546
 
442
- `--allow-to` explicitly limits recipients when supplied. With it omitted, the
443
- gateway imposes no recipient allowlist. When configured, the list applies to
547
+ `mail.recipientAllowlist` or explicit `--allow-to` limits recipients when
548
+ configured. An absent or empty effective list imposes no recipient allowlist.
549
+ When configured, the list applies to
444
550
  every recipient in the resolved `to`, `cc`, and `bcc` fields, whether supplied
445
551
  as a string or an array. Sender, recipient, subject, and body
446
552
  values are not trimmed, lowercased, or filtered by an SDK email grammar at
@@ -457,3 +457,53 @@ payloads, profiles, other settings and lifecycle remain unchanged.
457
457
 
458
458
  Existing credential and CLI test fixtures now use the selected name. Source
459
459
  and diff were reviewed; local tests, checks and server execution were not run.
460
+
461
+ ## Capability configuration and secret separation follow-up
462
+
463
+ The selected outcome is one app-authored mail configuration in
464
+ `arcane.config.json.mail`, with Resend credentials in ignored
465
+ `.arcane.env.json.mail`. The SDK owns reading and applying these settings through
466
+ the existing CLI/toolchain mail operation. Domains, senders, certificates, and
467
+ provider accounts remain deployment-owned data. This increment begins the
468
+ named-capability configuration convention with mail; it does not migrate other
469
+ capabilities or consumer files.
470
+
471
+ The source audit found no existing `arcane.config.json` reader to reuse. The
472
+ workspace loader owns `arcane-packager.json`, descriptors, and application
473
+ layout. Requiring that loader to start a mail listener would add unrelated work.
474
+ The existing mail JSON reader is the reusable boundary for the two selected
475
+ files. Their explicit precedence and credential compatibility rules are in the
476
+ [mail reference](../reference/mail.md#operate-the-cli-and-gateway).
477
+
478
+ | Method or action | Gates | Decision, callers, and concrete purpose |
479
+ | --- | --- | --- |
480
+ | `readMailConfiguration` | Y/Y/N | Keep the cohesive configuration at the SDK owner. Send/serve read each required file once, in parallel when both are needed. A send with an injected credential reader skips the unused secrets file; incoming HTTP requests use the resolved configuration without file reads. Other capability settings stay untouched. |
481
+ | CLI's early default host, port, and send/serve profile values | Y/Y/Y | Remove premature default assignment. The same defaults remain after file configuration resolves, while explicit CLI values retain priority. Assigning defaults before reading JSON would conceal the deployment's chosen settings. |
482
+ | `origins` string array and list replacement | Y/Y/N | Keep one exact list for multiple caller domains. Explicit options replace the file list, including empty arrays. Existing `origin`, `allowTo`, `errorTo`, and `requestTimeout` aliases remain compatible and win over their canonical programmatic names when both are supplied. No normalization or list-merging helper is needed. |
483
+ | Root provider-key, named-profile, and TLS compatibility | Y/Y/N | Preserve live deployments using `RESEND_API_KEY`, `MAIL_PROFILES`, `MAIL_TLS_CERT_PATH`, and `MAIL_TLS_KEY_PATH`. Nested selected key presence takes priority even when null or empty. Named profiles never select a different account's default key. |
484
+ | Key set/status/delete operations | Y/Y/N | Preserve credential management and secret-free status. New keys use the nested member; existing legacy keys are updated in place unless a nested key exists. Delete removes both selected representations to prevent an old credential reappearing, preserving other keys, settings, and profile containers. Key commands still default to `mail` independently of the serving profile. |
485
+ | Send configuration | Y/Y/N | Use the same profile, sender, provider-timeout, and retry-guidance settings for direct sending. A send needs provider authority and its report, so listener certificates remain a serve-only prerequisite. Reports and idempotency keys remain per-operation inputs. |
486
+ | Explicit runtime dependencies and callbacks | Y/Y/N | Preserve `readCredential`, provider injection, observation, cancellation, and subscription callbacks as programmatic inputs. A function or signal is not JSON configuration. The portable browser mail import retains its existing dependency boundary. |
487
+ | Shared sender omission | Y/Y/N | Preserve per-report `from` and provider-template sender selection when no shared override is configured. One listener can therefore serve independent application sender identities. |
488
+ | Origin rejection behavior and explanation | Y/Y/N | Document the existing `403 mail_origin_not_allowed`, exact current-authority default, acceptance without Origin, and absence of IP filtering or loopback exceptions. This clarifies the retained behavior without adding admission policy. |
489
+ | New provider selector, per-field helper hierarchy, consumer parser, CLI-generation layer, automatic migration, watcher, and request-time config reads | N/N/Y | Add none. Resend is the sole implemented provider, the selected settings fit one owner, and existing CLI/API options express overrides. These additions would increase work without changing the requested outcome. |
490
+
491
+ The operation graph is at most two asynchronous JSON reads and one selected
492
+ credential per send/serve invocation, followed by its existing provider attempt
493
+ or listener lifecycle. A send with an injected credential reader reads only
494
+ `arcane.config.json` and calls that reader once. Key operations retain one
495
+ credential-file read and a write only when
496
+ the requested set/delete needs one. Certificate contents are still loaded by
497
+ the published HTTP server module. No dependency, process, polling loop, cache,
498
+ or application/host fan-out is introduced. These are source-level work counts,
499
+ not measured performance claims.
500
+
501
+ The compatibility audit traces the CLI into `executeMailCommand`, the public
502
+ `createToolchain().mail(...)` / `executeOperation('mail', ...)` entry points,
503
+ credential CRUD, direct send, and serve. The complete report and provider-result
504
+ paths are preserved. Configuration paths use the same portable Node filesystem
505
+ contract on Windows, Linux, and macOS, with a compatible Node host as the Android
506
+ adaptation. Local tests, checks, server launches, live mail sends, and platform
507
+ execution were not performed by this documentation author. Selected package
508
+ verification and publication outcomes belong to the release owner's delivery
509
+ record.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/cli/main.mjs CHANGED
@@ -86,7 +86,7 @@ Usage:
86
86
  ${CLI_NAME} mail key delete [profile]
87
87
  ${CLI_NAME} mail send [--profile <profile>] [--from <address>] --report-key <id> --report-stdin [--request-timeout <ms>]
88
88
  ${CLI_NAME} mail serve [--profile <profile>] [--from <address>] [--app <label>] [--origin <origin>] [--allow-to <addresses>] [--host 0.0.0.0] [--port 4433] [--request-timeout <ms>]
89
- HTTPS/HTTP2; .arcane.env.json supplies RESEND_API_KEY, MAIL_TLS_CERT_PATH, and MAIL_TLS_KEY_PATH.
89
+ HTTPS/HTTP2; arcane.config.json.mail supplies settings; .arcane.env.json supplies keys.
90
90
 
91
91
  Development:
92
92
  --public Bind dev to all IPv4 interfaces (0.0.0.0) and print network URLs.
@@ -692,13 +692,13 @@ function operationOptions(command,parsed,cwd){
692
692
  return {
693
693
  action:'serve',
694
694
  cwd,
695
- profile:values.profile??'mail',
695
+ profile:values.profile,
696
696
  from:values.from,
697
697
  appId:values.app,
698
698
  origin:values.origin,
699
699
  allowTo:values['allow-to'],
700
- host:values.host??'0.0.0.0',
701
- port:readPort(values.port,4433),
700
+ host:values.host,
701
+ port:readPort(values.port),
702
702
  requestTimeout:readMailRequestTimeout(values['request-timeout']),
703
703
  };
704
704
  }
@@ -721,7 +721,7 @@ function operationOptions(command,parsed,cwd){
721
721
  return {
722
722
  action:'send',
723
723
  cwd,
724
- profile:values.profile??'mail',
724
+ profile:values.profile,
725
725
  from:values.from,
726
726
  reportKey:values['report-key'],
727
727
  reportStdin:true,
@@ -41,38 +41,63 @@ async function readMailSettings(filePath,signal){
41
41
  return settings;
42
42
  }
43
43
 
44
- function mailProfileSettings(settings,location){
44
+ function mailProfileSettings(settings,location,nested=false){
45
+ const source=nested?settings.mail:settings;
46
+ if(source===undefined)return undefined;
47
+ if(!source||!is.object(source)||is.array(source)){
48
+ throw new ArcaneError(ERROR_CODES.usage,`mail in ${location.filePath} must be an object.`);
49
+ }
45
50
  if(location.profile==='mail'){
46
- return settings;
51
+ return source;
47
52
  }
48
- if(settings.MAIL_PROFILES===undefined){
53
+ const profiles=nested?source.profiles:source.MAIL_PROFILES;
54
+ const label=nested?'mail.profiles':'MAIL_PROFILES';
55
+ if(profiles===undefined){
49
56
  return undefined;
50
57
  }
51
- if(!settings.MAIL_PROFILES||!is.object(settings.MAIL_PROFILES)||is.array(settings.MAIL_PROFILES)){
52
- throw new ArcaneError(ERROR_CODES.usage,`MAIL_PROFILES in ${location.filePath} must be an object.`);
58
+ if(!profiles||!is.object(profiles)||is.array(profiles)){
59
+ throw new ArcaneError(ERROR_CODES.usage,`${label} in ${location.filePath} must be an object.`);
53
60
  }
54
- if(!Object.hasOwn(settings.MAIL_PROFILES,location.profile)){
61
+ if(!Object.hasOwn(profiles,location.profile)){
55
62
  return undefined;
56
63
  }
57
- const profileSettings=settings.MAIL_PROFILES[location.profile];
64
+ const profileSettings=profiles[location.profile];
58
65
  if(!profileSettings||!is.object(profileSettings)||is.array(profileSettings)){
59
66
  throw new ArcaneError(
60
67
  ERROR_CODES.usage,
61
- `MAIL_PROFILES[${JSON.stringify(location.profile)}] in ${location.filePath} must be an object.`
68
+ `${label}[${JSON.stringify(location.profile)}] in ${location.filePath} must be an object.`
62
69
  );
63
70
  }
64
71
  return profileSettings;
65
72
  }
66
73
 
74
+ function mailCredentialEntry(settings, location) {
75
+ const nestedSettings = mailProfileSettings(settings, location, true);
76
+ const nestedEntry = {
77
+ profileSettings: nestedSettings,
78
+ key: 'apiKey',
79
+ setting: location.profile === 'mail'
80
+ ? 'mail.apiKey'
81
+ : `mail.profiles[${JSON.stringify(location.profile)}].apiKey`
82
+ };
83
+ if (nestedSettings && Object.hasOwn(nestedSettings, 'apiKey')) return nestedEntry;
84
+ const legacySettings = mailProfileSettings(settings, location);
85
+ if (legacySettings && Object.hasOwn(legacySettings, 'RESEND_API_KEY')) {
86
+ return {profileSettings: legacySettings, key: 'RESEND_API_KEY', setting: location.setting};
87
+ }
88
+ return nestedEntry;
89
+ }
90
+
67
91
  function configuredMailKey(settings,location){
68
- const apiKey=mailProfileSettings(settings,location)?.RESEND_API_KEY;
92
+ const entry=mailCredentialEntry(settings,location);
93
+ const apiKey=entry.profileSettings?.[entry.key];
69
94
  if(apiKey===undefined||apiKey===null||apiKey===''){
70
95
  return null;
71
96
  }
72
97
  if(!is.string(apiKey)){
73
98
  throw new ArcaneError(
74
99
  ERROR_CODES.usage,
75
- `${location.setting} in ${location.filePath} must be a string.`
100
+ `${entry.setting} in ${location.filePath} must be a string.`
76
101
  );
77
102
  }
78
103
  return apiKey;
@@ -88,16 +113,22 @@ export async function setMailCredential(options={}){
88
113
  throw new ArcaneError(ERROR_CODES.usage,'The Resend API key must be a nonempty string.');
89
114
  }
90
115
  const settings=await readMailSettings(location.filePath,options.signal);
91
- const profileSettings=mailProfileSettings(settings,location);
92
- const updatedProfile={...profileSettings,RESEND_API_KEY:options.secret};
93
- const updatedSettings=location.profile==='mail'
94
- ?updatedProfile
95
- :{
96
- ...settings,
97
- MAIL_PROFILES:{...settings.MAIL_PROFILES,[location.profile]:updatedProfile}
98
- };
116
+ const entry=mailCredentialEntry(settings,location);
117
+ if(entry.profileSettings){
118
+ entry.profileSettings[entry.key]=options.secret;
119
+ }else{
120
+ settings.mail??={};
121
+ if(location.profile==='mail'){
122
+ settings.mail.apiKey=options.secret;
123
+ }else{
124
+ settings.mail.profiles={
125
+ ...settings.mail.profiles,
126
+ [location.profile]:{apiKey:options.secret}
127
+ };
128
+ }
129
+ }
99
130
  throwIfAborted(options.signal);
100
- await writeFile(location.filePath,`${JSON.stringify(updatedSettings,null,2)}\n`,{
131
+ await writeFile(location.filePath,`${JSON.stringify(settings,null,2)}\n`,{
101
132
  encoding:'utf8',
102
133
  mode:0o600,
103
134
  signal:options.signal
@@ -111,30 +142,58 @@ export async function readMailCredential(options={}){
111
142
  return configuredMailKey(settings,location);
112
143
  }
113
144
 
114
- export async function readMailServerSettings(options = {}) {
115
- const location = mailCredentialLocation(options);
116
- const settings = await readMailSettings(location.filePath, options.signal);
117
- const serverSettings = (options.readCredential ?? null) === null
118
- ? {apiKey: configuredMailKey(settings, location)}
119
- : {};
145
+ export async function readMailConfiguration(options = {}) {
146
+ const directory = path.resolve(options.cwd ?? options.workspaceRoot ?? process.cwd());
147
+ const configPath = path.join(directory, 'arcane.config.json');
148
+ const envPath = path.join(directory, '.arcane.env.json');
149
+ const readCredentialFromFile = (options.readCredential ?? null) === null;
150
+ const [config, secrets] = await Promise.all(
151
+ [
152
+ readMailSettings(configPath, options.signal),
153
+ options.action !== 'send' || readCredentialFromFile
154
+ ? readMailSettings(envPath, options.signal)
155
+ : {}
156
+ ]
157
+ );
158
+ const mailSettings = config.mail === undefined ? {} : config.mail;
159
+ if (!mailSettings || !is.object(mailSettings) || is.array(mailSettings)) {
160
+ throw new ArcaneError(ERROR_CODES.usage, `mail in ${configPath} must be an object.`);
161
+ }
162
+ const location = mailCredentialLocation(
163
+ {...options, profile: options.profile !== undefined ? options.profile : mailSettings.profile}
164
+ );
165
+ const configuration = {profile: location.profile};
166
+ for (const name of [
167
+ 'host', 'port', 'origins', 'from', 'appId', 'recipientAllowlist',
168
+ 'errorRecipients', 'bodyTimeoutMs', 'providerTimeoutMs', 'retryableDelayMs'
169
+ ]) {
170
+ if (mailSettings[name] !== undefined) configuration[name] = mailSettings[name];
171
+ }
172
+ if (readCredentialFromFile) {
173
+ configuration.apiKey = configuredMailKey(secrets, location);
174
+ }
175
+ // Sending a report does not consume listener certificate configuration.
176
+ if (options.action === 'send') return configuration;
120
177
  const tlsSettings = {
121
178
  MAIL_TLS_CERT_PATH: 'certPath',
122
179
  MAIL_TLS_KEY_PATH: 'keyPath'
123
180
  };
124
181
  for (const [setting, option] of Object.entries(tlsSettings)) {
125
- const value = settings[setting];
182
+ const value = options[option] !== undefined
183
+ ? options[option]
184
+ : mailSettings[option] !== undefined ? mailSettings[option] : secrets[setting];
126
185
  if (value === undefined || value === null || value === '') {
127
186
  continue;
128
187
  }
129
188
  if (!is.string(value)) {
130
189
  throw new ArcaneError(
131
190
  ERROR_CODES.usage,
132
- `${setting} in ${location.filePath} must be a PEM file path string.`
191
+ `mail.${option} (${setting}) must be a PEM file path string.`
133
192
  );
134
193
  }
135
- serverSettings[option] = path.resolve(path.dirname(location.filePath), value);
194
+ configuration[option] = path.resolve(directory, value);
136
195
  }
137
- return serverSettings;
196
+ return configuration;
138
197
  }
139
198
 
140
199
  export async function getMailCredentialStatus(options={}){
@@ -146,17 +205,18 @@ export async function getMailCredentialStatus(options={}){
146
205
  export async function deleteMailCredential(options={}){
147
206
  const location=mailCredentialLocation(options);
148
207
  const settings=await readMailSettings(location.filePath,options.signal);
149
- const profileSettings=mailProfileSettings(settings,location);
150
- if(profileSettings&&Object.hasOwn(profileSettings,'RESEND_API_KEY')){
151
- const {RESEND_API_KEY,...remainingSettings}=profileSettings;
152
- const updatedSettings=location.profile==='mail'
153
- ?remainingSettings
154
- :{
155
- ...settings,
156
- MAIL_PROFILES:{...settings.MAIL_PROFILES,[location.profile]:remainingSettings}
157
- };
208
+ let changed=false;
209
+ for(const nested of [true,false]){
210
+ const profileSettings=mailProfileSettings(settings,location,nested);
211
+ const key=nested?'apiKey':'RESEND_API_KEY';
212
+ if(profileSettings&&Object.hasOwn(profileSettings,key)){
213
+ delete profileSettings[key];
214
+ changed=true;
215
+ }
216
+ }
217
+ if(changed){
158
218
  throwIfAborted(options.signal);
159
- await writeFile(location.filePath,`${JSON.stringify(updatedSettings,null,2)}\n`,{
219
+ await writeFile(location.filePath,`${JSON.stringify(settings,null,2)}\n`,{
160
220
  encoding:'utf8',
161
221
  signal:options.signal
162
222
  });
package/src/mail.mjs CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  getMailCredentialStatus,
6
6
  mailCredentialLocation,
7
7
  readMailCredential,
8
- readMailServerSettings,
8
+ readMailConfiguration,
9
9
  setMailCredential
10
10
  } from './mail-credentials.mjs';
11
11
  import {sendResendMail,startResendMailServer} from './mail-server.mjs';
@@ -62,6 +62,12 @@ function mailCredentialOptions(options){
62
62
  };
63
63
  }
64
64
 
65
+ function configuredMailOption(options, settings, name, alias = name) {
66
+ if (options[alias] !== undefined) return options[alias];
67
+ if (options[name] !== undefined) return options[name];
68
+ return settings[name];
69
+ }
70
+
65
71
  async function setMailCredentialFromInput(options){
66
72
  const readSecret=resolveMailCommandDependency(options,'readSecret',null);
67
73
  const store=resolveMailCommandDependency(options,'setCredential',setMailCredential);
@@ -94,20 +100,23 @@ async function sendMailFromReport(options){
94
100
  throwIfAborted(options.signal);
95
101
  const report=await readReport();
96
102
  throwIfAborted(options.signal);
97
- let apiKey=await readMailProviderKey(options);
103
+ const readConfiguration=resolveMailCommandDependency(options,'readServerSettings',readMailConfiguration);
104
+ const mailSettings=await readConfiguration(options);
105
+ throwIfAborted(options.signal);
106
+ let apiKey=await readMailProviderKey(options,mailSettings);
98
107
  try{
99
108
  throwIfAborted(options.signal);
100
109
  const result=await send({
101
110
  apiKey,
102
111
  appId:'arcane-cli',
103
112
  fetchImpl:options.fetchImpl,
104
- from:options.from,
113
+ from:configuredMailOption(options,mailSettings,'from'),
105
114
  onEvent:options.onEvent,
106
- providerTimeoutMs:options.requestTimeout,
115
+ providerTimeoutMs:configuredMailOption(options,mailSettings,'providerTimeoutMs','requestTimeout'),
107
116
  report,
108
117
  reportKey:options.reportKey,
109
118
  requestIdFactory:options.requestIdFactory,
110
- retryableDelayMs:options.retryableDelayMs,
119
+ retryableDelayMs:configuredMailOption(options,mailSettings,'retryableDelayMs'),
111
120
  signal:options.signal
112
121
  });
113
122
  if(result?.classification==='accepted'&&result.status==='accepted'){
@@ -126,16 +135,21 @@ async function sendMailFromReport(options){
126
135
  }
127
136
  }
128
137
 
129
- async function readMailProviderKey(options, serverSettings){
130
- const credentialOptions=mailCredentialOptions(options);
131
- const apiKey = serverSettings !== undefined && (options.readCredential ?? null) === null
132
- ? serverSettings.apiKey
138
+ async function readMailProviderKey(options, mailSettings){
139
+ const credentialOptions=mailCredentialOptions(
140
+ {...options,profile:options.profile??mailSettings?.profile}
141
+ );
142
+ const apiKey = mailSettings !== undefined && (options.readCredential ?? null) === null
143
+ ? mailSettings.apiKey
133
144
  : await resolveMailCommandDependency(options, 'readCredential', readMailCredential)(credentialOptions);
134
145
  if(apiKey===null){
135
146
  const location=mailCredentialLocation(credentialOptions);
147
+ const nestedSetting = location.profile === 'mail'
148
+ ? 'mail.apiKey'
149
+ : `mail.profiles[${JSON.stringify(location.profile)}].apiKey`;
136
150
  throw new ArcaneError(
137
151
  ERROR_CODES.prerequisiteMissing,
138
- `Missing ${location.setting} in ${location.filePath}.`
152
+ `Missing ${location.setting} or ${nestedSetting} in ${location.filePath}.`
139
153
  );
140
154
  }
141
155
  if(!is.string(apiKey)||!apiKey){
@@ -149,11 +163,9 @@ async function readMailProviderKey(options, serverSettings){
149
163
 
150
164
  async function serveMailGateway(options){
151
165
  const startServer=resolveMailCommandDependency(options,'startServer',startResendMailServer);
152
- const readServerSettings = resolveMailCommandDependency(options, 'readServerSettings', readMailServerSettings);
166
+ const readServerSettings = resolveMailCommandDependency(options, 'readServerSettings', readMailConfiguration);
153
167
  throwIfAborted(options.signal);
154
- const serverSettings = await readServerSettings(
155
- {...mailCredentialOptions(options), readCredential: options.readCredential}
156
- );
168
+ const serverSettings = await readServerSettings(options);
157
169
  throwIfAborted(options.signal);
158
170
  let apiKey=await readMailProviderKey(options, serverSettings);
159
171
  try{
@@ -165,32 +177,36 @@ async function serveMailGateway(options){
165
177
  const location = mailCredentialLocation(options);
166
178
  throw new ArcaneError(
167
179
  ERROR_CODES.prerequisiteMissing,
168
- `Missing ${missingSettings.join(', ')} in ${location.filePath}. Mail HTTPS requires a certificate and private key.`
180
+ `Missing ${missingSettings.join(', ')} in ${location.filePath}, or the corresponding mail.certPath/mail.keyPath in arcane.config.json. Mail HTTPS requires a certificate and private key.`
169
181
  );
170
182
  }
171
- const recipientAllowlist=mailRecipientOptions(options.allowTo,'allowTo');
172
- const errorRecipients=options.errorTo===undefined
183
+ const recipientAllowlist=mailRecipientOptions(
184
+ configuredMailOption(options,serverSettings,'recipientAllowlist','allowTo'),'recipientAllowlist'
185
+ );
186
+ const errorTo=configuredMailOption(options,serverSettings,'errorRecipients','errorTo');
187
+ const errorRecipients=errorTo===undefined
173
188
  ? recipientAllowlist
174
- : mailRecipientOptions(options.errorTo,'errorTo');
189
+ : mailRecipientOptions(errorTo,'errorRecipients');
190
+ const origins=configuredMailOption(options,serverSettings,'origins','origin');
175
191
  return await startServer({
176
192
  apiKey,
177
- appId:options.appId,
178
- allowedOrigins:options.origin===undefined
193
+ appId:configuredMailOption(options,serverSettings,'appId'),
194
+ allowedOrigins:origins===undefined
179
195
  ?[]
180
- :is.array(options.origin)?[...options.origin]:[options.origin],
181
- bodyTimeoutMs:options.bodyTimeoutMs,
196
+ :is.array(origins)?[...origins]:[origins],
197
+ bodyTimeoutMs:configuredMailOption(options,serverSettings,'bodyTimeoutMs'),
182
198
  certPath:serverSettings.certPath,
183
199
  errorRecipients,
184
200
  fetchImpl:options.fetchImpl,
185
- from:options.from,
186
- host:options.host??'0.0.0.0',
201
+ from:configuredMailOption(options,serverSettings,'from'),
202
+ host:configuredMailOption(options,serverSettings,'host')??'0.0.0.0',
187
203
  keyPath:serverSettings.keyPath,
188
204
  onEvent:options.onEvent,
189
- port:options.port,
190
- providerTimeoutMs:options.requestTimeout,
205
+ port:configuredMailOption(options,serverSettings,'port'),
206
+ providerTimeoutMs:configuredMailOption(options,serverSettings,'providerTimeoutMs','requestTimeout'),
191
207
  recipientAllowlist,
192
208
  requestIdFactory:options.requestIdFactory,
193
- retryableDelayMs:options.retryableDelayMs,
209
+ retryableDelayMs:configuredMailOption(options,serverSettings,'retryableDelayMs'),
194
210
  signal:options.signal,
195
211
  verifySubscription:options.verifySubscription
196
212
  });