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/sync.js ADDED
@@ -0,0 +1,167 @@
1
+ import { join } from 'node:path';
2
+ import { FileNotFoundError, } from 'blogwright-core';
3
+ import { listPublishablePosts } from './content.js';
4
+ import { postPath, tidFromPath } from './rkey.js';
5
+ import { rkeyFromUri } from './xrpc.js';
6
+ export const PUBLICATION_COLLECTION = 'site.standard.publication';
7
+ export const DOCUMENT_COLLECTION = 'site.standard.document';
8
+ /**
9
+ * Repo-relative path of the standard.site well-known file `pds init` writes;
10
+ * its segment under the public dir is protocol-fixed. The companion
11
+ * atproto.json path comes straight from `config.paths.atprotoJson`.
12
+ */
13
+ export function wellKnownPath(cfg) {
14
+ return `${cfg.paths.publicDir}/.well-known/site.standard.publication`;
15
+ }
16
+ export function requirePdsConfig(ctx) {
17
+ if (!ctx.config.pds) {
18
+ throw new Error('config has no "pds" section — add it to config/production.jsonc');
19
+ }
20
+ return ctx.config.pds;
21
+ }
22
+ /** Read the atproto.json site file; undefined when the site has not been initialised. */
23
+ async function readAtprotoSiteConfig(fs, repoRoot, cfg) {
24
+ let text;
25
+ try {
26
+ text = await fs.readText(join(repoRoot, cfg.paths.atprotoJson));
27
+ }
28
+ catch (err) {
29
+ if (err instanceof FileNotFoundError)
30
+ return undefined;
31
+ throw err;
32
+ }
33
+ const parsed = JSON.parse(text);
34
+ if (!parsed.did || !parsed.publicationUri)
35
+ return undefined;
36
+ return { did: parsed.did, publicationUri: parsed.publicationUri };
37
+ }
38
+ /** Read the committed well-known file; undefined when absent. */
39
+ export async function readWellKnownUri(fs, repoRoot, cfg) {
40
+ try {
41
+ const text = await fs.readText(join(repoRoot, wellKnownPath(cfg)));
42
+ return text.trim() || undefined;
43
+ }
44
+ catch (err) {
45
+ if (err instanceof FileNotFoundError)
46
+ return undefined;
47
+ throw err;
48
+ }
49
+ }
50
+ /**
51
+ * The publication record as config + domain describe it. `siteUrl` must have
52
+ * no trailing slash — standard.site appends the document `path`, which already
53
+ * starts with one.
54
+ */
55
+ export function publicationRecord(pds, siteUrl) {
56
+ return {
57
+ $type: PUBLICATION_COLLECTION,
58
+ url: siteUrl,
59
+ name: pds.name,
60
+ ...(pds.description ? { description: pds.description } : {}),
61
+ preferences: { showInDiscover: true },
62
+ };
63
+ }
64
+ /** The document record for a post. */
65
+ export function documentRecord(publicationUri, post) {
66
+ return {
67
+ $type: DOCUMENT_COLLECTION,
68
+ site: publicationUri,
69
+ title: post.title,
70
+ path: postPath(post.slug),
71
+ description: post.description,
72
+ publishedAt: post.pubDate.toISOString(),
73
+ };
74
+ }
75
+ /** Fields compared to decide whether an existing record needs a put. */
76
+ const DOCUMENT_DIFF_FIELDS = ['site', 'title', 'path', 'description', 'publishedAt'];
77
+ const PUBLICATION_DIFF_FIELDS = ['url', 'name', 'description'];
78
+ function recordsDiffer(existing, desired, fields) {
79
+ return fields.some((f) => existing[f] !== desired[f]);
80
+ }
81
+ /** Put the publication record when it differs from (or is missing at) its rkey. */
82
+ export async function syncPublication(client, desired, publicationUri) {
83
+ const rkey = rkeyFromUri(publicationUri);
84
+ const existing = await client.getRecord(PUBLICATION_COLLECTION, rkey);
85
+ if (existing && !recordsDiffer(existing.value, desired, PUBLICATION_DIFF_FIELDS)) {
86
+ return 'unchanged';
87
+ }
88
+ // Preserve preferences the owner set out-of-band (e.g. showInDiscover off in
89
+ // a standard.site client) — a config-driven update must not clobber them.
90
+ const next = existing && existing.value.preferences !== undefined
91
+ ? { ...desired, preferences: existing.value.preferences }
92
+ : desired;
93
+ await client.putRecord(PUBLICATION_COLLECTION, rkey, next);
94
+ return 'updated';
95
+ }
96
+ /**
97
+ * Reconcile document records against the local posts: create missing rkeys,
98
+ * update drifted ones, report (never delete) orphans. Adapted from
99
+ * mastrojs/atproto createOrUpdateDocuments (MIT) onto the local XRPC client.
100
+ */
101
+ export async function syncDocuments(client, posts, publicationUri) {
102
+ const existing = new Map();
103
+ for (const record of await client.listRecords(DOCUMENT_COLLECTION)) {
104
+ if (record.value.site === publicationUri)
105
+ existing.set(rkeyFromUri(record.uri), record);
106
+ }
107
+ const summary = {
108
+ created: [],
109
+ updated: [],
110
+ unchanged: 0,
111
+ orphans: [],
112
+ };
113
+ const localRkeys = new Set();
114
+ for (const post of posts) {
115
+ const rkey = tidFromPath(postPath(post.slug));
116
+ if (localRkeys.has(rkey))
117
+ throw new Error(`rkey collision for slug "${post.slug}"`);
118
+ localRkeys.add(rkey);
119
+ const desired = documentRecord(publicationUri, post);
120
+ const current = existing.get(rkey);
121
+ if (!current) {
122
+ await client.putRecord(DOCUMENT_COLLECTION, rkey, desired);
123
+ summary.created.push(post.slug);
124
+ }
125
+ else if (recordsDiffer(current.value, desired, DOCUMENT_DIFF_FIELDS)) {
126
+ await client.putRecord(DOCUMENT_COLLECTION, rkey, desired);
127
+ summary.updated.push(post.slug);
128
+ }
129
+ else {
130
+ summary.unchanged += 1;
131
+ }
132
+ }
133
+ for (const rkey of existing.keys()) {
134
+ if (!localRkeys.has(rkey))
135
+ summary.orphans.push(rkey);
136
+ }
137
+ return summary;
138
+ }
139
+ /**
140
+ * Full reconcile: OAuth session restore against the PDS, publication +
141
+ * documents. The caller decides when this may run (production only) and
142
+ * whether a failure is fatal.
143
+ */
144
+ export async function syncPds(ctx, repoRoot, openRepo) {
145
+ const pds = requirePdsConfig(ctx);
146
+ const atprotoJson = ctx.config.paths.atprotoJson;
147
+ const site = await readAtprotoSiteConfig(ctx.ports.fs, repoRoot, ctx.config);
148
+ if (!site) {
149
+ throw new Error(`${atprotoJson} is not initialised — run \`blogwright pds init\` first`);
150
+ }
151
+ const wellKnown = await readWellKnownUri(ctx.ports.fs, repoRoot, ctx.config);
152
+ if (wellKnown !== site.publicationUri) {
153
+ throw new Error(`${wellKnownPath(ctx.config)} (${wellKnown ?? 'missing'}) does not match ${atprotoJson} ` +
154
+ `(${site.publicationUri}) — re-run \`blogwright pds init\``);
155
+ }
156
+ if (!ctx.domain)
157
+ throw new Error('pds sync requires a configured domain');
158
+ const { did, repo } = await openRepo(ctx);
159
+ if (did !== site.did) {
160
+ throw new Error(`session DID ${did} does not match ${atprotoJson} DID ${site.did}`);
161
+ }
162
+ const posts = await listPublishablePosts(ctx.ports.fs, repoRoot, ctx.config.paths.content);
163
+ const publication = await syncPublication(repo, publicationRecord(pds, `https://${ctx.domain}`), site.publicationUri);
164
+ const documents = await syncDocuments(repo, posts, site.publicationUri);
165
+ return { publication, ...documents };
166
+ }
167
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.js","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Test-only PdsContext factory. Builds a real, fully-typed context over
3
+ * in-memory adapters: file access hits a Map-backed FileSystem, and every
4
+ * secrets-client method a test has not overridden fails fast at the transport.
5
+ * Tests substitute behaviour here — at the ports — never by casting.
6
+ */
7
+ import { type FileSystem, type OpsConfig, type SecretsManagerClient, type Terminal } from 'blogwright-core';
8
+ import type { PdsContext, PdsLogger } from './context.js';
9
+ export interface TestContextOverrides {
10
+ env?: string | undefined;
11
+ domain?: string | undefined;
12
+ config?: Partial<OpsConfig> | undefined;
13
+ clients?: {
14
+ secrets?: Partial<SecretsManagerClient> | undefined;
15
+ } | undefined;
16
+ logger?: Partial<PdsLogger> | undefined;
17
+ ports?: {
18
+ fs?: FileSystem | undefined;
19
+ terminal?: Terminal | undefined;
20
+ } | undefined;
21
+ }
22
+ /** Create a unique real-disk directory for a node-adapter integration test. */
23
+ export declare function makeTempDir(prefix: string): Promise<string>;
24
+ /** Delete a directory created by {@link makeTempDir}. */
25
+ export declare function removeTempDir(dir: string): Promise<void>;
26
+ /**
27
+ * Build a complete PdsContext for tests. Defaults: env "test", site "example",
28
+ * config merged over DEFAULT_CONFIG, a fresh in-memory FileSystem, a silent
29
+ * terminal, a secrets client that fails fast until overridden, and a silent
30
+ * logger.
31
+ */
32
+ export declare function createTestContext(overrides?: TestContextOverrides): PdsContext;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Test-only PdsContext factory. Builds a real, fully-typed context over
3
+ * in-memory adapters: file access hits a Map-backed FileSystem, and every
4
+ * secrets-client method a test has not overridden fails fast at the transport.
5
+ * Tests substitute behaviour here — at the ports — never by casting.
6
+ */
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { createClients, createMemoryFileSystem, mergeConfig, staticCredentials, } from 'blogwright-core';
11
+ const rejectAllTransport = async (req) => {
12
+ throw new Error(`unexpected AWS request in test: ${req.method} ${req.url} — override the client method on createTestContext`);
13
+ };
14
+ /**
15
+ * Layer test overrides over a real client so untouched methods still fail fast.
16
+ * Overrides must be plain objects of methods (own properties) — a class
17
+ * instance's prototype methods would not be copied.
18
+ */
19
+ function testSecrets(region, overrides) {
20
+ const base = createClients({
21
+ region,
22
+ credentials: staticCredentials({ accessKeyId: 'test', secretAccessKey: 'test' }),
23
+ transport: rejectAllTransport,
24
+ }).secrets;
25
+ if (!overrides)
26
+ return base;
27
+ return Object.assign(Object.create(base), overrides);
28
+ }
29
+ /** Silent, non-interactive terminal; a prompt in a test is a missing override. */
30
+ const silentTerminal = {
31
+ isInteractive: false,
32
+ write: () => undefined,
33
+ error: () => undefined,
34
+ status: () => undefined,
35
+ question: async (prompt) => {
36
+ throw new Error(`unexpected terminal prompt in test: ${prompt} — override ports.terminal on createTestContext`);
37
+ },
38
+ };
39
+ const NOOP_LOGGER = {
40
+ info: () => undefined,
41
+ step: () => undefined,
42
+ ok: () => undefined,
43
+ warn: () => undefined,
44
+ error: () => undefined,
45
+ };
46
+ /** Create a unique real-disk directory for a node-adapter integration test. */
47
+ export async function makeTempDir(prefix) {
48
+ return mkdtemp(join(tmpdir(), `${prefix}-`));
49
+ }
50
+ /** Delete a directory created by {@link makeTempDir}. */
51
+ export async function removeTempDir(dir) {
52
+ await rm(dir, { recursive: true, force: true });
53
+ }
54
+ /**
55
+ * Build a complete PdsContext for tests. Defaults: env "test", site "example",
56
+ * config merged over DEFAULT_CONFIG, a fresh in-memory FileSystem, a silent
57
+ * terminal, a secrets client that fails fast until overridden, and a silent
58
+ * logger.
59
+ */
60
+ export function createTestContext(overrides = {}) {
61
+ const config = mergeConfig({ siteName: 'example', ...overrides.config });
62
+ return {
63
+ env: overrides.env ?? 'test',
64
+ domain: overrides.domain,
65
+ config,
66
+ clients: { secrets: testSecrets(config.region, overrides.clients?.secrets) },
67
+ ports: {
68
+ fs: overrides.ports?.fs ?? createMemoryFileSystem(),
69
+ terminal: overrides.ports?.terminal ?? silentTerminal,
70
+ },
71
+ logger: { ...NOOP_LOGGER, ...overrides.logger },
72
+ };
73
+ }
74
+ //# sourceMappingURL=test-support.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-support.js","sourceRoot":"","sources":["../src/test-support.ts"],"names":[],"mappings":"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"}
package/dist/xrpc.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /** Minimal AT Protocol XRPC client — just the calls the standard.site sync needs. */
2
+ export interface PdsRecord {
3
+ uri: string;
4
+ cid: string;
5
+ value: Record<string, unknown>;
6
+ }
7
+ /**
8
+ * Sends one XRPC request. `pathname` is relative to the account's PDS; auth
9
+ * (OAuth DPoP headers, nonce retries) is the transport's job — in production
10
+ * this is the bound `fetchHandler` of an OAuthSession (see oauth.ts).
11
+ */
12
+ export type XrpcTransport = (pathname: string, init?: RequestInit) => Promise<Response>;
13
+ /** A structured error raised when the PDS returns a non-2xx response. */
14
+ export declare class XrpcError extends Error {
15
+ readonly code: string;
16
+ readonly statusCode: number;
17
+ constructor(opts: {
18
+ code: string;
19
+ message: string;
20
+ statusCode: number;
21
+ });
22
+ get isNotFound(): boolean;
23
+ }
24
+ /** rkey of a record, from the trailing segment of its AT-URI. */
25
+ export declare function rkeyFromUri(uri: string): string;
26
+ export declare class PdsClient {
27
+ private readonly did;
28
+ private readonly transport;
29
+ constructor(did: string, transport: XrpcTransport);
30
+ private call;
31
+ /** All records in a collection for the account repo (cursor-paginated). */
32
+ listRecords(collection: string): Promise<PdsRecord[]>;
33
+ /** Fetch one record; undefined when it does not exist. */
34
+ getRecord(collection: string, rkey: string): Promise<PdsRecord | undefined>;
35
+ /** Create a record; the PDS assigns the rkey unless one is given. */
36
+ createRecord(collection: string, record: Record<string, unknown>, rkey?: string): Promise<{
37
+ uri: string;
38
+ }>;
39
+ /** Create or overwrite the record at a known rkey. */
40
+ putRecord(collection: string, rkey: string, record: Record<string, unknown>): Promise<{
41
+ uri: string;
42
+ }>;
43
+ }
package/dist/xrpc.js ADDED
@@ -0,0 +1,104 @@
1
+ /** Minimal AT Protocol XRPC client — just the calls the standard.site sync needs. */
2
+ /** A structured error raised when the PDS returns a non-2xx response. */
3
+ export class XrpcError extends Error {
4
+ code;
5
+ statusCode;
6
+ constructor(opts) {
7
+ super(`pds: ${opts.code} — ${opts.message} (HTTP ${opts.statusCode})`);
8
+ this.name = 'XrpcError';
9
+ this.code = opts.code;
10
+ this.statusCode = opts.statusCode;
11
+ }
12
+ get isNotFound() {
13
+ return this.statusCode === 404 || /RecordNotFound/i.test(this.code);
14
+ }
15
+ }
16
+ /** rkey of a record, from the trailing segment of its AT-URI. */
17
+ export function rkeyFromUri(uri) {
18
+ const rkey = uri.split('/').pop();
19
+ if (!rkey)
20
+ throw new Error(`cannot extract rkey from AT-URI "${uri}"`);
21
+ return rkey;
22
+ }
23
+ const LIST_PAGE_SIZE = 100;
24
+ export class PdsClient {
25
+ did;
26
+ transport;
27
+ constructor(did, transport) {
28
+ this.did = did;
29
+ this.transport = transport;
30
+ }
31
+ async call(nsid, opts) {
32
+ const query = new URLSearchParams(opts.params ?? {}).toString();
33
+ const res = await this.transport(`/xrpc/${nsid}${query ? `?${query}` : ''}`, {
34
+ method: opts.method,
35
+ ...(opts.body
36
+ ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(opts.body) }
37
+ : {}),
38
+ });
39
+ const text = await res.text();
40
+ let parsed = {};
41
+ try {
42
+ parsed = text ? JSON.parse(text) : {};
43
+ }
44
+ catch {
45
+ /* non-JSON error body — fall through with the raw text as message */
46
+ }
47
+ if (!res.ok) {
48
+ throw new XrpcError({
49
+ code: typeof parsed.error === 'string' ? parsed.error : 'UnknownError',
50
+ message: typeof parsed.message === 'string' ? parsed.message : text.slice(0, 200),
51
+ statusCode: res.status,
52
+ });
53
+ }
54
+ return parsed;
55
+ }
56
+ /** All records in a collection for the account repo (cursor-paginated). */
57
+ async listRecords(collection) {
58
+ const records = [];
59
+ let cursor;
60
+ do {
61
+ const out = await this.call('com.atproto.repo.listRecords', {
62
+ method: 'GET',
63
+ params: {
64
+ repo: this.did,
65
+ collection,
66
+ limit: String(LIST_PAGE_SIZE),
67
+ ...(cursor ? { cursor } : {}),
68
+ },
69
+ });
70
+ records.push(...(out.records ?? []));
71
+ cursor = out.records?.length ? out.cursor : undefined;
72
+ } while (cursor);
73
+ return records;
74
+ }
75
+ /** Fetch one record; undefined when it does not exist. */
76
+ async getRecord(collection, rkey) {
77
+ try {
78
+ return await this.call('com.atproto.repo.getRecord', {
79
+ method: 'GET',
80
+ params: { repo: this.did, collection, rkey },
81
+ });
82
+ }
83
+ catch (err) {
84
+ if (err instanceof XrpcError && err.isNotFound)
85
+ return undefined;
86
+ throw err;
87
+ }
88
+ }
89
+ /** Create a record; the PDS assigns the rkey unless one is given. */
90
+ async createRecord(collection, record, rkey) {
91
+ return this.call('com.atproto.repo.createRecord', {
92
+ method: 'POST',
93
+ body: { repo: this.did, collection, ...(rkey ? { rkey } : {}), record },
94
+ });
95
+ }
96
+ /** Create or overwrite the record at a known rkey. */
97
+ async putRecord(collection, rkey, record) {
98
+ return this.call('com.atproto.repo.putRecord', {
99
+ method: 'POST',
100
+ body: { repo: this.did, collection, rkey, record },
101
+ });
102
+ }
103
+ }
104
+ //# sourceMappingURL=xrpc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xrpc.js","sourceRoot":"","sources":["../src/xrpc.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "blogwright-pds",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./rkey": {
15
+ "types": "./dist/rkey.d.ts",
16
+ "default": "./dist/rkey.js"
17
+ }
18
+ },
19
+ "dependencies": {
20
+ "@atproto/oauth-client-node": "^0.4.7",
21
+ "blogwright-core": "0.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^26.1.0",
25
+ "oxlint": "^1.72.0",
26
+ "typescript": "^6.0.3",
27
+ "vitest": "^4.1.9"
28
+ },
29
+ "description": "standard.site (AT Protocol) publishing for blogwright: OAuth client, PDS record sync, and URL-derived rkeys",
30
+ "keywords": [
31
+ "atproto",
32
+ "standard.site",
33
+ "pds",
34
+ "oauth"
35
+ ],
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/antstanley/blogwright.git"
40
+ },
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "typecheck": "tsc -p tsconfig.typecheck.json",
47
+ "lint": "oxlint src",
48
+ "test": "vitest run"
49
+ }
50
+ }