From 569be7bedb85d75d806002ff47b1d7a4eaa2df07 Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Mon, 20 Jul 2026 19:21:13 +0300 Subject: [PATCH 1/9] feat: describe the fields Confluence sends but never documented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the uniform half of what the schema audit found: 161 -> 83 undocumented fields, 93 -> 50 endpoints, with the suite still passing. The `_links` group is gone entirely, and it was the bulk — 61 fields, 57% of all occurrences. v1 declared its links object with no properties at all while using it for every entity, so `_links.webui` existed at runtime and nowhere in the types. Now it is typed. `version.ncsStepVersion` and `version.contributorIds` follow, along with the `_expandable` keys Confluence has grown since the spec was written. Types are what the API actually returned. `ncsStepVersion` is nullable because responses came back null; `contributorIds` keeps an open element type because every array observed was empty, and a guessed element type would read as breakage rather than as drift. 79 fields remain, with no shape in common — `ari`, `operations`, the `container` group, user fields like `locale`. Each needs deciding which schema owns it, so they are a separate pass. --- scripts/schemaAudit.ts | 14 +++++++--- src/core/createClient.ts | 40 ++++++++++++++++++++++++----- src/core/schemaAudit.ts | 2 ++ src/v1/models/content.ts | 2 ++ src/v1/models/contentChildren.ts | 2 ++ src/v1/models/genericLinks.ts | 29 +++++++++++++++++++-- src/v1/models/space.ts | 4 +++ src/v2/models/abstractPageLinks.ts | 18 +++++++++++++ src/v2/models/attachmentLinks.ts | 20 +++++++++++++++ src/v2/models/commentLinks.ts | 22 ++++++++++++++++ src/v2/models/customContentLinks.ts | 22 ++++++++++++++++ src/v2/models/databaseLinks.ts | 22 ++++++++++++++++ src/v2/models/folderLinks.ts | 22 ++++++++++++++++ src/v2/models/multiEntityLinks.ts | 20 +++++++++++++++ src/v2/models/optionalFieldLinks.ts | 22 ++++++++++++++++ src/v2/models/smartLinkLinks.ts | 22 ++++++++++++++++ src/v2/models/spaceLinks.ts | 22 ++++++++++++++++ src/v2/models/version.ts | 4 +++ src/v2/models/whiteboardLinks.ts | 20 +++++++++++++++ 19 files changed, 317 insertions(+), 12 deletions(-) diff --git a/scripts/schemaAudit.ts b/scripts/schemaAudit.ts index e3a2e52f..b8b629f7 100644 --- a/scripts/schemaAudit.ts +++ b/scripts/schemaAudit.ts @@ -23,6 +23,7 @@ interface SchemaDrift { endpoint: string; path: string; keys: string[]; + types: Record; } // `--report-only` rebuilds the summary from the last run's findings. The suite takes @@ -73,6 +74,7 @@ function normalizeEndpoint(endpoint: string): string { } const byField = new Map>(); +const typesByField = new Map>(); if (existsSync(findings)) { const lines = readFileSync(findings, 'utf8').trim(); @@ -86,6 +88,10 @@ if (existsSync(findings)) { if (!byField.has(field)) byField.set(field, new Set()); byField.get(field)!.add(normalizeEndpoint(entry.endpoint)); + + if (!typesByField.has(field)) typesByField.set(field, new Set()); + + typesByField.get(field)!.add(entry.types?.[key] ?? 'unknown'); } } } @@ -107,15 +113,17 @@ if (ranked.length === 0) { 'the published spec has fallen behind the API — but each one is a field consumers cannot see', 'in the types.', '', - '| Field | Endpoints | Seen at |', - '| --- | ---: | --- |', + '| Field | Type | Endpoints | Seen at |', + '| --- | --- | ---: | --- |', ); for (const [field, seen] of ranked) { const sample = [...seen].sort().slice(0, 3).join('
'); const more = seen.size > 3 ? `
…and ${seen.size - 3} more` : ''; - summary.push(`| \`${field}\` | ${seen.size} | ${sample}${more} |`); + const types = [...(typesByField.get(field) ?? new Set(['unknown']))].sort().join(' | '); + + summary.push(`| \`${field}\` | \`${types}\` | ${seen.size} | ${sample}${more} |`); } } diff --git a/src/core/createClient.ts b/src/core/createClient.ts index f8380b94..bb88bdf1 100644 --- a/src/core/createClient.ts +++ b/src/core/createClient.ts @@ -29,26 +29,52 @@ async function isScopeMismatchResponse(response: Response): Promise { } } +/** What an undocumented value looks like, in the vocabulary a schema would use to describe it. */ +function describeValue(value: unknown): string { + if (value === null) return 'null'; + + if (Array.isArray(value)) { + const elements = new Set(value.slice(0, 10).map(element => describeValue(element))); + + return elements.size === 0 ? 'unknown[]' : `${[...elements].sort().join(' | ')}[]`; + } + + return typeof value; +} + /** - * Removes the keys an audit run found undocumented, so the response can be parsed a second time. + * Reads the undocumented values, then removes them so the response can be parsed a second time. + * + * Audit-only, and it reports the types because the point of the audit is to write the missing field into the schema — + * which cannot be done from a list of names. Guessing `contributorIds` is an array of strings from its name alone is + * how a schema ends up wrong in a new way. * - * Audit-only. `path` is a zod issue path, so every segment is an object key or an array index, and anything that is - * not there any more is simply skipped — the walk describes a body that was just parsed, not an arbitrary structure. + * `path` is a zod issue path, so every segment is an object key or an array index, and anything no longer there is + * simply skipped — the walk describes a body that was just parsed, not an arbitrary structure. */ -function dropKeys(body: unknown, path: readonly PropertyKey[], keys: readonly PropertyKey[]): void { +function takeKeys( + body: unknown, + path: readonly PropertyKey[], + keys: readonly PropertyKey[], +): Record { let target = body; for (const segment of path) { - if (target === null || typeof target !== 'object') return; + if (target === null || typeof target !== 'object') return {}; target = (target as Record)[segment]; } - if (target === null || typeof target !== 'object') return; + if (target === null || typeof target !== 'object') return {}; + + const types: Record = {}; for (const key of keys) { + types[String(key)] = describeValue((target as Record)[key]); delete (target as Record)[key]; } + + return types; } interface DriftFinding { @@ -354,8 +380,8 @@ export function createClient(config: ClientConfig | Client): Client { endpoint, path: finding.path.join('.'), keys: finding.keys.map(key => String(key)), + types: takeKeys(data, finding.path, finding.keys), }); - dropKeys(data, finding.path, finding.keys); } // Re-parsed rather than returned raw, so the caller still gets what diff --git a/src/core/schemaAudit.ts b/src/core/schemaAudit.ts index 39a5cc77..2eb3e833 100644 --- a/src/core/schemaAudit.ts +++ b/src/core/schemaAudit.ts @@ -18,6 +18,8 @@ export interface SchemaDrift { path: string; /** The keys the API sent that the schema does not describe. */ keys: string[]; + /** What each of those keys actually held, so the missing field can be written into the schema. */ + types: Record; } type Store = { [STORE]?: SchemaDrift[] }; diff --git a/src/v1/models/content.ts b/src/v1/models/content.ts index e15f99b4..45f214aa 100644 --- a/src/v1/models/content.ts +++ b/src/v1/models/content.ts @@ -81,6 +81,7 @@ export type Content = { schedulePublishDate?: string; schedulePublishInfo?: string; macroRenderedOutput?: string; + draftVersion?: string; }; _links?: GenericLinks; }; @@ -156,6 +157,7 @@ export const ContentSchema: z.ZodType = apiObject({ schedulePublishDate: z.string().optional(), schedulePublishInfo: z.string().optional(), macroRenderedOutput: z.string().optional(), + draftVersion: z.string().optional(), }).optional(), _links: GenericLinksSchema.optional(), }); diff --git a/src/v1/models/contentChildren.ts b/src/v1/models/contentChildren.ts index b4376ad0..c523c420 100644 --- a/src/v1/models/contentChildren.ts +++ b/src/v1/models/contentChildren.ts @@ -19,6 +19,7 @@ export type ContentChildren = { database?: string; embed?: string; folder?: string; + slide?: string; }; _links?: GenericLinks; }; @@ -39,6 +40,7 @@ export const ContentChildrenSchema: z.ZodType = apiObject({ database: z.string().optional(), embed: z.string().optional(), folder: z.string().optional(), + slide: z.string().optional(), }).optional(), _links: GenericLinksSchema.optional(), }); diff --git a/src/v1/models/genericLinks.ts b/src/v1/models/genericLinks.ts index cbfd51a0..dbe170c2 100644 --- a/src/v1/models/genericLinks.ts +++ b/src/v1/models/genericLinks.ts @@ -1,6 +1,31 @@ -import type { z } from 'zod'; +import { z } from 'zod'; import { apiObject } from '#/core'; -export const GenericLinksSchema = apiObject({}); +export const GenericLinksSchema = apiObject({ + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), + /** Web UI link of the content. */ + webui: z.string().optional(), +}); export type GenericLinks = z.infer; diff --git a/src/v1/models/space.ts b/src/v1/models/space.ts index b44fbba0..84d42943 100644 --- a/src/v1/models/space.ts +++ b/src/v1/models/space.ts @@ -54,6 +54,8 @@ export type Space = { history?: string; homepage?: string; identifiers?: string; + roles?: string; + typeSettings?: string; }; _links: GenericLinks; }; @@ -100,6 +102,8 @@ export const SpaceSchema: z.ZodType = apiObject({ history: z.string().optional(), homepage: z.string().optional(), identifiers: z.string().optional(), + roles: z.string().optional(), + typeSettings: z.string().optional(), }), _links: GenericLinksSchema, }); diff --git a/src/v2/models/abstractPageLinks.ts b/src/v2/models/abstractPageLinks.ts index 2e56f01e..0ed594f7 100644 --- a/src/v2/models/abstractPageLinks.ts +++ b/src/v2/models/abstractPageLinks.ts @@ -8,6 +8,24 @@ export const AbstractPageLinksSchema = apiObject({ editui: z.string().optional(), /** Web UI link of the content. */ tinyui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), }); export type AbstractPageLinks = z.infer; diff --git a/src/v2/models/attachmentLinks.ts b/src/v2/models/attachmentLinks.ts index 1ead94f8..9b054197 100644 --- a/src/v2/models/attachmentLinks.ts +++ b/src/v2/models/attachmentLinks.ts @@ -6,6 +6,26 @@ export const AttachmentLinksSchema = apiObject({ webui: z.string().optional(), /** Download link of the content. */ download: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type AttachmentLinks = z.infer; diff --git a/src/v2/models/commentLinks.ts b/src/v2/models/commentLinks.ts index dc8e3d7d..2f3c06f8 100644 --- a/src/v2/models/commentLinks.ts +++ b/src/v2/models/commentLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const CommentLinksSchema = apiObject({ /** Web UI link of the content. */ webui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type CommentLinks = z.infer; diff --git a/src/v2/models/customContentLinks.ts b/src/v2/models/customContentLinks.ts index 8a655113..e30753c2 100644 --- a/src/v2/models/customContentLinks.ts +++ b/src/v2/models/customContentLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const CustomContentLinksSchema = apiObject({ /** Web UI link of the content. */ webui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type CustomContentLinks = z.infer; diff --git a/src/v2/models/databaseLinks.ts b/src/v2/models/databaseLinks.ts index 4a58601e..2630db08 100644 --- a/src/v2/models/databaseLinks.ts +++ b/src/v2/models/databaseLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const DatabaseLinksSchema = apiObject({ /** Web UI link of the content. */ webui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type DatabaseLinks = z.infer; diff --git a/src/v2/models/folderLinks.ts b/src/v2/models/folderLinks.ts index 3dd5c276..8d1280b6 100644 --- a/src/v2/models/folderLinks.ts +++ b/src/v2/models/folderLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const FolderLinksSchema = apiObject({ /** Web UI link of the content. */ webui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type FolderLinks = z.infer; diff --git a/src/v2/models/multiEntityLinks.ts b/src/v2/models/multiEntityLinks.ts index e93ee063..83fbbc7e 100644 --- a/src/v2/models/multiEntityLinks.ts +++ b/src/v2/models/multiEntityLinks.ts @@ -9,6 +9,26 @@ export const MultiEntityLinksSchema = apiObject({ next: z.string().optional(), /** Base url of the Confluence site. */ base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), + /** Web UI link of the content. */ + webui: z.string().optional(), }); export type MultiEntityLinks = z.infer; diff --git a/src/v2/models/optionalFieldLinks.ts b/src/v2/models/optionalFieldLinks.ts index 4e6de910..ed7f7d3b 100644 --- a/src/v2/models/optionalFieldLinks.ts +++ b/src/v2/models/optionalFieldLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const OptionalFieldLinksSchema = apiObject({ /** A relative URL that can be used to fetch results beyond what this include parameter retrieves. */ self: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), + /** Web UI link of the content. */ + webui: z.string().optional(), }); export type OptionalFieldLinks = z.infer; diff --git a/src/v2/models/smartLinkLinks.ts b/src/v2/models/smartLinkLinks.ts index 1d77440f..7a479f51 100644 --- a/src/v2/models/smartLinkLinks.ts +++ b/src/v2/models/smartLinkLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const SmartLinkLinksSchema = apiObject({ /** Web UI link of the content. */ webui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type SmartLinkLinks = z.infer; diff --git a/src/v2/models/spaceLinks.ts b/src/v2/models/spaceLinks.ts index 90953bae..bdafe3e4 100644 --- a/src/v2/models/spaceLinks.ts +++ b/src/v2/models/spaceLinks.ts @@ -4,6 +4,28 @@ import { apiObject } from '#/core'; export const SpaceLinksSchema = apiObject({ /** Web UI link of the space. */ webui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI link of the content. */ + editui: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type SpaceLinks = z.infer; diff --git a/src/v2/models/version.ts b/src/v2/models/version.ts index e1608f7a..b0d3a587 100644 --- a/src/v2/models/version.ts +++ b/src/v2/models/version.ts @@ -15,6 +15,10 @@ export const VersionSchema = apiObject({ minorEdit: z.boolean().optional(), /** The account ID of the user who created this version. */ authorId: z.string().optional(), + /** Internal content-sync step version. Returned as null while no sync step applies. */ + ncsStepVersion: z.string().nullish(), + /** Account ids of everyone who has contributed to the content. */ + contributorIds: z.array(z.unknown()).optional(), }); export type Version = z.infer; diff --git a/src/v2/models/whiteboardLinks.ts b/src/v2/models/whiteboardLinks.ts index 177fd938..49d3a904 100644 --- a/src/v2/models/whiteboardLinks.ts +++ b/src/v2/models/whiteboardLinks.ts @@ -6,6 +6,26 @@ export const WhiteboardLinksSchema = apiObject({ webui: z.string().optional(), /** Edit UI link of the content. */ editui: z.string().optional(), + /** Base URL of the Confluence site. */ + base: z.string().optional(), + /** Link to the restrictions grouped by operation. */ + byOperation: z.string().optional(), + /** Link to the collection this entity belongs to. */ + collection: z.string().optional(), + /** Context path of the Confluence site. */ + context: z.string().optional(), + /** Download link for the content. */ + download: z.string().optional(), + /** Edit UI (v2 editor) link of the content. */ + edituiv2: z.string().optional(), + /** Relative URL of the next page of results. */ + next: z.string().optional(), + /** Relative URL of the previous page of results. */ + prev: z.string().optional(), + /** Canonical link to this entity. */ + self: z.string().optional(), + /** Short web UI link of the content. */ + tinyui: z.string().optional(), }); export type WhiteboardLinks = z.infer; From 97fb277afefb9a4c43871c146e9399bb46121f38 Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Mon, 20 Jul 2026 22:31:13 +0300 Subject: [PATCH 2/9] feat: describe the remaining undocumented fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 161 undocumented fields across 93 endpoints down to 7 across 5, with the suite passing 493/493. This is the long tail the first pass deliberately left: fields with no shape in common, each needing a decision about which schema owns it. `Container` turned out to be a second empty schema, like the links one before it — declared with no properties at all while naming the parent of every piece of content. Ten fields, including its own `_links`. The rest are one-offs: `ari` and `base64EncodedAri`, `accountStatus` and `locale` across the three separate user schemas that each lag differently, `resolutionStatus` on comments, `operations` and `properties` on the newer v2 entities. Seven remain. Each sits either behind an inline response schema with no name to target, or behind a collection whose item schema differs from the single-entity one — both want a proper look rather than a guess, and the audit will keep reporting them until they get one. --- src/v1/models/bulkUserLookup.ts | 2 ++ src/v1/models/container.ts | 16 ++++++++++++++-- src/v1/models/content.ts | 4 ++++ src/v1/models/contentMetadata.ts | 4 ++++ src/v1/models/group.ts | 1 + src/v1/models/message.ts | 1 + src/v1/models/space.ts | 2 ++ src/v1/models/user.ts | 4 ++++ src/v1/models/userAnonymous.ts | 3 +++ src/v1/models/version.ts | 4 ++++ src/v1/models/watchUser.ts | 3 +++ src/v2/models/blogPostComment.ts | 1 + src/v2/models/childrenComment.ts | 2 ++ src/v2/models/database.ts | 2 ++ src/v2/models/descendants.ts | 1 + src/v2/models/folder.ts | 2 ++ src/v2/models/footerComment.ts | 1 + src/v2/models/inlineComment.ts | 2 ++ src/v2/models/inlineCommentChildren.ts | 1 + src/v2/models/pageComment.ts | 1 + src/v2/models/pageSummary.ts | 1 + src/v2/models/smartLink.ts | 2 ++ src/v2/models/space.ts | 1 + src/v2/models/whiteboard.ts | 2 ++ 24 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/v1/models/bulkUserLookup.ts b/src/v1/models/bulkUserLookup.ts index 1615a101..1c96e4bb 100644 --- a/src/v1/models/bulkUserLookup.ts +++ b/src/v1/models/bulkUserLookup.ts @@ -38,6 +38,8 @@ export const BulkUserLookupSchema = apiObject({ personalSpace: z.string().optional(), }), _links: GenericLinksSchema, + accountStatus: z.string().optional(), + locale: z.string().optional(), }); export type BulkUserLookup = z.infer; diff --git a/src/v1/models/container.ts b/src/v1/models/container.ts index 2699fd0f..03ed0ab5 100644 --- a/src/v1/models/container.ts +++ b/src/v1/models/container.ts @@ -1,10 +1,22 @@ -import type { z } from 'zod'; +import { z } from 'zod'; import { apiObject } from '#/core'; +import { GenericLinksSchema } from './genericLinks'; /** * Container for content. This can be either a space (containing a page or blogpost)* or a page/blog post (containing an * attachment or comment) */ -export const ContainerSchema = apiObject({}); +export const ContainerSchema = apiObject({ + id: z.string().optional(), + type: z.string().optional(), + status: z.string().optional(), + title: z.string().optional(), + ari: z.string().optional(), + base64EncodedAri: z.string().optional(), + extensions: z.record(z.string(), z.any()).optional(), + macroRenderedOutput: z.record(z.string(), z.any()).optional(), + _links: GenericLinksSchema.optional(), + _expandable: z.record(z.string(), z.any()).optional(), +}); export type Container = z.infer; diff --git a/src/v1/models/content.ts b/src/v1/models/content.ts index 45f214aa..4799efeb 100644 --- a/src/v1/models/content.ts +++ b/src/v1/models/content.ts @@ -84,6 +84,8 @@ export type Content = { draftVersion?: string; }; _links?: GenericLinks; + ari?: string; + base64EncodedAri?: string; }; /** Base object for all content types. */ @@ -160,4 +162,6 @@ export const ContentSchema: z.ZodType = apiObject({ draftVersion: z.string().optional(), }).optional(), _links: GenericLinksSchema.optional(), + ari: z.string().optional(), + base64EncodedAri: z.string().optional(), }); diff --git a/src/v1/models/contentMetadata.ts b/src/v1/models/contentMetadata.ts index 07923923..e56e3243 100644 --- a/src/v1/models/contentMetadata.ts +++ b/src/v1/models/contentMetadata.ts @@ -35,6 +35,8 @@ export type ContentMetadata = { properties?: GenericLinks; frontend?: Record; labels?: LabelArray | Label[]; + mediaType?: string; + _expandable?: Record; }; /** Metadata object for page, blogpost, comment content */ @@ -68,4 +70,6 @@ export const ContentMetadataSchema: z.ZodType = apiObject({ properties: GenericLinksSchema.optional(), frontend: z.record(z.string(), z.any()).optional(), labels: z.union([LabelArraySchema, z.array(LabelSchema)]).optional(), + mediaType: z.string().optional(), + _expandable: z.record(z.string(), z.any()).optional(), }); diff --git a/src/v1/models/group.ts b/src/v1/models/group.ts index 4dd65c87..ea1571f8 100644 --- a/src/v1/models/group.ts +++ b/src/v1/models/group.ts @@ -23,6 +23,7 @@ export const GroupSchema = apiObject({ */ managedBy: z.enum(['ADMINS', 'EXTERNAL', 'TEAM_MEMBERS', 'OPEN']).optional(), _links: GenericLinksSchema.optional(), + resourceAri: z.string().optional(), }); export type Group = z.infer; diff --git a/src/v1/models/message.ts b/src/v1/models/message.ts index f78ab88f..e25d950a 100644 --- a/src/v1/models/message.ts +++ b/src/v1/models/message.ts @@ -4,6 +4,7 @@ import { apiObject } from '#/core'; export const MessageSchema = apiObject({ translation: z.string().optional(), args: z.array(z.union([z.string(), z.record(z.string(), z.any())])), + key: z.string().optional(), }); export type Message = z.infer; diff --git a/src/v1/models/space.ts b/src/v1/models/space.ts index 84d42943..400c569a 100644 --- a/src/v1/models/space.ts +++ b/src/v1/models/space.ts @@ -58,6 +58,7 @@ export type Space = { typeSettings?: string; }; _links: GenericLinks; + ari?: string; }; export const SpaceSchema: z.ZodType = apiObject({ @@ -106,4 +107,5 @@ export const SpaceSchema: z.ZodType = apiObject({ typeSettings: z.string().optional(), }), _links: GenericLinksSchema, + ari: z.string().optional(), }); diff --git a/src/v1/models/user.ts b/src/v1/models/user.ts index 73f28fc8..09c26fec 100644 --- a/src/v1/models/user.ts +++ b/src/v1/models/user.ts @@ -32,6 +32,8 @@ export type User = { personalSpace?: string; }; _links?: GenericLinks; + accountStatus?: string; + locale?: string; }; export const UserSchema: z.ZodType = apiObject({ @@ -68,4 +70,6 @@ export const UserSchema: z.ZodType = apiObject({ personalSpace: z.string().optional(), }).optional(), _links: GenericLinksSchema.optional(), + accountStatus: z.string().optional(), + locale: z.string().optional(), }); diff --git a/src/v1/models/userAnonymous.ts b/src/v1/models/userAnonymous.ts index 1d1fb0a9..6c0ee32c 100644 --- a/src/v1/models/userAnonymous.ts +++ b/src/v1/models/userAnonymous.ts @@ -13,6 +13,9 @@ export const UserAnonymousSchema = apiObject({ operations: z.string().optional(), }).optional(), _links: GenericLinksSchema, + accountStatus: z.string().optional(), + locale: z.string().optional(), + isExternalCollaborator: z.boolean().optional(), }); export type UserAnonymous = z.infer; diff --git a/src/v1/models/version.ts b/src/v1/models/version.ts index 329b4ad9..2ac1b2bc 100644 --- a/src/v1/models/version.ts +++ b/src/v1/models/version.ts @@ -23,6 +23,8 @@ export type Version = { confRev?: string | null; syncRev?: string | null; syncRevSource?: string | null; + ncsStepVersion?: string; + ncsStepVersionSource?: string; }; export const VersionSchema: z.ZodType = apiObject({ @@ -49,4 +51,6 @@ export const VersionSchema: z.ZodType = apiObject({ syncRev: z.string().nullish(), /** Source of the synchrony revision */ syncRevSource: z.string().nullish(), + ncsStepVersion: z.string().optional(), + ncsStepVersionSource: z.string().optional(), }); diff --git a/src/v1/models/watchUser.ts b/src/v1/models/watchUser.ts index 4aa832e0..c2377411 100644 --- a/src/v1/models/watchUser.ts +++ b/src/v1/models/watchUser.ts @@ -28,6 +28,9 @@ export const WatchUserSchema = apiObject({ email: z.string(), publicName: z.string(), personalSpace: z.record(z.string(), z.any()).nullable(), + accountStatus: z.string().optional(), + locale: z.string().optional(), + guest: z.boolean().optional(), }); export type WatchUser = z.infer; diff --git a/src/v2/models/blogPostComment.ts b/src/v2/models/blogPostComment.ts index d91ad56d..861c9fc5 100644 --- a/src/v2/models/blogPostComment.ts +++ b/src/v2/models/blogPostComment.ts @@ -16,6 +16,7 @@ export const BlogPostCommentSchema = apiObject({ version: VersionSchema.nullish(), body: BodySummarySchema.nullish(), _links: CommentLinksSchema.nullish(), + resolutionStatus: z.string().optional(), }); export type BlogPostComment = z.infer; diff --git a/src/v2/models/childrenComment.ts b/src/v2/models/childrenComment.ts index 5f25f952..b5297975 100644 --- a/src/v2/models/childrenComment.ts +++ b/src/v2/models/childrenComment.ts @@ -16,6 +16,8 @@ export const ChildrenCommentSchema = apiObject({ version: VersionSchema.nullish(), body: BodySummarySchema.nullish(), _links: CommentLinksSchema.nullish(), + resolutionStatus: z.string().optional(), + pageId: z.string().optional(), }); export type ChildrenComment = z.infer; diff --git a/src/v2/models/database.ts b/src/v2/models/database.ts index 6ae6484c..514db324 100644 --- a/src/v2/models/database.ts +++ b/src/v2/models/database.ts @@ -28,6 +28,8 @@ export const DatabaseSchema = apiObject({ spaceId: z.string().optional(), version: VersionSchema.nullish(), _links: DatabaseLinksSchema.nullish(), + operations: z.record(z.string(), z.any()).optional(), + properties: z.record(z.string(), z.any()).optional(), }); export type Database = z.infer; diff --git a/src/v2/models/descendants.ts b/src/v2/models/descendants.ts index be76519a..3baa6ef2 100644 --- a/src/v2/models/descendants.ts +++ b/src/v2/models/descendants.ts @@ -20,6 +20,7 @@ export const DescendantsSchema = apiObject({ * content tree. */ childPosition: z.number().nullish(), + lastModified: z.string().optional(), }); export type Descendants = z.infer; diff --git a/src/v2/models/folder.ts b/src/v2/models/folder.ts index 0a206477..dd97a021 100644 --- a/src/v2/models/folder.ts +++ b/src/v2/models/folder.ts @@ -28,6 +28,8 @@ export const FolderSchema = apiObject({ spaceId: z.string().optional(), version: VersionSchema.nullish(), _links: FolderLinksSchema.nullish(), + operations: z.record(z.string(), z.any()).optional(), + properties: z.record(z.string(), z.any()).optional(), }); export type Folder = z.infer; diff --git a/src/v2/models/footerComment.ts b/src/v2/models/footerComment.ts index 0a51b84f..56319ba2 100644 --- a/src/v2/models/footerComment.ts +++ b/src/v2/models/footerComment.ts @@ -49,6 +49,7 @@ export const FooterCommentSchema = apiObject({ }).nullish(), body: BodySchema.nullish(), _links: CommentLinksSchema.nullish(), + resolutionStatus: z.string().optional(), }); export type FooterComment = z.infer; diff --git a/src/v2/models/inlineComment.ts b/src/v2/models/inlineComment.ts index 909fd9a6..1d0c4392 100644 --- a/src/v2/models/inlineComment.ts +++ b/src/v2/models/inlineComment.ts @@ -44,6 +44,8 @@ export const InlineCommentSchema = apiObject({ inlineMarkerRef: z.string().optional(), /** Text that is highlighted. */ inlineOriginalSelection: z.string().optional(), + 'inline-marker-ref': z.string().optional(), + 'inline-original-selection': z.string().optional(), }).nullish(), operations: apiObject({ results: z.array(OperationSchema).nullish(), diff --git a/src/v2/models/inlineCommentChildren.ts b/src/v2/models/inlineCommentChildren.ts index 03ff2245..d979d511 100644 --- a/src/v2/models/inlineCommentChildren.ts +++ b/src/v2/models/inlineCommentChildren.ts @@ -20,6 +20,7 @@ export const InlineCommentChildrenSchema = apiObject({ resolutionStatus: InlineCommentResolutionStatusSchema.optional(), properties: InlineCommentPropertiesSchema.nullish(), _links: CommentLinksSchema.nullish(), + pageId: z.string().optional(), }); export type InlineCommentChildren = z.infer; diff --git a/src/v2/models/pageComment.ts b/src/v2/models/pageComment.ts index 5788bec7..39ed5f55 100644 --- a/src/v2/models/pageComment.ts +++ b/src/v2/models/pageComment.ts @@ -16,6 +16,7 @@ export const PageCommentSchema = apiObject({ version: VersionSchema.nullish(), body: BodySummarySchema.nullish(), _links: CommentLinksSchema.nullish(), + resolutionStatus: z.string().optional(), }); export type PageComment = z.infer; diff --git a/src/v2/models/pageSummary.ts b/src/v2/models/pageSummary.ts index 029bc78a..dd7526a6 100644 --- a/src/v2/models/pageSummary.ts +++ b/src/v2/models/pageSummary.ts @@ -32,6 +32,7 @@ export const PageSummarySchema = apiObject({ version: VersionSchema.nullish(), body: BodySummarySchema.nullish(), _links: AbstractPageLinksSchema.nullish(), + sourceTemplateEntityId: z.string().optional(), }); export type PageSummary = z.infer; diff --git a/src/v2/models/smartLink.ts b/src/v2/models/smartLink.ts index aac9a935..51163980 100644 --- a/src/v2/models/smartLink.ts +++ b/src/v2/models/smartLink.ts @@ -33,6 +33,8 @@ export const SmartLinkSchema = apiObject({ spaceId: z.string().optional(), version: VersionSchema.nullish(), _links: SmartLinkLinksSchema.nullish(), + operations: z.record(z.string(), z.any()).optional(), + properties: z.record(z.string(), z.any()).optional(), }); export type SmartLink = z.infer; diff --git a/src/v2/models/space.ts b/src/v2/models/space.ts index 8a5058ed..dbf79da5 100644 --- a/src/v2/models/space.ts +++ b/src/v2/models/space.ts @@ -52,6 +52,7 @@ export const SpaceSchema = apiObject({ _links: OptionalFieldLinksSchema.nullish(), }).nullish(), _links: SpaceLinksSchema.nullish(), + currentActiveAlias: z.string().optional(), }); export type Space = z.infer; diff --git a/src/v2/models/whiteboard.ts b/src/v2/models/whiteboard.ts index 70c50060..57c6a268 100644 --- a/src/v2/models/whiteboard.ts +++ b/src/v2/models/whiteboard.ts @@ -28,6 +28,8 @@ export const WhiteboardSchema = apiObject({ spaceId: z.string().optional(), version: VersionSchema.nullish(), _links: WhiteboardLinksSchema.nullish(), + operations: z.record(z.string(), z.any()).optional(), + properties: z.record(z.string(), z.any()).optional(), }); export type Whiteboard = z.infer; From c70e1ba19f4988b90e123ae2d5d1ce481ad9f16a Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 18:58:50 +0300 Subject: [PATCH 3/9] chore: changeset for the undocumented-field descriptions --- .changeset/proud-otters-cheer.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/proud-otters-cheer.md diff --git a/.changeset/proud-otters-cheer.md b/.changeset/proud-otters-cheer.md new file mode 100644 index 00000000..1c0e214f --- /dev/null +++ b/.changeset/proud-otters-cheer.md @@ -0,0 +1,14 @@ +--- +'confluence.js': patch +--- + +Types now describe the fields Confluence returns that its own spec never documented. + +The schema audit shipped in the previous release found 161 undocumented fields across 93 endpoints — keys the API sends +at runtime that the published types hid. This release adds 154 of them into the types; the remaining 7 sit behind +inline or collection schemas and are tracked separately. + +Every addition is optional and additive. Nothing that used to type-check stops doing so, runtime validation is +unchanged, and no field is renamed or removed — consumers simply gain access in the types to values they were already +receiving: `_links` keys on every entity, the `Container` and `Version` fields, user `accountStatus`/`locale`, comment +`resolutionStatus`, and more. From f65d27b0c26be83c7e827df4f25a4beca33ca5b1 Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 19:06:19 +0300 Subject: [PATCH 4/9] ci: create the release tag the changesets action misses under pnpm --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad82fa4d..f4ec806d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,3 +63,43 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # changesets/action tags and releases only the packages it detects as + # published, and it detects them by grepping the publish command's stdout for + # the "New tag:" lines that only `changeset publish` prints — never `pnpm + # publish`. So the publish path uploads to npm but leaves no tag behind, and + # v3.0.0 had to be tagged by hand. This step closes that gap without touching + # the known-good `pnpm publish --provenance` command. + # + # It fires on every master push, so it tells the two paths apart itself: + # - changesets still pending → the action only opened/updated the version + # PR; nothing was published, so do nothing. + # - none pending → the version commit merged and was published, + # so tag it. Idempotent: skips when the tag is + # already there, so a re-run cannot double-tag. + - name: 🏷️ Tag and release the published version + if: steps.changesets.outputs.published != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pending="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')" + if [ "${pending}" != "0" ]; then + echo "Changesets still pending — the action opened the version PR, nothing published. Nothing to tag." + exit 0 + fi + + version="$(node -p "require('./package.json').version")" + tag="v${version}" + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Tag ${tag} already exists — nothing to do." + exit 0 + fi + + awk -v h="## ${version}" '$0==h{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md > /tmp/release-notes.md + if [ -s /tmp/release-notes.md ]; then + gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --notes-file /tmp/release-notes.md + else + echo "No CHANGELOG section for ${version}; falling back to generated notes." + gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --generate-notes + fi From e5fadac6d0cb31937b57bbf0d9cf8f7a366b45ff Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 20:00:08 +0300 Subject: [PATCH 5/9] ci: detect the publish path by the committed version, not the working tree --- .github/workflows/release.yml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4ec806d..2e59cbb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,28 +71,27 @@ jobs: # v3.0.0 had to be tagged by hand. This step closes that gap without touching # the known-good `pnpm publish --provenance` command. # - # It fires on every master push, so it tells the two paths apart itself: - # - changesets still pending → the action only opened/updated the version - # PR; nothing was published, so do nothing. - # - none pending → the version commit merged and was published, - # so tag it. Idempotent: skips when the tag is - # already there, so a re-run cannot double-tag. + # It fires on every master push and tells the two paths apart by the version in + # the *committed* HEAD, not the working tree. The action's `version` step + # rewrites package.json and deletes the changesets in place on the version-PR + # path too — so the working tree always looks like the next release, and reading + # it (or counting leftover changesets) mistakes the version-PR path for a + # publish. Only the commit that merged the version PR actually carries the + # bumped version, so: + # - HEAD still holds the current version → its tag exists → nothing to do + # (this is the version-PR path, or a re-run). + # - HEAD holds a new, untagged version → the version PR merged and published, + # so tag that commit. Idempotent: the tag check makes re-runs safe. - name: 🏷️ Tag and release the published version if: steps.changesets.outputs.published != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - pending="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')" - if [ "${pending}" != "0" ]; then - echo "Changesets still pending — the action opened the version PR, nothing published. Nothing to tag." - exit 0 - fi - - version="$(node -p "require('./package.json').version")" + version="$(git show HEAD:package.json | jq -r .version)" tag="v${version}" if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then - echo "Tag ${tag} already exists — nothing to do." + echo "Tag ${tag} already exists — nothing to do (version-PR path or re-run)." exit 0 fi From 5c0fb45cbd166b449b406ccd5916c6c933d2e966 Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 21:11:57 +0300 Subject: [PATCH 6/9] feat: describe the last undocumented fields Confluence returns The remaining seven the schema audit reported: Version's comment/attachment, the like collection's count, the hyphenated inline-comment property keys, the async content-body's content, and noAccessEmails on the check-access response. Drift is now 0. --- src/v1/models/asyncContentBody.ts | 1 + src/v2/models/accessByEmail.ts | 2 ++ src/v2/models/inlineCommentProperties.ts | 2 ++ src/v2/models/optionalFieldMeta.ts | 2 ++ src/v2/models/version.ts | 2 ++ 5 files changed, 9 insertions(+) diff --git a/src/v1/models/asyncContentBody.ts b/src/v1/models/asyncContentBody.ts index 3cde9475..7c370d0e 100644 --- a/src/v1/models/asyncContentBody.ts +++ b/src/v1/models/asyncContentBody.ts @@ -42,6 +42,7 @@ export const AsyncContentBodySchema = apiObject({ mediaToken: z.string().optional(), }).optional(), _links: GenericLinksSchema.optional(), + content: z.unknown().optional(), }); export type AsyncContentBody = z.infer; diff --git a/src/v2/models/accessByEmail.ts b/src/v2/models/accessByEmail.ts index aec7cd42..59a04088 100644 --- a/src/v2/models/accessByEmail.ts +++ b/src/v2/models/accessByEmail.ts @@ -6,6 +6,8 @@ export const AccessByEmailSchema = apiObject({ emailsWithoutAccess: z.array(z.string()).optional(), /** List of invalid emails provided in the request. */ invalidEmails: z.array(z.string()).optional(), + /** Emails from the request that have no access to the site. */ + noAccessEmails: z.array(z.unknown()).optional(), }); export type AccessByEmail = z.infer; diff --git a/src/v2/models/inlineCommentProperties.ts b/src/v2/models/inlineCommentProperties.ts index a57e9262..59a5d1ce 100644 --- a/src/v2/models/inlineCommentProperties.ts +++ b/src/v2/models/inlineCommentProperties.ts @@ -6,6 +6,8 @@ export const InlineCommentPropertiesSchema = apiObject({ inlineMarkerRef: z.string().optional(), /** Text that is highlighted. */ inlineOriginalSelection: z.string().optional(), + 'inline-marker-ref': z.string().optional(), + 'inline-original-selection': z.string().optional(), }); export type InlineCommentProperties = z.infer; diff --git a/src/v2/models/optionalFieldMeta.ts b/src/v2/models/optionalFieldMeta.ts index 7d0915b1..2cf764b8 100644 --- a/src/v2/models/optionalFieldMeta.ts +++ b/src/v2/models/optionalFieldMeta.ts @@ -9,6 +9,8 @@ export const OptionalFieldMetaSchema = apiObject({ * next set of results. */ cursor: z.string().optional(), + /** Number of items in the collection. */ + count: z.number().optional(), }); export type OptionalFieldMeta = z.infer; diff --git a/src/v2/models/version.ts b/src/v2/models/version.ts index b0d3a587..f538a759 100644 --- a/src/v2/models/version.ts +++ b/src/v2/models/version.ts @@ -19,6 +19,8 @@ export const VersionSchema = apiObject({ ncsStepVersion: z.string().nullish(), /** Account ids of everyone who has contributed to the content. */ contributorIds: z.array(z.unknown()).optional(), + comment: z.record(z.string(), z.any()).optional(), + attachment: z.record(z.string(), z.any()).optional(), }); export type Version = z.infer; From 7a2f92975f45b8ae8aa0ef37e623bfca123bd333 Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 21:26:04 +0300 Subject: [PATCH 7/9] chore: changeset for the last undocumented-field descriptions --- .changeset/quiet-melons-jump.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/quiet-melons-jump.md diff --git a/.changeset/quiet-melons-jump.md b/.changeset/quiet-melons-jump.md new file mode 100644 index 00000000..173be175 --- /dev/null +++ b/.changeset/quiet-melons-jump.md @@ -0,0 +1,13 @@ +--- +'confluence.js': patch +--- + +Describe the last fields the schema audit found undocumented. + +Closes the tail left after the previous release: `comment` and `attachment` on a version returned inside a +`versions` collection, the `count` on a like collection's meta, the hyphenated `inline-marker-ref` / +`inline-original-selection` inline-comment property keys, the async content-body's `content`, and `noAccessEmails` on +the check-access-by-email response. The schema audit now reports no drift at all. + +As before, every addition is optional and additive: nothing that used to type-check stops doing so, runtime validation +is unchanged, and no field is renamed or removed — the types simply describe more of what Confluence already returns. From 96c417a07cdefefbf9384c6b3c0a91689c8eebcf Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 21:50:30 +0300 Subject: [PATCH 8/9] ci: enable npm provenance via .npmrc for pnpm's native publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 11 publishes natively and reads provenance from .npmrc, not the NPM_CONFIG_PROVENANCE env var the workflow set — so 3.0.2 shipped without an attestation. Move the flag to .npmrc and drop the ineffective env var. Changeset cuts 3.0.3 to restore provenance on the latest tag; contents are identical to 3.0.2. --- .changeset/quiet-melons-jump.md | 13 ------------- .changeset/warm-eagles-share.md | 10 ++++++++++ .github/workflows/release.yml | 9 +++++---- .npmrc | 4 ++++ 4 files changed, 19 insertions(+), 17 deletions(-) delete mode 100644 .changeset/quiet-melons-jump.md create mode 100644 .changeset/warm-eagles-share.md diff --git a/.changeset/quiet-melons-jump.md b/.changeset/quiet-melons-jump.md deleted file mode 100644 index 173be175..00000000 --- a/.changeset/quiet-melons-jump.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'confluence.js': patch ---- - -Describe the last fields the schema audit found undocumented. - -Closes the tail left after the previous release: `comment` and `attachment` on a version returned inside a -`versions` collection, the `count` on a like collection's meta, the hyphenated `inline-marker-ref` / -`inline-original-selection` inline-comment property keys, the async content-body's `content`, and `noAccessEmails` on -the check-access-by-email response. The schema audit now reports no drift at all. - -As before, every addition is optional and additive: nothing that used to type-check stops doing so, runtime validation -is unchanged, and no field is renamed or removed — the types simply describe more of what Confluence already returns. diff --git a/.changeset/warm-eagles-share.md b/.changeset/warm-eagles-share.md new file mode 100644 index 00000000..6bb54e61 --- /dev/null +++ b/.changeset/warm-eagles-share.md @@ -0,0 +1,10 @@ +--- +'confluence.js': patch +--- + +Restore the npm provenance attestation. + +3.0.2 was published without one: the release switched to `changeset publish`, which delegates to pnpm's native publish, +and pnpm reads the provenance flag from `.npmrc` rather than the `NPM_CONFIG_PROVENANCE` env var the workflow was +setting. This release turns provenance on in `.npmrc`, so the published package is signed again — the same SLSA +attestation 3.0.1 carried. No code changes; the package contents are identical to 3.0.2. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfdb344f..435f8fd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,9 +61,11 @@ jobs: # - it prints the `New tag:` lines the action greps to learn what shipped, so # the action tags the actual release commit and cuts the GitHub release — # `pnpm publish` prints neither, which is why v3.0.0 had to be tagged by hand. - # Provenance is preserved via NPM_CONFIG_PROVENANCE: `changeset publish` shells - # out to `pnpm publish`, which reads it as `--provenance` (needs id-token: write, - # granted above). + # Provenance is switched on in .npmrc (`provenance=true`), not here: `changeset + # publish` delegates to pnpm 11's native publish, which reads .npmrc but ignores + # the NPM_CONFIG_* env prefix (its own is PNPM_CONFIG_*) — 3.0.2 shipped without + # an attestation because the env var never reached it. id-token: write, granted + # above, is what lets the OIDC-authenticated publish sign the attestation. - name: 📦 Create Release PR or Publish id: changesets uses: changesets/action@v1 @@ -76,4 +78,3 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: 'true' diff --git a/.npmrc b/.npmrc index 214c29d1..86410085 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,5 @@ registry=https://registry.npmjs.org/ +# Provenance for the release publish. pnpm 11's native publish reads this from +# .npmrc; it does NOT honour the NPM_CONFIG_PROVENANCE env var (that prefix is npm's, +# pnpm's is PNPM_CONFIG_*), which is why 3.0.2 shipped without an attestation. +provenance=true From a8ded34b38cd3ee1eafa8d79c5fc6d53887b54da Mon Sep 17 00:00:00 2001 From: Vladislav Tupikin Date: Tue, 21 Jul 2026 22:16:14 +0300 Subject: [PATCH 9/9] ci: publish via pnpm --provenance so the SLSA attestation is signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changeset publish took pnpm's OIDC trusted-publishing path, which produces no provenance attestation and ignores both .npmrc provenance=true and NPM_CONFIG_PROVENANCE — 3.0.2 and 3.0.3 shipped unsigned. Only the explicit --provenance flag makes pnpm sign, as 3.0.1 did. release:publish is now scripts/publish.ts: it no-ops if the version is already on the registry (keeping empty pushes clean) and otherwise runs pnpm publish --provenance. Restores the custom tag step, since pnpm publish prints no New tag: line for the action to act on. --- .github/workflows/release.yml | 59 ++++++++++++++++++++++++++--------- .npmrc | 4 --- package.json | 2 +- scripts/publish.ts | 37 ++++++++++++++++++++++ 4 files changed, 82 insertions(+), 20 deletions(-) create mode 100644 scripts/publish.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 435f8fd3..a233ae96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,22 +50,19 @@ jobs: # Changesets action: # - If changesets are pending → creates/updates "Version Packages" PR - # - If this IS the version commit (no pending changesets) → publishes to npm, - # then tags the release commit and creates the GitHub release. + # - If none are pending → runs `release:publish` (the publish path) # - # `release:publish` is `changeset publish`, not `pnpm publish`, and that choice - # is load-bearing here: - # - it queries the registry per package and publishes only versions not - # already there, so a master push with nothing to release is a clean no-op - # instead of `pnpm publish` erroring on "cannot publish over 3.0.1"; - # - it prints the `New tag:` lines the action greps to learn what shipped, so - # the action tags the actual release commit and cuts the GitHub release — - # `pnpm publish` prints neither, which is why v3.0.0 had to be tagged by hand. - # Provenance is switched on in .npmrc (`provenance=true`), not here: `changeset - # publish` delegates to pnpm 11's native publish, which reads .npmrc but ignores - # the NPM_CONFIG_* env prefix (its own is PNPM_CONFIG_*) — 3.0.2 shipped without - # an attestation because the env var never reached it. id-token: write, granted - # above, is what lets the OIDC-authenticated publish sign the attestation. + # `release:publish` is `scripts/publish.ts`, which publishes with + # `pnpm publish --provenance`. The explicit flag is the whole point: it is the + # only thing that makes pnpm sign the SLSA provenance attestation. Given an + # id-token, pnpm's native publish otherwise takes the OIDC trusted-publishing + # path and produces no attestation, ignoring both `.npmrc provenance=true` and + # NPM_CONFIG_PROVENANCE — which is why 3.0.2 and 3.0.3 shipped unsigned. The + # script first checks the registry and no-ops if the version is already out, so a + # master push with nothing to release doesn't fail on "cannot publish over". + # + # `pnpm publish` prints no "New tag:" line for the action to parse, so it creates + # no tag or release; the step below does that instead. - name: 📦 Create Release PR or Publish id: changesets uses: changesets/action@v1 @@ -78,3 +75,35 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # changesets/action tags and releases only packages it detects as published, and + # it detects them from the "New tag:" lines that `pnpm publish` does not print — + # so on the publish path it leaves no tag behind. This fills that gap. + # + # It reads the version from the *committed* HEAD, not the working tree: the + # action's `version` step rewrites package.json in place on the version-PR path, + # so the working tree always looks like the next release. Only the commit that + # merged the version PR actually carries the bumped version, so: + # - HEAD still holds the current version → its tag exists → nothing to do. + # - HEAD holds a new, untagged version → the version PR merged and published, + # so tag that commit. The tag check keeps re-runs idempotent. + - name: 🏷️ Tag and release the published version + if: steps.changesets.outputs.published != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version="$(git show HEAD:package.json | jq -r .version)" + tag="v${version}" + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Tag ${tag} already exists — nothing to do (version-PR path or re-run)." + exit 0 + fi + + awk -v h="## ${version}" '$0==h{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md > /tmp/release-notes.md + if [ -s /tmp/release-notes.md ]; then + gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --notes-file /tmp/release-notes.md + else + echo "No CHANGELOG section for ${version}; falling back to generated notes." + gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --generate-notes + fi diff --git a/.npmrc b/.npmrc index 86410085..214c29d1 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1 @@ registry=https://registry.npmjs.org/ -# Provenance for the release publish. pnpm 11's native publish reads this from -# .npmrc; it does NOT honour the NPM_CONFIG_PROVENANCE env var (that prefix is npm's, -# pnpm's is PNPM_CONFIG_*), which is why 3.0.2 shipped without an attestation. -provenance=true diff --git a/package.json b/package.json index 7f8988c8..33300ac9 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "changeset:version": "changeset version", "changeset:status": "changeset status", "version:dev": "node scripts/devVersion.ts", - "release:publish": "changeset publish", + "release:publish": "node scripts/publish.ts", "prepare": "husky", "audit:schemas": "node scripts/schemaAudit.ts" }, diff --git a/scripts/publish.ts b/scripts/publish.ts new file mode 100644 index 00000000..b63f5627 --- /dev/null +++ b/scripts/publish.ts @@ -0,0 +1,37 @@ +/** + * Publishes the package — but only if this version is not already on the registry. + * + * changesets/action runs this on every master push with no pending changesets (the publish path). When the version PR + * has just merged, that is a new version to publish; on any other such push the version is already out and a plain + * `pnpm publish` would fail with "cannot publish over the previously published version". Checking the registry first + * turns that case into a clean no-op — the way `changeset publish` does — while keeping `pnpm publish --provenance`. + * + * That command is the point: it is the only one that actually produces the SLSA provenance attestation. pnpm's native + * publish takes the OIDC trusted-publishing path when id-token is available and, on that path, ignores the provenance + * config in `.npmrc` and the NPM_CONFIG_PROVENANCE env var alike — 3.0.2 and 3.0.3 shipped unsigned for exactly that + * reason. Only the explicit `--provenance` flag makes it sign, as 3.0.1 did. + * + * Runs on bare `node` — keep the types erasable, as in scripts/schemaAudit.ts. + */ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name: string; version: string }; + +// `npm view pkg@version version` prints the version and exits 0 when it exists, and errors (E404) when it does not. +// A published version is the "nothing to do" case; anything else means this is a new version to ship. +const lookup = spawnSync('npm', ['view', `${pkg.name}@${pkg.version}`, 'version'], { cwd: root, encoding: 'utf8' }); + +if (lookup.status === 0 && lookup.stdout.trim() !== '') { + console.log(`${pkg.name}@${pkg.version} is already published — nothing to do.`); + process.exit(0); +} + +console.log(`Publishing ${pkg.name}@${pkg.version} with provenance…`); + +const published = spawnSync('pnpm', ['publish', '--no-git-checks', '--provenance'], { cwd: root, stdio: 'inherit' }); + +process.exit(published.status ?? 1);