@taprootio/site-authoring 0.12.6 → 0.13.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.
@@ -20,6 +20,27 @@ import {
20
20
  SHIPPED_FIXTURE_NAME,
21
21
  shippedFixtureDirectory,
22
22
  } from "./fixture-contract.js";
23
+ import {
24
+ DATE_ANCHORS,
25
+ FIELD_TYPES,
26
+ MAX_AFTER_SUBMIT_MESSAGE_LENGTH,
27
+ MAX_CONSENT_LABEL_LENGTH,
28
+ MAX_DATE_OFFSET_DAYS,
29
+ MAX_FIELDS,
30
+ MAX_HINT_LENGTH,
31
+ MAX_SUBMIT_LABEL_LENGTH,
32
+ MAX_LABEL_LENGTH,
33
+ MAX_SUBMISSION_BYTES,
34
+ RELATIVE_BOUND_GRACE_DAYS,
35
+ } from "./field-validation.js";
36
+ import {
37
+ FORM_FILE_EXTENSION,
38
+ FORM_KEY_MAXIMUM_LENGTH,
39
+ FORM_LIMITS,
40
+ FORM_RETENTION_DAYS,
41
+ FORM_SINKS,
42
+ FORMS_DIRECTORY,
43
+ } from "./forms-contract.js";
23
44
  import {
24
45
  formatPresentationReference,
25
46
  getAppearanceReference,
@@ -50,7 +71,23 @@ import {
50
71
 
51
72
  export { getAppearanceReference, getFooterReference, getThemeReference };
52
73
 
53
- export const REFERENCE_VERSION = 28;
74
+ export const REFERENCE_VERSION = 32;
75
+
76
+ // The date-limit examples `help forms` prints; tests run them through the form validator.
77
+ export const FORM_DATE_WINDOW_FIELD = Object.freeze({
78
+ id: "visit_day",
79
+ type: "date",
80
+ label: "Day of your visit",
81
+ min: Object.freeze({ from: "today" }),
82
+ max: Object.freeze({ from: "today", offset_days: 30 }),
83
+ });
84
+ export const FORM_NEXT_MONTH_FIELD = Object.freeze({
85
+ id: "start_day",
86
+ type: "date",
87
+ label: "A day next month",
88
+ min: Object.freeze({ from: "start_of_next_month" }),
89
+ max: Object.freeze({ from: "end_of_next_month" }),
90
+ });
54
91
  export const PAGE_TYPES = Object.freeze(["free-form", ...TYPED_PAGE_TYPES]);
55
92
 
56
93
  function deepFreeze(value) {
@@ -363,6 +400,11 @@ export const REFERENCE_TOPICS = Object.freeze([
363
400
  usage: `${CLI_BINARY_NAME} help redirects`,
364
401
  summary: "Author redirects.json: entry shape, normalization, and the refusals.",
365
402
  }),
403
+ Object.freeze({
404
+ name: "forms",
405
+ usage: `${CLI_BINARY_NAME} help forms`,
406
+ summary: "Author a form as forms/<key>.json: file shape, field types, validate, pull, push, and placing it on a page.",
407
+ }),
366
408
  Object.freeze({
367
409
  name: "media",
368
410
  usage: `${CLI_BINARY_NAME} help media`,
@@ -526,7 +568,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
526
568
  `Pull the baseline: ${CLI_BINARY_NAME} pull writes settings/taproot-styles.json with both schemes' complete effective `
527
569
  + "themes (the stored theme resolved over the same defaults every consumer renders) beside brand.json, "
528
570
  + "site-header.json and site-publishing-preferences.json. Keep that pull as the baseline you edit; never build "
529
- + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens.",
571
+ + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens. "
572
+ + "site-publishing-preferences.json also carries timeZone, the site's IANA zone (UTC until the owner sets it), "
573
+ + "which decides what \"today\" means for relative form date bounds; it is read-only here, so ask the owner to set "
574
+ + "it in the editor under Site settings.",
530
575
  "Edit both schemes: settings.lightTheme and settings.darkTheme are separate documents. Declare anchors, then roles "
531
576
  + "(help theme lists the role slots and how anchor references resolve), tune typography per scheme, and keep "
532
577
  + "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
@@ -681,9 +726,110 @@ const WORKFLOW_REFERENCES = Object.freeze({
681
726
  ]),
682
727
  }),
683
728
  }),
729
+ forms: Object.freeze({
730
+ title: "Form workspace contract",
731
+ summary: `A form is one JSON file, ${FORMS_DIRECTORY}/<key>${FORM_FILE_EXTENSION}. 'forms validate' proves it offline, `
732
+ + "'forms push' creates or updates it on the site, and 'forms pull' reads the site's copy back.",
733
+ usage: `${CLI_BINARY_NAME} forms validate [key...] | ${CLI_BINARY_NAME} forms pull | ${CLI_BINARY_NAME} forms push [key...]`,
734
+ details: Object.freeze([
735
+ `Run 'pull' once first: 'forms pull' and 'forms push' need the pulled workspace's manifest, which records the `
736
+ + "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
737
+ `The file is { key, name, sink, retention_days, definition }. key must equal the file's name `
738
+ + `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
739
+ + `1 to ${FORM_LIMITS.nameCharacters} characters. sink is one of ${FORM_SINKS.join(", ")} and defaults to `
740
+ + "'none' (store submissions only); 'newsletter' also subscribes the contact address to the site's newsletter. "
741
+ + `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
742
+ + "submissions are deleted that many days after they arrive. Leave it out and the site chooses on creation (its "
743
+ + "plan's default, usually 365) and keeps the current value on an update. Shortening it deletes the submissions that fall outside it, "
744
+ + "so a key cannot do it; the site refuses it, and an owner or admin must make that change.",
745
+ `definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
746
+ + `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
747
+ + "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
748
+ + "field is not marked 'Required' and a visitor learns it from the error on submit. "
749
+ + `At most ${MAX_FIELDS} fields and ${MAX_SUBMISSION_BYTES} bytes `
750
+ + "per submission. There are no author-supplied regular expressions: use 'format' from its closed list. "
751
+ + "The exact constraints per type are in shared/field-schema.json, which 'forms validate' enforces by "
752
+ + "running the same validator the published form and the site use. Each refusal names the field path and a "
753
+ + "stable code such as duplicate_id or unknown_constraint.",
754
+ "A date field's min and max are each a fixed 'YYYY-MM-DD' date or a relative limit, { from, offset_days? }. "
755
+ + `from is one of ${DATE_ANCHORS.join(", ")}, the site's calendar date today or that day of this or next month; `
756
+ + `offset_days is a whole number of days from -${MAX_DATE_OFFSET_DAYS} to ${MAX_DATE_OFFSET_DAYS} (default 0; a `
757
+ + "year either way covers a leap year, and further out is a fixed date). There is no expression language. "
758
+ + "'Today' is the site's date in the site's time zone, which is UTC until the owner sets one in the editor's "
759
+ + "Site settings; 'pull' shows it as timeZone in settings/site-publishing-preferences.json, read-only here. "
760
+ + "The published form sets the date picker's limits when the page loads and checks them again when the visitor "
761
+ + `submits; the site lets a relative minimum reach back ${RELATIVE_BOUND_GRACE_DAYS} day so a visitor who loaded `
762
+ + "the page before midnight and submits after it is not refused for a date the page offered (a relative maximum "
763
+ + `and a fixed date are never loosened). Within the next 30 days: ${JSON.stringify(FORM_DATE_WINDOW_FIELD)}. `
764
+ + `A day next month: ${JSON.stringify(FORM_NEXT_MONTH_FIELD)}. A fixed and a relative limit may be mixed. 'forms `
765
+ + "validate' refuses an unknown anchor or an offset out of range as invalid_constraint, and a minimum after the "
766
+ + "maximum as min_exceeds_max only when both are fixed dates or both start from the same anchor; other pairs "
767
+ + "depend on the day and are not compared.",
768
+ `submit_label is the text on the submit button, up to ${MAX_SUBMIT_LABEL_LENGTH} characters; leave it out for "Send". `
769
+ + "after_submit says what the visitor sees once a response is sent. Leave it out, or write "
770
+ + `{ "show": "message" }, for Taproot's own confirmation; { "show": "message", "message": "..." } replaces `
771
+ + `its words (up to ${MAX_AFTER_SUBMIT_MESSAGE_LENGTH} characters, line breaks kept). { "show": "page", `
772
+ + `"page_path": "/thanks" } sends the visitor to that page on this site instead. The page is written as `
773
+ + "its path: a leading slash, no trailing slash, '/' for the home page. 'forms push' looks the page up "
774
+ + "and stores it by its resource id, so the form keeps pointing at the page if it is renamed, and 'forms "
775
+ + "pull' writes the page's current path back. A page_resource_id is accepted in place of page_path, and a "
776
+ + "pull keeps it only for a page that no longer exists. A file whose page_path names no page on the site is "
777
+ + "refused as forms.page_not_found, so push the page first. It need not be published to save the form, but "
778
+ + "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
779
+ + "each release is the one the page has in that release, so the published form never asks the site where "
780
+ + "to go. The page must be one you created, the home page included: a generated page (folder, tag or "
781
+ + "archive index), the 404 page, a legal page, a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
782
+ + "the 'form' component takes only formKey.",
783
+ "A form's contact address is the value of one email field, stored with each submission. That is what lets the "
784
+ + "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
785
+ + "contact address and the file leaves contact_field out (a file that names it anyway is accepted, and a pull "
786
+ + "writes it without). With two or more email fields, contact_field must name one of them; leaving it out is "
787
+ + "refused as contact_field_required. A form with no email field has no contact address, and contact_field "
788
+ + "there is refused as invalid_contact_field.",
789
+ `A consent field's label is the exact words the visitor agrees to, up to ${MAX_CONSENT_LABEL_LENGTH} characters `
790
+ + `(every other field's label is at most ${MAX_LABEL_LENGTH}); a form has no separate consent text. `
791
+ + "Each version freezes the labels, and every submission records the hash of each consent field's label as "
792
+ + "the visitor saw it, so a response shows what each box agreed to.",
793
+ "Editing a definition appends a version on the site; versions are never changed. A page keeps the form "
794
+ + "version it was published with until the next deploy, and a rollback restores the form it shipped with.",
795
+ "Both verbs write the file in one canonical key order, so a pulled file pushed back unchanged reports "
796
+ + "'unchanged' and leaves the bytes as they were. Write your own files in that order and a push rewrites "
797
+ + "nothing you did not change.",
798
+ "'forms push' validates every file and compares every form with the site before the first write. It refuses "
799
+ + "forms.concurrent_modification when the site's version moved since this workspace last read it, "
800
+ + "forms.pull_required when the site has a form with that key this workspace never pulled, and forms.archived "
801
+ + "when the form is archived on the site. Reconcile with 'forms pull' and retry; nothing was written.",
802
+ "Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
803
+ + "them on the site's Forms page.",
804
+ "To show a form, place the free-form component 'form' on a page (a component:form block) with formKey set "
805
+ + "to the form's key ('help component form' lists its properties). Push the form first and deploy: the release "
806
+ + "freezes the form it was built with.",
807
+ "The delegation.content capability carries site.forms.manage, which every forms verb needs: it covers "
808
+ + "definitions, settings and status. Visitors' submissions are in no capability: reading them "
809
+ + "(site.forms.submissions.read) and finding, marking or deleting them (site.forms.submissions.manage) belong "
810
+ + "to the site's owners and admins on the site's Forms page, never to a key. A refusal names the missing "
811
+ + "permission.",
812
+ ]),
813
+ example: Object.freeze({
814
+ key: "contact",
815
+ name: "Contact",
816
+ sink: "none",
817
+ retention_days: 365,
818
+ definition: Object.freeze({
819
+ fields: Object.freeze([
820
+ Object.freeze({ id: "name", type: "text", label: "Your name", required: true, max_length: 120 }),
821
+ Object.freeze({ id: "email", type: "email", label: "Email", required: true }),
822
+ Object.freeze({ id: "message", type: "long_text", label: "Message", required: true, max_length: 2000 }),
823
+ Object.freeze({ id: "agree", type: "consent", label: "I agree that you may store this message and reply to me." }),
824
+ ]),
825
+ submit_label: "Send message",
826
+ after_submit: Object.freeze({ show: "page", page_path: "/thanks" }),
827
+ }),
828
+ }),
829
+ }),
684
830
  media: Object.freeze({
685
831
  title: "Media upload contract",
686
- summary: "Upload workspace-root-relative raster files and receive component-ready media objects.",
832
+ summary: "Upload workspace-root-relative raster files and videos and receive component-ready media objects and video ids.",
687
833
  usage: `${CLI_BINARY_NAME} media upload [path...]`,
688
834
  details: Object.freeze([
689
835
  "Paths are relative to the configured workspace root, not the shell's current directory.",
@@ -692,6 +838,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
692
838
  "Prepare variants locally with any image tool before uploading: a transparent logo, its @2x copy, a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
693
839
  "Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the returned image ids; theme push writes them. See help walkthrough for the full design pass.",
694
840
  "Each result item includes media: { imageId, src, urls, width, height, alt }.",
841
+ "MP4, MOV, and WebM files are uploaded as videos (up to 250 MB each; the site's licence sets the cap, and the container is read from the file's bytes, not its name). The CLI reads each file with mediabunny. A file that is H.264 and AAC but not already an MP4 with its index first is rewritten as one without re-encoding; anything else is sent as it is and judged by the server. When the server refuses a file, its one-line message is printed as media.video_refused. A video is ready to play as soon as the upload is confirmed. The CLI sets no poster, because Node has no video decoder: the Videos page picks one automatically for a video uploaded there, and lets the owner choose another frame, so set a CLI-uploaded video's poster there. The command reports videos.items: { file, videoId, contentType, byteLength, deduplicated, durationMilliseconds, component }, where component.markdown is the component:video fence and component.block the componentBlock node that place it. Needs the site's licence to include video.",
842
+ "Place an uploaded video on a page with a video component: a component:video fence in Markdown or a componentBlock of type video, with the returned videoId and optional poster, caption, and aspectRatio. See help component video. pages push refuses a video that is not in the site's library; validate checks it against fixture.videoIds.",
695
843
  "The same src/urls delivery fields are saved in .taproot-site-media.json for page and component authoring.",
696
844
  ]),
697
845
  example: Object.freeze({
@@ -746,6 +894,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
746
894
  `Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
747
895
  `Optional root fields: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")}. Appearance and footer metadata, when present, `
748
896
  + "must match the local settings. Snapshot times and deployments are not live evidence. Other root fields are refused.",
897
+ `The optional forms root field is { items: { <key>: { id, version } } }: 'forms pull' also records a settings baseline, which a fixture does not carry. Each key `
898
+ + `names a ${FORMS_DIRECTORY}/<key>${FORM_FILE_EXTENSION} that must validate as in 'help forms', and no form file `
899
+ + "may sit beside them unbound.",
749
900
  `manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
750
901
  "siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
751
902
  + "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
@@ -776,7 +927,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
776
927
  SETTINGS_GROUPS.map((group) => `${group.settingsType} to '${SETTINGS_DIRECTORY}/${group.file}'`).join("; ")
777
928
  }.`,
778
929
  `The fixture block carries ${FIXTURE_METADATA_FIELDS.join(", ")}. Every image a page references by imageId must `
779
- + "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin.",
930
+ + "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin. "
931
+ + "videoIds is optional: list the ready videos a page may place with a video component, and validate refuses a "
932
+ + "video component whose videoId is not listed. A live push makes the same check against the site's video library.",
780
933
  `Each deliveryOrigins entry is an origin-only HTTPS URL (no path, query, fragment, or credentials) on `
781
934
  + `${FIXTURE_DELIVERY_ORIGIN_DOMAIN} or a subdomain of it, at most ${FIXTURE_MAXIMUM_DELIVERY_ORIGINS} of them, `
782
935
  + "with no duplicates. Fixtures are copied and shipped, so a real delivery host in one would be a live "
@@ -876,6 +1029,12 @@ export function getComponentReference(type) {
876
1029
  accessibility: definition.accessibility,
877
1030
  example,
878
1031
  markdownExample,
1032
+ ...(definition.authoring
1033
+ ? {
1034
+ authoringNotes: definition.authoring.notes,
1035
+ authoringMarkdownExample: definition.authoring.markdownExample,
1036
+ }
1037
+ : {}),
879
1038
  componentBlockExample: {
880
1039
  type: "componentBlock",
881
1040
  attrs: {
@@ -1692,7 +1851,11 @@ export function formatReferenceResult(result) {
1692
1851
  component.accessibility.map((note) => ` - ${note}`).join("\n")
1693
1852
  }\n\nValid componentData object:\n${
1694
1853
  JSON.stringify(component.example, null, 2)
1695
- }\n\nMarkdown component fence:\n${component.markdownExample}\n\nRemember: componentData stores JSON.stringify(the object), not the object itself. Use --json for the exact nested schema and a complete componentBlock example.\n`;
1854
+ }\n\nMarkdown component fence:\n${component.markdownExample}${
1855
+ component.authoringNotes === undefined
1856
+ ? ""
1857
+ : `\n\nAuthoring by link:\n${component.authoringNotes.map((note) => ` - ${note}`).join("\n")}\n${component.authoringMarkdownExample}`
1858
+ }\n\nRemember: componentData stores JSON.stringify(the object), not the object itself. Use --json for the exact nested schema and a complete componentBlock example.\n`;
1696
1859
  }
1697
1860
  case "workflow": {
1698
1861
  const reference = result.reference;
package/src/session.js CHANGED
@@ -373,7 +373,28 @@ export async function openSession(invocation = {}) {
373
373
  // Deliberately swallowed; see above.
374
374
  }
375
375
 
376
- const client = new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, exchanged.key));
376
+ // The exchanged credential lasts about an hour. A long operation (a video
377
+ // upload and its encoding) re-exchanges the same stored sign-in for the same
378
+ // site and capabilities shortly before it lapses, rather than failing midway
379
+ // with the work already done.
380
+ const client = new SiteApiClient({
381
+ ...buildClientOptions(invocation, apiBaseUrl, exchanged.key),
382
+ credentialRenewal: {
383
+ expiresAtMilliseconds: Date.parse(exchanged.expiresAt),
384
+ renew: async () => {
385
+ const renewed = await withRefusalGuidance(
386
+ onProgress,
387
+ "sign-in token exchange",
388
+ async () =>
389
+ await exchangeSiteAuthoringToken(accountClient, {
390
+ siteId: config.siteId,
391
+ capabilities: narrowedCapabilities(invocation, config.authoringSurface),
392
+ }),
393
+ );
394
+ return { key: renewed.key, expiresAtMilliseconds: Date.parse(renewed.expiresAt) };
395
+ },
396
+ },
397
+ });
377
398
  return {
378
399
  config,
379
400
  client,
@@ -116,6 +116,9 @@ export const SETTINGS_GROUPS = Object.freeze([
116
116
  // exist is worse than one that omits the field.
117
117
  { name: "commentsMode", wireType: "enum", zero: "SITE_COMMENTS_MODE_UNSPECIFIED" },
118
118
  ...stringFields("tiptapImagePlacement"),
119
+ // One IANA zone id (TR01091). The server answers UTC for a site that
120
+ // never set one, so a pulled snapshot is never empty.
121
+ ...stringFields("timeZone"),
119
122
  { name: "allowSearch", wireType: "boolean" },
120
123
  { name: "showFollowButton", wireType: "boolean" },
121
124
  { name: "albumSeamless", wireType: "boolean" },
package/src/transport.js CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  REFUSAL_UNCLASSIFIED,
23
23
  ROLLOUT_REFUSAL_FIELD,
24
24
  } from "./constants.js";
25
+ import { Readable } from "node:stream";
26
+
25
27
  import { validateApiBaseUrl } from "./config.js";
26
28
  import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
27
29
 
@@ -43,6 +45,9 @@ const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
43
45
  // `REPLACEABLE_POST_PATHS` below.
44
46
  const IDEMPOTENT_METHODS = new Set(["GET"]);
45
47
 
48
+ /** An exchanged credential is renewed once it is this close to expiring. */
49
+ const CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS = 5 * 60_000;
50
+
46
51
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
47
52
  const RETRYABLE_RESPONSE_ERRORS = new Set([
48
53
  "transport.invalid_json",
@@ -611,9 +616,20 @@ export class SiteApiClient {
611
616
  */
612
617
  #authorization;
613
618
 
619
+ /**
620
+ * How an exchanged credential is replaced before it expires, or `undefined`
621
+ * for a credential that is not exchanged (a key in the environment). The
622
+ * exchanged credential lives about an hour, and a video upload plus its
623
+ * encoding can outlast that.
624
+ */
625
+ #renewCredential;
626
+ #credentialExpiresAtMilliseconds;
627
+ #renewing;
628
+
614
629
  constructor({
615
630
  apiBaseUrl,
616
631
  token,
632
+ credentialRenewal,
617
633
  fetch = globalThis.fetch,
618
634
  sleep = defaultSleep,
619
635
  signal,
@@ -643,6 +659,12 @@ export class SiteApiClient {
643
659
  this.apiPathPrefix = new URL(this.apiBaseUrl).pathname;
644
660
  this.anonymous = anonymous;
645
661
  this.#authorization = anonymous ? undefined : `Bearer ${token}`;
662
+ // A renewal whose first expiry is not a real time could never say when it
663
+ // is due, and would exchange on every request, so it is not installed.
664
+ if (!anonymous && credentialRenewal !== undefined && Number.isFinite(credentialRenewal.expiresAtMilliseconds)) {
665
+ this.#renewCredential = credentialRenewal.renew;
666
+ this.#credentialExpiresAtMilliseconds = credentialRenewal.expiresAtMilliseconds;
667
+ }
646
668
  this.fetch = fetch;
647
669
  this.sleep = sleep;
648
670
  this.signal = signal;
@@ -665,6 +687,42 @@ export class SiteApiClient {
665
687
  return await this.#request(path, options, false);
666
688
  }
667
689
 
690
+ /**
691
+ * Replaces an exchanged credential that is within five minutes of expiring,
692
+ * once however many requests ask at the same time, before the request that
693
+ * would have carried the old one. A failed renewal is the request's failure:
694
+ * sending a credential known to be expiring would only be refused later.
695
+ */
696
+ async #renewCredentialIfDue() {
697
+ if (
698
+ this.#renewCredential === undefined
699
+ || this.now() < this.#credentialExpiresAtMilliseconds - CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS
700
+ ) return;
701
+ this.#renewing ??= (async () => {
702
+ try {
703
+ const renewed = await this.#renewCredential();
704
+ if (!isWellFormedCredential(renewed?.key) || !Number.isFinite(renewed?.expiresAtMilliseconds)) {
705
+ throw new SiteAuthoringError(
706
+ "auth.renewal_invalid",
707
+ "Taproot returned an invalid renewed credential.",
708
+ );
709
+ }
710
+ this.#authorization = `Bearer ${renewed.key}`;
711
+ this.#credentialExpiresAtMilliseconds = renewed.expiresAtMilliseconds;
712
+ // A credential that is due again the moment it arrives (a lifetime
713
+ // shorter than the margin, or a clock far ahead of the server's) would
714
+ // be exchanged for on every request, each minting a key. It is used as
715
+ // it is, and the server's own refusal speaks if it has truly lapsed.
716
+ if (renewed.expiresAtMilliseconds - CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS <= this.now()) {
717
+ this.#renewCredential = undefined;
718
+ }
719
+ } finally {
720
+ this.#renewing = undefined;
721
+ }
722
+ })();
723
+ await this.#renewing;
724
+ }
725
+
668
726
  /**
669
727
  * Replays only the POST shapes whose server contract answers a replay instead
670
728
  * of duplicating work — see `REPLACEABLE_POST_PATHS`. This is deliberately not
@@ -711,6 +769,7 @@ export class SiteApiClient {
711
769
  }
712
770
  const replayable = IDEMPOTENT_METHODS.has(method) || replaceableMutation;
713
771
  const serializedBody = body === undefined ? undefined : JSON.stringify(body);
772
+ await this.#renewCredentialIfDue();
714
773
  // Whether any attempt of a mutation has ended without an authoritative
715
774
  // outcome. Accumulated, not read off the final attempt: a claim whose first
716
775
  // send committed the mint and died on the reply, and whose retry then failed
@@ -843,10 +902,19 @@ export class SiteApiClient {
843
902
  * shape that may be replayed.
844
903
  */
845
904
  async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
905
+ // A video original is streamed from disk: `{ byteLength, stream, timeoutMilliseconds }`,
906
+ // where `stream()` returns a fresh readable of exactly `byteLength` bytes so a
907
+ // retried attempt reads the file again from the start.
908
+ const streamed = bodyBytes !== null
909
+ && typeof bodyBytes === "object"
910
+ && !ArrayBuffer.isView(bodyBytes)
911
+ && typeof bodyBytes.stream === "function"
912
+ && Number.isSafeInteger(bodyBytes.byteLength)
913
+ && bodyBytes.byteLength >= 0;
846
914
  if (
847
915
  typeof uploadUrl !== "string"
848
916
  || uploadUrl.length === 0
849
- || !ArrayBuffer.isView(bodyBytes)
917
+ || !(streamed || ArrayBuffer.isView(bodyBytes))
850
918
  ) {
851
919
  throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid upload capability.");
852
920
  }
@@ -920,9 +988,15 @@ export class SiteApiClient {
920
988
  response = await this.fetch(url, {
921
989
  method: "PUT",
922
990
  redirect: "error",
923
- signal: operationSignal(this.signal, LIMITS.uploadMilliseconds, this.timeoutSignal),
991
+ signal: operationSignal(
992
+ this.signal,
993
+ (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
994
+ this.timeoutSignal,
995
+ ),
924
996
  headers,
925
- body: bodyBytes,
997
+ ...(streamed
998
+ ? { body: Readable.toWeb(bodyBytes.stream()), duplex: "half" }
999
+ : { body: bodyBytes }),
926
1000
  });
927
1001
  } catch {
928
1002
  if (attempt + 1 < LIMITS.uploadAttempts && !this.signal?.aborted) {
@@ -1,4 +1,5 @@
1
1
  import { FREE_FORM_TEMPLATE_VERSION, TEMPLATE_TYPE_FREE_FORM } from "./api.js";
2
+ import { isVideoEmbedId } from "./content/video-embed-url.js";
2
3
  import { SiteAuthoringError } from "./errors.js";
3
4
 
4
5
  /**
@@ -596,6 +597,26 @@ export const COVER_IMAGE_UNUSED_MESSAGE = "The selected cover image must be used
596
597
  * wrongly thinks unused would be refused for nothing, and the server has the
597
598
  * final say.
598
599
  */
600
+ /**
601
+ * The parsed JSON of a `componentData` string, or `undefined` when it is not
602
+ * JSON. The server decodes HTML entities (up to three rounds) before reading
603
+ * component JSON, so a string that only parses once decoded is read the way the
604
+ * server reads it.
605
+ */
606
+ function parseComponentData(text) {
607
+ let decoded = text;
608
+ for (let round = 0; round < 3 && /&(?:quot|amp|#39|lt|gt);/u.test(decoded); round += 1) {
609
+ decoded = decoded.replace(/&quot;/gu, "\"").replace(/&#39;/gu, "'").replace(/&lt;/gu, "<").replace(/&gt;/gu, ">")
610
+ .replace(/&amp;/gu, "&");
611
+ }
612
+ for (const candidate of [text, decoded]) {
613
+ try {
614
+ return JSON.parse(candidate);
615
+ } catch { /* not JSON: try the decoded form, then nothing to read */ }
616
+ }
617
+ return undefined;
618
+ }
619
+
599
620
  export function usedImageIds(document_) {
600
621
  const found = new Set();
601
622
  const visit = (value) => {
@@ -607,19 +628,8 @@ export function usedImageIds(document_) {
607
628
  else if (key === "imageIds" && Array.isArray(child)) {
608
629
  child.filter((id) => typeof id === "string").forEach((id) => found.add(id.toLowerCase()));
609
630
  } else if (key === "componentData" && typeof child === "string") {
610
- // The server decodes HTML entities (up to three rounds) before reading component JSON.
611
- let text = child;
612
- for (let round = 0; round < 3 && /&(?:quot|amp|#39|lt|gt);/u.test(text); round += 1) {
613
- text = text.replace(/&quot;/gu, "\"").replace(/&#39;/gu, "'").replace(/&lt;/gu, "<").replace(/&gt;/gu, ">")
614
- .replace(/&amp;/gu, "&");
615
- }
616
- try {
617
- visit(JSON.parse(child));
618
- } catch {
619
- try {
620
- visit(JSON.parse(text));
621
- } catch { /* not JSON: nothing to read */ }
622
- }
631
+ const parsed = parseComponentData(child);
632
+ if (parsed !== undefined) visit(parsed);
623
633
  } else visit(child);
624
634
  }
625
635
  }
@@ -629,6 +639,66 @@ export function usedImageIds(document_) {
629
639
  return found;
630
640
  }
631
641
 
642
+ /**
643
+ * Every video id the page places: the `videoId` of each `video` component block
644
+ * in any of its documents. Block-scoped on purpose — a `videoId` anywhere else
645
+ * is author prose, not a reference the publisher resolves.
646
+ */
647
+ export function placedVideoIds(document_) {
648
+ const found = new Set();
649
+ const visit = (value) => {
650
+ if (Array.isArray(value)) {
651
+ value.forEach(visit);
652
+ } else if (isPlainObject(value)) {
653
+ if (value.type === "componentBlock" && isPlainObject(value.attrs) && value.attrs.componentType === "video") {
654
+ const data = typeof value.attrs.componentData === "string"
655
+ ? parseComponentData(value.attrs.componentData)
656
+ : undefined;
657
+ if (isPlainObject(data) && typeof data.videoId === "string") found.add(data.videoId.toLowerCase());
658
+ return;
659
+ }
660
+ Object.values(value).forEach(visit);
661
+ }
662
+ };
663
+ for (const { doc } of contentDocuments(document_)) visit(doc);
664
+ return found;
665
+ }
666
+
667
+ /**
668
+ * The `video-embed` blocks in any of the page's documents that name a video and
669
+ * hold no poster, each with a way to set one. `pages push` copies the provider's
670
+ * thumbnail into the site's images for these (TR01109). Block-scoped like
671
+ * `placedVideoIds`.
672
+ */
673
+ export function posterlessVideoEmbeds(document_) {
674
+ const found = [];
675
+ const visit = (value) => {
676
+ if (Array.isArray(value)) {
677
+ value.forEach(visit);
678
+ } else if (isPlainObject(value)) {
679
+ if (value.type === "componentBlock" && isPlainObject(value.attrs) && value.attrs.componentType === "video-embed") {
680
+ const data = typeof value.attrs.componentData === "string"
681
+ ? parseComponentData(value.attrs.componentData)
682
+ : undefined;
683
+ if (isPlainObject(data) && (data.poster === undefined || data.poster === null)
684
+ && isVideoEmbedId(data.provider, data.videoId)) {
685
+ found.push({
686
+ provider: data.provider,
687
+ videoId: data.videoId,
688
+ setPoster: (poster) => {
689
+ value.attrs.componentData = JSON.stringify({ ...data, poster });
690
+ },
691
+ });
692
+ }
693
+ return;
694
+ }
695
+ Object.values(value).forEach(visit);
696
+ }
697
+ };
698
+ for (const { doc } of contentDocuments(document_)) visit(doc);
699
+ return found;
700
+ }
701
+
632
702
  /** The selected cover image when the page does not use it, else `undefined`. */
633
703
  export function unusedCoverImageId(document_) {
634
704
  if (documentTemplate(document_) === TEMPLATE_FREE_FORM) return undefined;
@@ -0,0 +1,67 @@
1
+ import { withRefusalGuidance } from "../api.js";
2
+ import { VERB_FORMS_PULL } from "../constants.js";
3
+ import { getPageIndex, getSiteForm, listSiteForms } from "../forms-api.js";
4
+ import { afterSubmitPage, formFileName, projectFormForWorkspace } from "../forms-contract.js";
5
+ import { formsManifestEntry, siteSettingsBaseline } from "../forms-workspace.js";
6
+ import { boundedList, openSession, successResult } from "../session.js";
7
+ import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
8
+
9
+ const MAXIMUM_REPORTED = 200;
10
+
11
+ /**
12
+ * `forms pull` — write one file per live form and record the versions read.
13
+ *
14
+ * It overwrites a form's file with the site's state, so a local edit that was
15
+ * never pushed is replaced; that is what pull means everywhere else in this
16
+ * CLI. An archived form is reported and not written, because `forms push`
17
+ * refuses an archived form and a file that can never be pushed is noise.
18
+ */
19
+ export async function formsPull(invocation) {
20
+ const { client, config, siteId, onProgress } = await openSession(invocation);
21
+ // The manifest is the pulled workspace this baseline belongs to, the same
22
+ // precondition every push has.
23
+ const manifest = await readManifest(config.workspaceDir, siteId);
24
+
25
+ const listed = await withRefusalGuidance(onProgress, "forms pull", async () => await listSiteForms(client, siteId));
26
+ const live = listed.filter((form) => !form.archived);
27
+ const archived = listed.filter((form) => form.archived).map((form) => form.key).sort();
28
+
29
+ const baselines = [];
30
+ const written = [];
31
+ // A form that sends visitors to a page is written with the page's path, which needs the page list once.
32
+ let pages;
33
+ for (const summary of live) {
34
+ const { form, version } = await withRefusalGuidance(
35
+ onProgress,
36
+ "forms pull",
37
+ async () => await getSiteForm(client, siteId, summary.id),
38
+ );
39
+ if (pages === undefined && afterSubmitPage(version.definition)) {
40
+ pages = await withRefusalGuidance(onProgress, "forms pull", async () => await getPageIndex(client, siteId, { onProgress }));
41
+ }
42
+ const document_ = projectFormForWorkspace({
43
+ key: form.key,
44
+ name: form.name,
45
+ sink: form.sink,
46
+ retention_days: form.retentionDays,
47
+ definition: version.definition,
48
+ }, pages?.pathById);
49
+ await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), document_);
50
+ baselines.push([form.key, { id: form.id, version: version.version, settings: siteSettingsBaseline(form) }]);
51
+ written.push(form.key);
52
+ }
53
+
54
+ manifest.forms = formsManifestEntry(baselines);
55
+ await writeManifest(config.workspaceDir, manifest);
56
+ onProgress(`Wrote ${written.length} form file${written.length === 1 ? "" : "s"} and recorded their versions.`);
57
+
58
+ const reported = boundedList(written.sort(), MAXIMUM_REPORTED);
59
+ return successResult(VERB_FORMS_PULL, siteId, {
60
+ forms: {
61
+ total: written.length,
62
+ items: reported.items.map((key) => ({ key, file: formFileName(key) })),
63
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
64
+ ...(archived.length > 0 ? { archived } : {}),
65
+ },
66
+ });
67
+ }