@taprootio/site-authoring 0.23.1 → 0.26.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 CHANGED
@@ -235,8 +235,8 @@ Which verbs apply depends on the site:
235
235
  `pull` snapshots its four settings documents, `theme push`, `footer push`,
236
236
  and `media upload` author them, and `deploy` stages and promotes them.
237
237
  Its pages, navigation, and redirects come from the Docs artifact, so
238
- `pages push`, `nav push`, the `redirects` and `forms` verbs, `approve`, and the
239
- `preview` verbs refuse with `surface.presentation_only` before any request.
238
+ `pages push`, `nav push`, the `redirects`, `forms` and `marketing` verbs, `link build`,
239
+ `approve`, and the `preview` verbs refuse with `surface.presentation_only` before any request.
240
240
  - A **prebuilt Docs** site (`none`) serves its artifact unchanged and takes no
241
241
  verb; each refuses with `surface.none`.
242
242
 
@@ -246,7 +246,9 @@ answers Not found like any site the account can no longer author, and the
246
246
  CLI adds a hint to run `use` again because the recording says the site was
247
247
  authorable before.
248
248
 
249
- `logout` discards the stored sign-in locally and revokes nothing; the
249
+ `logout` discards only the stored account sign-in locally. Separately stored
250
+ site legal draft grants remain; select each site and use
251
+ `taproot-site --legal-drafts logout` to discard its grant. Logout revokes nothing; the
250
252
  credential stays valid until an owner revokes it under Account → Settings →
251
253
  API keys. The secret itself is never displayed, logged, or placed in any
252
254
  result: credentials are named by id and display prefix only.
@@ -396,6 +398,12 @@ the site's own document is kept as internal state under
396
398
  `.taproot-site-state/`, which is never a page source and never pushed. A page
397
399
  edited on the site since the last pull is a `pages.pull_conflict` refusal
398
400
  before anything under `pages/` changes, with both recovery paths named.
401
+ Pull before creating a new JSON source. Once a new page is declared with a
402
+ file, title or path but has no server page ID, save it with `pages push` before
403
+ pulling again, or back up both the source and its manifest entry and move them
404
+ out of the workspace. Pull refuses these pending entries rather than dropping
405
+ their metadata. It also refuses an untracked legal or unreadable JSON source
406
+ that would be replaced by a new remote page.
399
407
 
400
408
  Pages are not only free-form. A source chooses its template with `template:` in
401
409
  its front matter — `article`, `recipe`, `album` or `place-review`; free-form is
@@ -406,10 +414,25 @@ its original publication date) and `coverImage`, which the site requires to be a
406
414
  image the page itself uses (place it in the body too; `pages.cover_image_unused`). A page's template is fixed when
407
415
  it is created: a source for another template is refused with
408
416
  `pages.template_immutable`. `taproot-site help page <type>` gives each
409
- template's fields and one worked example. The structured legal page (a versioned
410
- privacy policy or terms with an effective date) is created and published in the
411
- app; the CLI records it as a metadata-only page, and `taproot-site help page
412
- legal` says what a site-authoring key can and cannot do with one.
417
+ template's fields and one worked example. Pull includes structured legal pages and their
418
+ bodies as `.pm.json` with the usual Content read permission. Create and update
419
+ legal drafts in JSON only; Markdown conversion is not supported. The document
420
+ key, role and path are immutable, and publication assigns the version, effective
421
+ date and superseded version. See `taproot-site help page legal` for the fields.
422
+
423
+ Select a standard site and run `taproot-site login --legal-drafts`; the owner
424
+ reviews and approves a site-specific grant in the app, valid for at most 24
425
+ hours. Ordinary account login stays separate. Use the global option before the
426
+ verb, for example `taproot-site --legal-drafts pages push`,
427
+ `taproot-site --legal-drafts plan`, or `taproot-site --legal-drafts apply`.
428
+ It also selects the grant for `pull`, although ordinary Content access can read
429
+ legal pages. Changed or new legal drafts require this grant before writes begin;
430
+ unchanged legal pages do not block ordinary work. `TAPROOT_SITE_KEY` still takes
431
+ precedence, and legal writes with an unclassified environment override are
432
+ refused: unset it and use the explicit site login. Saving a draft does not
433
+ approve it. Legal approval, deletion and deployment remain in the app. A bare
434
+ `taproot-site approve` skips legal drafts and stages other tracked drafts; explicitly
435
+ naming a legal page refuses the whole selection.
413
436
  A privacy policy or other legal page that names Taproot should link the first
