blogwright-pds 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oauth.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * atproto OAuth (confidential client) for standard.site publishing — the only
3
+ * module that touches @atproto/oauth-client-node. The blog itself is the OAuth
4
+ * client: its metadata + JWKS are static files on the site (client-metadata.ts),
5
+ * the private key and session live in the Secrets Manager secret (secret.ts),
6
+ * and every token refresh persists the rotated refresh token back there.
7
+ */
8
+ import { JoseKey, NodeOAuthClient, OAuthResponseError, TokenRefreshError, TokenRevokedError, } from '@atproto/oauth-client-node';
9
+ import { clientMetadata, clientMetadataUrl, jwksDocument, jwksUrl } from './client-metadata.js';
10
+ import { loadPdsSecret, sessionStoreForSecret } from './secret.js';
11
+ import { requirePdsConfig } from './sync.js';
12
+ import { PdsClient } from './xrpc.js';
13
+ const DEFAULT_HANDLE_RESOLVER = 'https://public.api.bsky.app';
14
+ function requireDomain(ctx) {
15
+ if (!ctx.domain)
16
+ throw new Error('pds OAuth requires a configured domain');
17
+ return ctx.domain;
18
+ }
19
+ function requireClientKey(secret, pds) {
20
+ if (!secret.clientKey) {
21
+ throw new Error(`secret "${pds.secretName}" has no OAuth client key — run \`blogwright pds keygen\``);
22
+ }
23
+ return secret.clientKey;
24
+ }
25
+ /** The OAuth client, keyed with the secret's private JWK. */
26
+ async function buildClient(ctx, clientKey) {
27
+ const pds = requirePdsConfig(ctx);
28
+ const states = new Map();
29
+ return new NodeOAuthClient({
30
+ clientMetadata: clientMetadata(requireDomain(ctx), pds),
31
+ keyset: [await JoseKey.fromJWK(clientKey)],
32
+ responseMode: 'query',
33
+ // The authorize → callback round-trip happens within one `pds login`
34
+ // process, so authorization state never needs to outlive it.
35
+ stateStore: {
36
+ get: async (key) => states.get(key),
37
+ set: async (key, state) => void states.set(key, state),
38
+ del: async (key) => void states.delete(key),
39
+ },
40
+ sessionStore: sessionStoreForSecret(ctx.clients.secrets, pds.secretName),
41
+ handleResolver: pds.handleResolver ?? DEFAULT_HANDLE_RESOLVER,
42
+ // Deliberately NO requestLock. The session lives in shared Secrets Manager
43
+ // state, and syncs can run concurrently (overlapping CI deploys). Providing
44
+ // a lock tells the library it holds the only instance, which disables its
45
+ // invalid_grant recovery (re-read the store and adopt a concurrently
46
+ // rotated session) and instead deletes the session — destroying the valid
47
+ // token a parallel sync just persisted. A process-local lock cannot
48
+ // serialize cross-process refreshes, so lock-less with recovery is correct.
49
+ });
50
+ }
51
+ function deepEqual(a, b) {
52
+ if (a === b)
53
+ return true;
54
+ if (Array.isArray(a) && Array.isArray(b)) {
55
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
56
+ }
57
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
58
+ const ka = Object.keys(a).sort();
59
+ const kb = Object.keys(b).sort();
60
+ return (deepEqual(ka, kb) &&
61
+ ka.every((k) => deepEqual(a[k], b[k])));
62
+ }
63
+ return false;
64
+ }
65
+ /** A fresh ES256 private JWK for private_key_jwt client authentication. */
66
+ export async function generateClientKey(kid) {
67
+ const key = await JoseKey.generate(['ES256'], kid);
68
+ const privateJwk = key.privateJwk;
69
+ if (!privateJwk)
70
+ throw new Error('generated key has no private JWK');
71
+ return JSON.parse(JSON.stringify(privateJwk));
72
+ }
73
+ /** Public half of the client key, for the served JWKS. */
74
+ export async function publicClientJwk(clientKey) {
75
+ const key = await JoseKey.fromJWK(clientKey);
76
+ const publicJwk = (key.publicJwk ?? {});
77
+ const source = Object.keys(publicJwk).length > 0 ? publicJwk : clientKey;
78
+ // Round-trip through JSON: drops `d: undefined` left by the getter and
79
+ // matches exactly what the committed jwks.json file will contain.
80
+ const plain = JSON.parse(JSON.stringify(source));
81
+ delete plain.d;
82
+ return plain;
83
+ }
84
+ /**
85
+ * Check that the deployed /oauth/ documents match what this CLI would send the
86
+ * authorization server. Run before login/init — a stale or missing deployment
87
+ * would otherwise fail deep inside the OAuth flow.
88
+ */
89
+ export async function verifyClientAssets(ctx, fetchImpl = fetch) {
90
+ const pds = requirePdsConfig(ctx);
91
+ const domain = requireDomain(ctx);
92
+ const secret = await loadPdsSecret(ctx);
93
+ const clientKey = requireClientKey(secret, pds);
94
+ const expectations = [
95
+ [clientMetadataUrl(domain), clientMetadata(domain, pds)],
96
+ [jwksUrl(domain), jwksDocument(await publicClientJwk(clientKey))],
97
+ ];
98
+ for (const [url, expected] of expectations) {
99
+ const res = await fetchImpl(url);
100
+ if (!res.ok) {
101
+ throw new Error(`${url} is not deployed (HTTP ${res.status}) — commit public/oauth/* and release first`);
102
+ }
103
+ let deployed;
104
+ try {
105
+ deployed = await res.json();
106
+ }
107
+ catch {
108
+ throw new Error(`${url} is not valid JSON — re-run \`blogwright pds keygen\` and redeploy`);
109
+ }
110
+ if (!deepEqual(deployed, expected)) {
111
+ throw new Error(`${url} does not match the local client configuration — ` +
112
+ 're-run `blogwright pds keygen`, commit public/oauth/*, and release before logging in');
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * One-time interactive bootstrap: authorize in a browser, land on the site's
118
+ * /oauth/callback page, paste the redirect URL back. The client persists the
119
+ * session (and DID) into the secret through the session store.
120
+ */
121
+ export async function login(ctx, identifier, deps) {
122
+ await (deps.verifyAssets ?? verifyClientAssets)(ctx);
123
+ let flow = deps.flow;
124
+ if (!flow) {
125
+ const secret = await loadPdsSecret(ctx);
126
+ flow = await buildClient(ctx, requireClientKey(secret, requirePdsConfig(ctx)));
127
+ }
128
+ const url = await flow.authorize(identifier);
129
+ ctx.logger.info('Open this URL in a browser and approve access:');
130
+ ctx.logger.info(` ${url.toString()}`);
131
+ const pasted = await deps.promptLine('Paste the full URL of the /oauth/callback page you landed on: ');
132
+ let params;
133
+ try {
134
+ params = new URL(pasted.trim()).searchParams;
135
+ }
136
+ catch {
137
+ throw new Error('that was not a URL — paste the full callback address, query string and all');
138
+ }
139
+ if (params.get('error')) {
140
+ throw new Error(`authorization failed: ${params.get('error')} — ${params.get('error_description') ?? ''}`);
141
+ }
142
+ const { session } = await flow.callback(params);
143
+ ctx.logger.ok(`logged in as ${session.did}`);
144
+ return session.did;
145
+ }
146
+ function sessionExpired(err) {
147
+ if (err instanceof TokenRefreshError || err instanceof TokenRevokedError)
148
+ return true;
149
+ return err instanceof OAuthResponseError && err.error === 'invalid_grant';
150
+ }
151
+ /**
152
+ * Restore the stored OAuth session (transparently refreshing — the rotated
153
+ * refresh token lands back in the secret) and wrap it as a PdsClient. The
154
+ * session's fetchHandler adds DPoP + auth headers and handles nonce retries.
155
+ */
156
+ export async function openPdsRepo(ctx) {
157
+ const pds = requirePdsConfig(ctx);
158
+ const secret = await loadPdsSecret(ctx);
159
+ const clientKey = requireClientKey(secret, pds);
160
+ if (!secret.did || !secret.session) {
161
+ throw new Error(`secret "${pds.secretName}" has no OAuth session — run \`blogwright pds login\``);
162
+ }
163
+ const client = await buildClient(ctx, clientKey);
164
+ try {
165
+ const session = await client.restore(secret.did);
166
+ return { did: secret.did, repo: new PdsClient(secret.did, session.fetchHandler.bind(session)) };
167
+ }
168
+ catch (err) {
169
+ if (sessionExpired(err)) {
170
+ throw new Error('the stored OAuth session is no longer valid (refresh tokens expire after 180 idle ' +
171
+ 'days, and rotation races invalidate them) — re-run `blogwright pds login`', { cause: err });
172
+ }
173
+ throw err;
174
+ }
175
+ }
176
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/rkey.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * URL-derived AT Protocol record keys for standard.site documents.
3
+ *
4
+ * `tidFromPath`, `extractDate`, `hashBits`, and `encodeTid` are vendored from
5
+ * https://github.com/mastrojs/atproto (src/rkey.ts), MIT License, © 2025 Mastro.
6
+ * Behaviour must not diverge: rkeys derived here must match link tags any
7
+ * standard.site-aware client reconstructs with the reference implementation.
8
+ *
9
+ * Exported from the package as the `./rkey` subpath so the consuming site can
10
+ * derive its document <link> tags from the same implementation. rkey.test.ts pins
11
+ * the outputs to on-the-wire vectors that must never change for an existing path.
12
+ */
13
+ /** Derive a TID-format rkey from a URL path (deterministic, no PDS round-trip). */
14
+ export declare const tidFromPath: (path: string) => string;
15
+ /** Exported only for tests. */
16
+ export declare const extractDate: (path: string) => string | undefined;
17
+ /**
18
+ * Canonical URL path for a blog post — the shape `src/pages/posts/index.astro` links
19
+ * (trailing slash). Rkeys derive from this string, so slugs must never change after
20
+ * publication.
21
+ */
22
+ export declare const postPath: (slug: string) => string;
23
+ /** AT-URI of the standard.site document record for a post. */
24
+ export declare const documentUri: (did: string, slug: string) => string;
package/dist/rkey.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * URL-derived AT Protocol record keys for standard.site documents.
3
+ *
4
+ * `tidFromPath`, `extractDate`, `hashBits`, and `encodeTid` are vendored from
5
+ * https://github.com/mastrojs/atproto (src/rkey.ts), MIT License, © 2025 Mastro.
6
+ * Behaviour must not diverge: rkeys derived here must match link tags any
7
+ * standard.site-aware client reconstructs with the reference implementation.
8
+ *
9
+ * Exported from the package as the `./rkey` subpath so the consuming site can
10
+ * derive its document <link> tags from the same implementation. rkey.test.ts pins
11
+ * the outputs to on-the-wire vectors that must never change for an existing path.
12
+ */
13
+ /** Derive a TID-format rkey from a URL path (deterministic, no PDS round-trip). */
14
+ export const tidFromPath = (path) => {
15
+ if (path.replaceAll('/', '').length === 0) {
16
+ throw Error(`tidFromPath received empty path (after slashes were removed): ${path}`);
17
+ }
18
+ const dateStr = extractDate(path);
19
+ // TID: 64-bit int (bit 63 = 0)
20
+ // - bits 62-10 = microsecond timestamp (53 bits)
21
+ // - bits 9-0 = clock ID (10 bits)
22
+ let tid;
23
+ if (dateStr) {
24
+ const date = new Date(dateStr);
25
+ if (Number.isNaN(date.getTime())) {
26
+ // A date-like digit pattern that is not a real date (e.g. a product
27
+ // slug "sku-3456-78-90"). The reference implementation throws here, so
28
+ // no existing record can hold a date-derived TID for such a path —
29
+ // falling back to the whole-path hash cannot diverge from live rkeys.
30
+ tid = hashBits(path, 63);
31
+ }
32
+ else {
33
+ const micros = (BigInt(date.getTime()) * 1000n) & ((1n << 53n) - 1n);
34
+ const clockId = hashBits(path, 10);
35
+ tid = (micros << 10n) | clockId;
36
+ }
37
+ }
38
+ else {
39
+ tid = hashBits(path, 63);
40
+ }
41
+ return encodeTid(tid);
42
+ };
43
+ /** Exported only for tests. */
44
+ export const extractDate = (path) => {
45
+ const m = path.match(/(\d{4})[/-](\d{2})[/-](\d{2})/);
46
+ if (m) {
47
+ return `${m[1]}-${m[2]}-${m[3]}`;
48
+ }
49
+ return undefined;
50
+ };
51
+ /** FNV-1a hash truncated to `nrOfBits` bits. */
52
+ const hashBits = (s, nrOfBits) => {
53
+ const mask = (1n << BigInt(nrOfBits)) - 1n;
54
+ let h = 0xcbf29ce484222325n;
55
+ for (let i = 0; i < s.length; i++) {
56
+ h ^= BigInt(s.charCodeAt(i));
57
+ h = (h * 0x100000001b3n) & 0xffffffffffffffffn;
58
+ }
59
+ return h & mask;
60
+ };
61
+ const encodeTid = (n) => {
62
+ let result = '';
63
+ for (let i = 0; i < 13; i++) {
64
+ result = BASE32[Number(n & 31n)] + result;
65
+ n >>= 5n;
66
+ }
67
+ return result;
68
+ };
69
+ const BASE32 = '234567abcdefghijklmnopqrstuvwxyz';
70
+ // --- example-specific helpers (not vendored) ---
71
+ /**
72
+ * Canonical URL path for a blog post — the shape `src/pages/posts/index.astro` links
73
+ * (trailing slash). Rkeys derive from this string, so slugs must never change after
74
+ * publication.
75
+ */
76
+ export const postPath = (slug) => `/posts/${slug}/`;
77
+ /** AT-URI of the standard.site document record for a post. */
78
+ export const documentUri = (did, slug) => `at://${did}/site.standard.document/${tidFromPath(postPath(slug))}`;
79
+ //# sourceMappingURL=rkey.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rkey.js","sourceRoot":"","sources":["../src/rkey.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The Secrets Manager secret backing standard.site publishing: one JSON value
3
+ * holding the OAuth confidential-client private key, the account DID, and the
4
+ * current OAuth session (rotated on every token refresh).
5
+ *
6
+ * Writers: `pds keygen` (clientKey, clears session), `pds login` (session+did),
7
+ * and the session store below whenever the OAuth client refreshes tokens.
8
+ */
9
+ import type { Jwk, NodeSavedSession, NodeSavedSessionStore } from '@atproto/oauth-client-node';
10
+ import type { SecretsManagerClient } from 'blogwright-core';
11
+ import type { PdsContext } from './context.js';
12
+ /** The client surface secret persistence needs — structural, so tests can stub it. */
13
+ export type SecretsStore = Pick<SecretsManagerClient, 'getSecretValue' | 'upsertSecret'>;
14
+ export interface PdsSecret {
15
+ version: 1;
16
+ /** Private ES256 JWK authenticating the OAuth client (private_key_jwt). */
17
+ clientKey?: Jwk | undefined;
18
+ /** Account DID; set by `pds login`, checked against src/data/atproto.json. */
19
+ did?: string | undefined;
20
+ /** Current OAuth session (token set + DPoP key); rotates on refresh. */
21
+ session?: NodeSavedSession | undefined;
22
+ }
23
+ /** Parse the secret JSON, rejecting the pre-OAuth app-password shape. */
24
+ export declare function parsePdsSecret(raw: string, secretName: string): PdsSecret;
25
+ /**
26
+ * Fetch and parse the secret. Called only at keygen/login/sync time —
27
+ * secret material must never be loaded during context creation.
28
+ */
29
+ export declare function loadPdsSecret(ctx: PdsContext): Promise<PdsSecret>;
30
+ /**
31
+ * Read-modify-write the secret; starts from an empty v1 value when absent.
32
+ * `replaceLegacy` lets keygen start over from a pre-OAuth app-password value —
33
+ * the migration entry point; every other writer must reject it.
34
+ */
35
+ export declare function updatePdsSecret(secrets: SecretsStore, secretName: string, mutate: (secret: PdsSecret) => PdsSecret, opts?: {
36
+ replaceLegacy?: boolean;
37
+ }): Promise<PdsSecret>;
38
+ /**
39
+ * NodeOAuthClient session store backed by the secret. `set` runs on login and
40
+ * on every refresh-token rotation — persisting it is what keeps the CI session
41
+ * alive; `del` clears only the session (client key and DID survive a logout).
42
+ */
43
+ export declare function sessionStoreForSecret(secrets: SecretsStore, secretName: string): NodeSavedSessionStore;
package/dist/secret.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The Secrets Manager secret backing standard.site publishing: one JSON value
3
+ * holding the OAuth confidential-client private key, the account DID, and the
4
+ * current OAuth session (rotated on every token refresh).
5
+ *
6
+ * Writers: `pds keygen` (clientKey, clears session), `pds login` (session+did),
7
+ * and the session store below whenever the OAuth client refreshes tokens.
8
+ */
9
+ import { requirePdsConfig } from './sync.js';
10
+ const SECRET_DESCRIPTION = 'AT Protocol OAuth client key + session for standard.site publishing (blogwright pds)';
11
+ function parseJsonObject(raw, secretName) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(raw);
15
+ }
16
+ catch {
17
+ throw new Error(`secret "${secretName}" is not valid JSON`);
18
+ }
19
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
20
+ throw new Error(`secret "${secretName}" is not a JSON object`);
21
+ }
22
+ return parsed;
23
+ }
24
+ /** The pre-OAuth `{ identifier, password }` shape, replaced only by keygen. */
25
+ function isLegacySecret(parsed) {
26
+ return 'identifier' in parsed || 'password' in parsed;
27
+ }
28
+ /** Parse the secret JSON, rejecting the pre-OAuth app-password shape. */
29
+ export function parsePdsSecret(raw, secretName) {
30
+ const parsed = parseJsonObject(raw, secretName);
31
+ if (isLegacySecret(parsed)) {
32
+ throw new Error(`secret "${secretName}" holds app-password credentials — app passwords are no longer ` +
33
+ 'supported; run `blogwright pds keygen` then `blogwright pds login`');
34
+ }
35
+ if (parsed.version !== 1) {
36
+ throw new Error(`secret "${secretName}" has unsupported version ${String(parsed.version)}`);
37
+ }
38
+ return parsed;
39
+ }
40
+ /**
41
+ * Fetch and parse the secret. Called only at keygen/login/sync time —
42
+ * secret material must never be loaded during context creation.
43
+ */
44
+ export async function loadPdsSecret(ctx) {
45
+ const pds = requirePdsConfig(ctx);
46
+ const raw = await ctx.clients.secrets.getSecretValue(pds.secretName);
47
+ if (!raw) {
48
+ throw new Error(`no secret at "${pds.secretName}" — create it with \`blogwright pds keygen\``);
49
+ }
50
+ return parsePdsSecret(raw, pds.secretName);
51
+ }
52
+ /**
53
+ * Read-modify-write the secret; starts from an empty v1 value when absent.
54
+ * `replaceLegacy` lets keygen start over from a pre-OAuth app-password value —
55
+ * the migration entry point; every other writer must reject it.
56
+ */
57
+ export async function updatePdsSecret(secrets, secretName, mutate, opts = {}) {
58
+ const raw = await secrets.getSecretValue(secretName);
59
+ const current = !raw || (opts.replaceLegacy && isLegacySecret(parseJsonObject(raw, secretName)))
60
+ ? { version: 1 }
61
+ : parsePdsSecret(raw, secretName);
62
+ const next = mutate(current);
63
+ await secrets.upsertSecret(secretName, JSON.stringify(next), SECRET_DESCRIPTION);
64
+ return next;
65
+ }
66
+ /**
67
+ * NodeOAuthClient session store backed by the secret. `set` runs on login and
68
+ * on every refresh-token rotation — persisting it is what keeps the CI session
69
+ * alive; `del` clears only the session (client key and DID survive a logout).
70
+ */
71
+ export function sessionStoreForSecret(secrets, secretName) {
72
+ return {
73
+ async get(sub) {
74
+ const raw = await secrets.getSecretValue(secretName);
75
+ if (!raw)
76
+ return undefined;
77
+ const secret = parsePdsSecret(raw, secretName);
78
+ if (secret.did !== sub)
79
+ return undefined;
80
+ return secret.session;
81
+ },
82
+ async set(sub, session) {
83
+ // Retried: this write persists a just-rotated single-use refresh token.
84
+ // If it throws, the OAuth library revokes the new token and deletes the
85
+ // session — one Secrets Manager throttle at exactly the wrong moment
86
+ // would otherwise force an interactive re-login.
87
+ let lastError;
88
+ for (let attempt = 0; attempt < 3; attempt++) {
89
+ try {
90
+ await updatePdsSecret(secrets, secretName, (secret) => ({
91
+ ...secret,
92
+ did: sub,
93
+ session,
94
+ }));
95
+ return;
96
+ }
97
+ catch (err) {
98
+ lastError = err;
99
+ await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
100
+ }
101
+ }
102
+ throw lastError;
103
+ },
104
+ async del(sub) {
105
+ await updatePdsSecret(secrets, secretName, (secret) => secret.did === sub ? { ...secret, session: undefined } : secret);
106
+ },
107
+ };
108
+ }
109
+ //# sourceMappingURL=secret.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret.js","sourceRoot":"","sources":["../src/secret.ts"],"names":[],"mappings":"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"}
package/dist/sync.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { type FileSystem, type OpsConfig, type PdsConfig } from 'blogwright-core';
2
+ import { type PostMeta } from './content.js';
3
+ import type { PdsContext } from './context.js';
4
+ import { type PdsClient } from './xrpc.js';
5
+ /** The client surface the sync needs — structural, so tests can stub it. */
6
+ export type PdsRepo = Pick<PdsClient, 'listRecords' | 'getRecord' | 'putRecord'>;
7
+ /**
8
+ * Opens an authenticated repo for the configured account. Production wiring is
9
+ * oauth.ts#openPdsRepo (OAuth session restore); tests inject stubs.
10
+ */
11
+ export type OpenRepo = (ctx: PdsContext) => Promise<{
12
+ did: string;
13
+ repo: PdsRepo;
14
+ }>;
15
+ export declare const PUBLICATION_COLLECTION = "site.standard.publication";
16
+ export declare const DOCUMENT_COLLECTION = "site.standard.document";
17
+ /**
18
+ * Repo-relative path of the standard.site well-known file `pds init` writes;
19
+ * its segment under the public dir is protocol-fixed. The companion
20
+ * atproto.json path comes straight from `config.paths.atprotoJson`.
21
+ */
22
+ export declare function wellKnownPath(cfg: Pick<OpsConfig, 'paths'>): string;
23
+ export interface SyncSummary {
24
+ publication: 'updated' | 'unchanged';
25
+ created: string[];
26
+ updated: string[];
27
+ unchanged: number;
28
+ /** rkeys of PDS document records with no matching local post (never deleted). */
29
+ orphans: string[];
30
+ }
31
+ export declare function requirePdsConfig(ctx: PdsContext): PdsConfig;
32
+ /** Read the committed well-known file; undefined when absent. */
33
+ export declare function readWellKnownUri(fs: FileSystem, repoRoot: string, cfg: Pick<OpsConfig, 'paths'>): Promise<string | undefined>;
34
+ /**
35
+ * The publication record as config + domain describe it. `siteUrl` must have
36
+ * no trailing slash — standard.site appends the document `path`, which already
37
+ * starts with one.
38
+ */
39
+ export declare function publicationRecord(pds: PdsConfig, siteUrl: string): Record<string, unknown>;
40
+ /** The document record for a post. */
41
+ export declare function documentRecord(publicationUri: string, post: PostMeta): Record<string, unknown>;
42
+ /** Put the publication record when it differs from (or is missing at) its rkey. */
43
+ export declare function syncPublication(client: PdsRepo, desired: Record<string, unknown>, publicationUri: string): Promise<'updated' | 'unchanged'>;
44
+ /**
45
+ * Reconcile document records against the local posts: create missing rkeys,
46
+ * update drifted ones, report (never delete) orphans. Adapted from
47
+ * mastrojs/atproto createOrUpdateDocuments (MIT) onto the local XRPC client.
48
+ */
49
+ export declare function syncDocuments(client: PdsRepo, posts: PostMeta[], publicationUri: string): Promise<Omit<SyncSummary, 'publication'>>;
50
+ /**
51
+ * Full reconcile: OAuth session restore against the PDS, publication +
52
+ * documents. The caller decides when this may run (production only) and
53
+ * whether a failure is fatal.
54
+ */
55
+ export declare function syncPds(ctx: PdsContext, repoRoot: string, openRepo: OpenRepo): Promise<SyncSummary>;