414
437
  mention to <https://www.taproot.io>; `help page free-form` and `help page legal`
415
438
  give a sample sentence.
@@ -618,6 +641,54 @@ and takes effect without publishing or a new form version. Delivery requires
618
641
  Taproot's rollout and email provider readiness; opt-in does not enable either
619
642
  or backfill earlier submissions. The CLI never pulls visitor answers.
620
643
 
644
+ ### Campaigns, placements and tracked links
645
+
646
+ A campaign groups the links of one push, and a placement names the spot a
647
+ link appears, such as a newsletter footer. Both are bounded sets of public
648
+ keys the site's release admits, so a page view that carries a matching
649
+ `utm_campaign` or `utm_content` is counted under it. Each is one JSON file:
650
+ `marketing/campaigns.json` is `{ campaigns: [{ key, name, note?, starts_on?,
651
+ ends_on? }] }` and `marketing/placements.json` is `{ placements: [{ key, name
652
+ }] }`.
653
+
654
+ ```bash
655
+ taproot-site marketing pull # write both files, with a baseline
656
+ # edit marketing/campaigns.json and marketing/placements.json
657
+ taproot-site marketing push # create, update and restore
658
+ taproot-site marketing push --archive # also archive what the files leave out
659
+ taproot-site link build --path /recipes/spring-salad --source newsletter \
660
+ --campaign spring-launch --placement nl-footer
661
+ ```
662
+
663
+ `marketing pull` replaces both files with the site's live records, each with
664
+ the read-only `state` (live, publishing, goes_live_with_first_publish,
665
+ not_in_current_release) and `counted_from`, the day its key first went live.
666
+ Archived records are listed and not written. `marketing push` validates both
667
+ files, compares every record with the site, and then creates a key the site
668
+ lacks, updates the labels of an existing key, and restores an archived key a
669
+ file names. A key is public URL text and never changes or returns to use, so a
670
+ changed key is a new record; the old one is archived only when you pass
671
+ `--archive`, and only if this workspace pulled it. A record that changed on the
672
+ site since the pull is refused (`marketing.concurrent_modification`), as is a
673
+ key the site has that this workspace never pulled (`marketing.pull_required`).
674
+ Quota and key refusals are shown as Taproot words them. Like `forms push`, it
675
+ has no plan step: it reads the site, refuses a stale baseline, and writes.
676
+ Creating or restoring a key queues the silent release that starts counting it;
677
+ nothing is deployed or waited for.
678
+
679
+ `link build` prints one address and saves nothing. It uses the Marketing link
680
+ builder's own rules, compiled from the app: the source decides the medium
681
+ (`reader` is not offered), a `utm_source`, `utm_medium`, `utm_campaign` or `utm_content` already on the path is replaced (other `utm_` pairs are kept), and a
682
+ campaign or placement is checked against `marketing/*.json` when the workspace
683
+ has them, and otherwise against the live site. A key that is not live yet is
684
+ allowed, with a note that page views count under the source only until it is
685
+ (a note read from a file is as of the last `marketing pull` or push).
686
+ The host is the signed-in site's public address; pass `--domain <host>` when
687
+ only a site credential is available. The `delegation.content` capability
688
+ carries what these verbs need (`site.analytics.view`, plus the publish
689
+ permission to change anything); `whoami` reports whether the last exchange
690
+ held it. `help marketing` and `help link` state the contracts.
691
+
621
692
  `preview page <page-path-or-id>` renders one persisted draft behind the
622
693
  staging gate and mints a browser handoff URL, reported only in the final JSON
623
694
  result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.23.1",
3
+ "version": "0.26.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@taprootio/espalier": "6.5.0",
31
+ "@taprootio/espalier": "6.7.0",
32
32
  "mediabunny": "1.61.1"
33
33
  },
34
34
  "scripts": {
@@ -0,0 +1,31 @@
1
+ {
2
+ "version": 1,
3
+ "keyPattern": "^[a-z0-9-]{3,32}$",
4
+ "keyMinLength": 3,
5
+ "keyMaxLength": 32,
6
+ "maxActivePerSite": 50,
7
+ "artifactPath": "_taproot/analytics-campaigns.json",
8
+ "artifactVersion": 1,
9
+ "collectorMarker": "c1",
10
+ "maxDigitRun": 8,
11
+ "maxTotalPerSite": 500,
12
+ "nameMaxLength": 80,
13
+ "noteMaxLength": 500,
14
+ "vectors": [
15
+ {"value": "spring-26", "valid": true},
16
+ {"value": "abc", "valid": true},
17
+ {"value": "ab", "valid": false},
18
+ {"value": "a-very-long-campaign-key-that-is-over-32", "valid": false},
19
+ {"value": "Spring-26", "valid": false},
20
+ {"value": "spring_26", "valid": false},
21
+ {"value": "spring 26", "valid": false},
22
+ {"value": "spring|26", "valid": false},
23
+ {"value": "spring.26", "valid": false},
24
+ {"value": "", "valid": false},
25
+ {"value": "-leading", "valid": true},
26
+ {"value": "x12345678y", "valid": true},
27
+ {"value": "promo-123456789", "valid": false},
28
+ {"value": "spring-26\n", "valid": false},
29
+ {"value": "0123456789", "valid": false}
30
+ ]
31
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": 1,
3
+ "keyGrammar": "campaign",
4
+ "artifactPath": "_taproot/analytics-links.json",
5
+ "artifactVersion": 1,
6
+ "collectorMarker": "c2",
7
+ "maxActivePerSite": 200,
8
+ "maxTotalPerSite": 2000,
9
+ "nameMaxLength": 80,
10
+ "vectors": [
11
+ {"value": "nl-footer", "valid": true},
12
+ {"value": "abc", "valid": true},
13
+ {"value": "ab", "valid": false},
14
+ {"value": "NL-footer", "valid": false},
15
+ {"value": "nl_footer", "valid": false},
16
+ {"value": "nl footer", "valid": false},
17
+ {"value": "nl|footer", "valid": false},
18
+ {"value": "a-label-key-that-is-longer-than-32", "valid": false},
19
+ {"value": "promo-123456789", "valid": false},
20
+ {"value": "", "valid": false}
21
+ ]
22
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "version": 1,
3
+ "media": ["social", "email", "share"],
4
+ "sources": [
5
+ { "key": "linkedin", "name": "LinkedIn", "medium": "social", "aliases": ["linkedin.com", "lnkd.in", "com.linkedin.android"] },
6
+ { "key": "x", "name": "X", "medium": "social", "aliases": ["x.com", "twitter.com", "t.co", "com.twitter.android"] },
7
+ { "key": "facebook", "name": "Facebook", "medium": "social", "aliases": ["facebook.com", "fb.com", "fb.me", "com.facebook.katana", "com.facebook.lite"] },
8
+ { "key": "instagram", "name": "Instagram", "medium": "social", "aliases": ["instagram.com", "com.instagram.android"] },
9
+ { "key": "threads", "name": "Threads", "medium": "social", "aliases": ["threads.net", "threads.com", "com.instagram.barcelona"] },
10
+ { "key": "bluesky", "name": "Bluesky", "medium": "social", "aliases": ["bsky.app", "bsky.social", "xyz.blueskyweb.app"] },
11
+ { "key": "mastodon", "name": "Mastodon", "medium": "social", "aliases": ["mastodon.social", "mastodon.online", "org.joinmastodon.android"] },
12
+ { "key": "reddit", "name": "Reddit", "medium": "social", "aliases": ["reddit.com", "redd.it", "com.reddit.frontpage"] },
13
+ { "key": "youtube", "name": "YouTube", "medium": "social", "aliases": ["youtube.com", "youtu.be", "com.google.android.youtube"] },
14
+ { "key": "newsletter", "name": "Newsletter", "medium": "email", "aliases": [] },
15
+ { "key": "reader", "name": "Reader shares", "medium": "share", "aliases": [] }
16
+ ]
17
+ }
package/src/api.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  // must equal acceptance on save — a prefix admitted here but refused there
26
26
  // would turn a successful mint into an orphaned key.
27
27
  import { KEY_PREFIX } from "./credentials.js";
28
+ import { CAPABILITY_LEGAL_DRAFTS, SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
28
29
  import { isCanonicalUuid, normalizePreviewDiagnostic, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
29
30
  import { normalizeAuthorListing, parseAuthorReference } from "./authors-contract.js";
30
31
  import { docsPublicationModeFromWire, siteKindFromWire, surfaceFromWire } from "./surface.js";
@@ -2238,7 +2239,7 @@ function requireBoundedSeconds(value, minimum, maximum, field) {
2238
2239
  * caller from the already-allowlisted API origin, so a compromised or
2239
2240
  * misconfigured server cannot steer the owner somewhere else to approve.
2240
2241
  */
2241
- export async function startCliAuthorization(client, { keyName }, requestOptions = {}) {
2242
+ export async function startCliAuthorization(client, { keyName, legalDraftSiteId }, requestOptions = {}) {
2242
2243
  const response = requireObject(
2243
2244
  // No site: TR00645 made the account-level sign-in the front door, so the
2244
2245
  // CLI no longer has to know a site id before it can authorize at all.
@@ -2248,7 +2249,7 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
2248
2249
  await client.request(CLI_AUTHORIZATION_PATH, {
2249
2250
  ...requestOptions,
2250
2251
  method: "POST",
2251
- body: { keyName, cliVersion: CLI_VERSION },
2252
+ body: { keyName, cliVersion: CLI_VERSION, ...(legalDraftSiteId === undefined ? {} : { legalDraftSiteId }) },
2252
2253
  }),
2253
2254
  "login.start_contract",
2254
2255
  "CLI authorization",
@@ -2263,9 +2264,13 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
2263
2264
  field: "userCode",
2264
2265
  });
2265
2266
  }
2267
+ if (legalDraftSiteId !== undefined && response.legalDraftSiteId !== legalDraftSiteId) {
2268
+ throw new SiteAuthoringError("login.start_contract", "Taproot did not confirm the selected legal draft site. Update the server and try again.", { field: "legalDraftSiteId" });
2269
+ }
2266
2270
  return {
2267
2271
  deviceCode: response.deviceCode,
2268
2272
  userCode: response.userCode,
2273
+ ...(legalDraftSiteId === undefined ? {} : { legalDraftSiteId }),
2269
2274
  expiresInSeconds: requireBoundedSeconds(
2270
2275
  response.expiresInSeconds,
2271
2276
  1,
@@ -2387,7 +2392,7 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
2387
2392
  };
2388
2393
  }
2389
2394
 
2390
- function normalizeIssuedCredential(response) {
2395
+ function normalizeIssuedCredential(response, legalDraftSiteId) {
2391
2396
  if (!isWellFormedCredential(response.rawKey)) {
2392
2397
  // The value is never quoted, here or anywhere: it is the secret.
2393
2398
  throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an invalid issued key.", {
@@ -2411,7 +2416,24 @@ function normalizeIssuedCredential(response) {
2411
2416
  || response.keyExpiresAt === ""
2412
2417
  ? undefined
2413
2418
  : requireCanonicalTimestamp(response.keyExpiresAt, "login.claim_unusable", "keyExpiresAt").value;
2419
+ if (legalDraftSiteId !== undefined) {
2420
+ const capabilities = response.capabilities;
2421
+ if (response.legalDraftSiteId !== legalDraftSiteId || !Array.isArray(capabilities)
2422
+ || capabilities.length !== SITE_AUTHORING_CAPABILITIES.length + 1
2423
+ || new Set(capabilities).size !== capabilities.length
2424
+ || ![...SITE_AUTHORING_CAPABILITIES, CAPABILITY_LEGAL_DRAFTS].every((value) => capabilities.includes(value))
2425
+ || (response.legalDraftPlatformSite !== undefined && typeof response.legalDraftPlatformSite !== "boolean")
2426
+ || keyExpiresAt === undefined) {
2427
+ throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an invalid legal draft grant.");
2428
+ }
2429
+ } else if (response.legalDraftSiteId || (Array.isArray(response.capabilities) && response.capabilities.length > 0)) {
2430
+ throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an unexpected site grant.");
2431
+ }
2414
2432
  return {
2433
+ ...(legalDraftSiteId === undefined ? {} : {
2434
+ purpose: "legal-drafts", siteId: legalDraftSiteId, capabilities: [...response.capabilities],
2435
+ legalDraftPlatformSite: response.legalDraftPlatformSite ?? false,
2436
+ }),
2415
2437
  key: response.rawKey,
2416
2438
  keyId,
2417
2439
  keyPrefix: response.keyPrefix,
@@ -2427,7 +2449,7 @@ function normalizeIssuedCredential(response) {
2427
2449
  * `..._CONSUMED`, and the caller reports the orphaned credential honestly
2428
2450
  * rather than hiding it behind a network error.
2429
2451
  */
2430
- export async function claimCliAuthorization(client, deviceCode, requestOptions = {}) {
2452
+ export async function claimCliAuthorization(client, deviceCode, requestOptions = {}, { legalDraftSiteId } = {}) {
2431
2453
  // Every `login.claim_contract` raised here — a non-object envelope, an
2432
2454
  // absent status, a status string this vocabulary does not know — is an
2433
2455
  // answer this client could not read, not proof the claim was refused. A
@@ -2451,7 +2473,7 @@ export async function claimCliAuthorization(client, deviceCode, requestOptions =
2451
2473
  );
2452
2474
  }
2453
2475
  if (status === CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED) {
2454
- return { status, credential: normalizeIssuedCredential(response) };
2476
+ return { status, credential: normalizeIssuedCredential(response, legalDraftSiteId) };
2455
2477
  }
2456
2478
  if (status === CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED) {
2457
2479
  // A consumed answer names the credential an earlier claim minted, so the
@@ -14,6 +14,7 @@
14
14
  export const CAPABILITY_CONTENT = "delegation.content";
15
15
  export const CAPABILITY_DESIGN = "delegation.design";
16
16
  export const CAPABILITY_DEPLOYMENTS = "delegation.deployments";
17
+ export const CAPABILITY_LEGAL_DRAFTS = "delegation.legal-drafts";
17
18
 
18
19
  export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
19
20
  CAPABILITY_CONTENT,
@@ -23,7 +24,7 @@ export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
23
24
 
24
25
  /** What each capability lets an exchanged credential do, for help and `whoami`. */
25
26
  export const SITE_AUTHORING_CAPABILITY_SUMMARIES = Object.freeze({
26
- "delegation.content": "write pages, media, and form definitions",
27
+ "delegation.content": "write pages, media, form definitions, campaigns, and placements",
27
28
  "delegation.design": "set the theme, navigation, and footer",
28
29
  "delegation.deployments": "stage and deploy",
29
30
  });
@@ -1,5 +1,43 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.26.0",
5
+ "note": "marketing pull and marketing push keep a site's campaigns and placements as files, and link build prints a tracked address, so an agent can run a site's attribution over the CLI.",
6
+ "behavior": [
7
+ "New verbs: taproot-site marketing pull, marketing push [--archive] and link build --path <page> --source <key> [--campaign <key>] [--placement <key>] [--domain <host>]. A key now carries the permissions they need: delegation.content includes site.analytics.view, so a credential minted before this release gains read access to the site's campaigns and placements and, with the publish permission it already held, may change them.",
8
+ "marketing pull writes marketing/campaigns.json and marketing/placements.json (key, name, a campaign's note, starts_on and ends_on, and the read-only state and counted_from) and records a baseline in the pull manifest; a plain pull carries it forward. marketing push validates both files, compares every record with the site, and creates a new key, updates the labels of an existing one, or restores an archived key the file names. A key is public and never changes, so a changed key is a new record; a record the file leaves out is archived only with --archive, and only if the workspace pulled it. A record that changed on the site since the pull is refused (marketing.concurrent_modification, marketing.pull_required). Quota and key refusals are shown as Taproot returns them. There is no plan or apply step, as with forms push.",
9
+ "link build prints one address and saves nothing, with the Marketing link builder's own rules: the source decides the medium, the reader source is not offered, a utm_source, utm_medium, utm_campaign or utm_content on the path is replaced, and a campaign or placement is checked against marketing/*.json when the workspace has it, otherwise against the site. One that is not live yet is allowed with the builder's note that page views count under the source only until it is. The host is the signed-in site's public address; pass --domain when only a site credential is available.",
10
+ "whoami reports whether the last exchange allows campaigns and placements (marketing.access).",
11
+ "The help reference version is 43.",
12
+ "Built against Espalier 6.7.0. The default site theme is rebuilt against it and its content is unchanged except for the recorded Espalier version, so pulled and validated themes behave as before."
13
+ ],
14
+ "help": [
15
+ "marketing",
16
+ "link"
17
+ ]
18
+ },
19
+ {
20
+ "version": "0.25.0",
21
+ "note": "Redirects from /s/ addresses are ordinary redirects now that tracked share links are retired.",
22
+ "behavior": [
23
+ "A redirect whose source is /s/<anything> saves and validates like any other source. The redirect.source_share_link site-check rule is gone, so offline checks and the server no longer refuse it."
24
+ ],
25
+ "help": []
26
+ },
27
+ {
28
+ "version": "0.24.0",
29
+ "note": "Site-specific consent enables legal draft authoring through JSON; legal approval and publication remain in the app.",
30
+ "behavior": [
31
+ "Select a standard site, then run login --legal-drafts and approve the pinned site grant in the app. It lasts at most 24 hours and is separate from ordinary account login. Use the global --legal-drafts option before pull, pages push, plan or apply to select that grant.",
32
+ "Pull includes legal bodies as .pm.json with ordinary Content read access. Legal create and update accept JSON only; document key, role and path are immutable, and version, effective date and superseded version are assigned by publication. A changed or new legal draft needs the explicit grant before writes begin.",
33
+ "TAPROOT_SITE_KEY takes precedence; legal writes with an unclassified environment key are refused. Legal approval, deletion and deployment remain app-only. Saving a draft does not approve it.",
34
+ "The help reference version is 42."
35
+ ],
36
+ "help": [
37
+ "page",
38
+ "pages"
39
+ ]
40
+ },
3
41
  {
4
42
  "version": "0.23.1",
5
43
  "note": "Image uploads support Taproot’s single-use upload gateway and send an operation key with each upload request.",