@cancia/astro 0.2.0 → 0.2.1

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.
@@ -1,63 +1,3 @@
1
- // src/storage/sqlite.ts
2
- import { createRequire } from "module";
3
- var require2 = createRequire(import.meta.url);
4
- var _db = null;
5
- function createDB(dbPath) {
6
- const Database = require2("better-sqlite3");
7
- const sqlite = new Database(dbPath);
8
- sqlite.pragma("journal_mode = WAL");
9
- sqlite.exec(`
10
- CREATE TABLE IF NOT EXISTS cancia_content (
11
- id INTEGER PRIMARY KEY AUTOINCREMENT,
12
- site TEXT NOT NULL,
13
- key TEXT NOT NULL,
14
- lang TEXT NOT NULL,
15
- value TEXT NOT NULL,
16
- updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
17
- UNIQUE(site, key, lang)
18
- )
19
- `);
20
- return {
21
- get: sqlite.prepare(
22
- "SELECT value FROM cancia_content WHERE site=? AND key=? AND lang=?"
23
- ),
24
- set: sqlite.prepare(
25
- `INSERT INTO cancia_content (site, key, lang, value, updated_at)
26
- VALUES (?, ?, ?, ?, unixepoch())
27
- ON CONFLICT(site, key, lang) DO UPDATE SET value=excluded.value, updated_at=unixepoch()`
28
- ),
29
- getAll: sqlite.prepare(
30
- "SELECT key, lang, value FROM cancia_content WHERE site=?"
31
- ),
32
- delete: sqlite.prepare(
33
- "DELETE FROM cancia_content WHERE site=? AND key=? AND lang=?"
34
- )
35
- };
36
- }
37
- function getDB(dbPath) {
38
- if (!_db) _db = createDB(dbPath);
39
- return _db;
40
- }
41
- function createSQLiteAdapter(dbPath) {
42
- const path = dbPath ?? process.cwd() + "/cancia.db";
43
- return {
44
- async get(site, key, lang) {
45
- const row = getDB(path).get.get(site, key, lang);
46
- return row?.value ?? null;
47
- },
48
- async set(site, key, lang, value) {
49
- getDB(path).set.run(site, key, lang, value);
50
- },
51
- async getAll(site) {
52
- const rows = getDB(path).getAll.all(site);
53
- return Object.fromEntries(rows.map((r) => [`${r.key}.${r.lang}`, r.value]));
54
- },
55
- async delete(site, key, lang) {
56
- getDB(path).delete.run(site, key, lang);
57
- }
58
- };
59
- }
60
-
61
1
  // src/storage/github-client.ts
62
2
  function toBase64(text) {
63
3
  if (typeof Buffer !== "undefined") {
@@ -299,7 +239,6 @@ function createGitBackedAdapter(opts) {
299
239
  }
300
240
 
301
241
  export {
302
- createSQLiteAdapter,
303
242
  createGitHubClient,
304
243
  createGitBackedAdapter
305
244
  };
@@ -0,0 +1,245 @@
1
+ import {
2
+ createGitBackedAdapter
3
+ } from "./chunk-5ELSN6LI.js";
4
+ import {
5
+ createJsonFileAdapter,
6
+ createJsonFileAdapterV2
7
+ } from "./chunk-ST44VULL.js";
8
+ import {
9
+ detectImageType,
10
+ isValidSite
11
+ } from "./chunk-5IPHDIC6.js";
12
+
13
+ // src/routes/upload.ts
14
+ import { writeFile, mkdir } from "fs/promises";
15
+ import { join } from "path";
16
+ import { randomUUID } from "crypto";
17
+ function makeLocalUploadHandler(opts) {
18
+ const uploadDir = opts.uploadDir ?? join(process.cwd(), "public/uploads");
19
+ const publicUrlBase = opts.publicUrlBase ?? "/uploads";
20
+ const maxBytes = (opts.maxMB ?? 10) * 1024 * 1024;
21
+ return async (file, site, detected) => {
22
+ const dest = join(uploadDir, site);
23
+ await mkdir(dest, { recursive: true });
24
+ const name = `${randomUUID()}.${detected.ext}`;
25
+ await writeFile(join(dest, name), Buffer.from(await file.arrayBuffer()));
26
+ return `${publicUrlBase}/${site}/${name}`;
27
+ };
28
+ }
29
+ function makeUploadRoute(uploadHandler, secret, maxMB = 10) {
30
+ return async ({ request }) => {
31
+ if (secret) {
32
+ const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
33
+ if (token !== secret) {
34
+ return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
35
+ }
36
+ }
37
+ const form = await request.formData().catch(() => null);
38
+ const file = form?.get("file");
39
+ const site = form?.get("site");
40
+ if (!(file instanceof File)) {
41
+ return new Response(JSON.stringify({ error: "Missing file field" }), { status: 400 });
42
+ }
43
+ if (typeof site !== "string" || !isValidSite(site)) {
44
+ return new Response(
45
+ JSON.stringify({ error: "Invalid site (allowed: a-z, 0-9, and . _ -; must start alphanumeric)" }),
46
+ { status: 400 }
47
+ );
48
+ }
49
+ if (file.size > maxMB * 1024 * 1024) {
50
+ return new Response(JSON.stringify({ error: `File too large (max ${maxMB}MB)` }), { status: 413 });
51
+ }
52
+ const buf = new Uint8Array(await file.arrayBuffer());
53
+ const detected = detectImageType(buf);
54
+ if (!detected) {
55
+ return new Response(JSON.stringify({ error: "File type not allowed" }), { status: 415 });
56
+ }
57
+ const url = await uploadHandler(file, site, detected);
58
+ return new Response(JSON.stringify({ url }), {
59
+ headers: { "Content-Type": "application/json" }
60
+ });
61
+ };
62
+ }
63
+
64
+ // src/routes/upload-r2.ts
65
+ import { createHmac, createHash } from "crypto";
66
+ import { randomUUID as randomUUID2 } from "crypto";
67
+ function sha256hex(data) {
68
+ return createHash("sha256").update(data).digest("hex");
69
+ }
70
+ function hmacSha256(key, data) {
71
+ return createHmac("sha256", key).update(data).digest();
72
+ }
73
+ function getSigningKey(secretKey, date, region, service) {
74
+ const kDate = hmacSha256(Buffer.from(`AWS4${secretKey}`, "utf8"), date);
75
+ const kRegion = hmacSha256(kDate, region);
76
+ const kService = hmacSha256(kRegion, service);
77
+ const kSigning = hmacSha256(kService, "aws4_request");
78
+ return kSigning;
79
+ }
80
+ async function signedPutRequest(opts) {
81
+ const { endpoint, bucket, key, body, contentType, accessKeyId, secretAccessKey } = opts;
82
+ const region = "auto";
83
+ const service = "s3";
84
+ const now = /* @__PURE__ */ new Date();
85
+ const isoDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15) + "Z";
86
+ const shortDate = isoDate.slice(0, 8);
87
+ const url = `${endpoint}/${bucket}/${key}`;
88
+ const host = new URL(endpoint).host;
89
+ const payloadHash = sha256hex(body);
90
+ const headers = {
91
+ "content-type": contentType,
92
+ "host": host,
93
+ "x-amz-content-sha256": payloadHash,
94
+ "x-amz-date": isoDate
95
+ };
96
+ const signedHeaders = Object.keys(headers).sort().join(";");
97
+ const canonicalHeaders = Object.keys(headers).sort().map((k) => `${k}:${headers[k]}
98
+ `).join("");
99
+ const canonicalRequest = [
100
+ "PUT",
101
+ `/${bucket}/${key}`,
102
+ "",
103
+ canonicalHeaders,
104
+ signedHeaders,
105
+ payloadHash
106
+ ].join("\n");
107
+ const credentialScope = `${shortDate}/${region}/${service}/aws4_request`;
108
+ const stringToSign = [
109
+ "AWS4-HMAC-SHA256",
110
+ isoDate,
111
+ credentialScope,
112
+ sha256hex(canonicalRequest)
113
+ ].join("\n");
114
+ const signingKey = getSigningKey(secretAccessKey, shortDate, region, service);
115
+ const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
116
+ const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
117
+ let res;
118
+ try {
119
+ res = await fetch(url, {
120
+ method: "PUT",
121
+ headers: { ...headers, Authorization: authHeader },
122
+ body: new Uint8Array(body)
123
+ });
124
+ } catch (err) {
125
+ throw new Error(`R2 upload: network error reaching ${url} \u2014 ${err}`);
126
+ }
127
+ if (!res.ok) {
128
+ const text = await res.text().catch(() => res.statusText);
129
+ throw new Error(`R2 upload failed (${res.status}): ${text}`);
130
+ }
131
+ }
132
+ function makeR2UploadHandler(opts) {
133
+ const {
134
+ accountId,
135
+ bucket,
136
+ accessKeyId,
137
+ secretAccessKey,
138
+ publicUrl,
139
+ prefix = "uploads"
140
+ } = opts;
141
+ const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
142
+ return async (file, _site, detected) => {
143
+ const key = `${prefix}/${randomUUID2()}.${detected.ext}`;
144
+ const body = Buffer.from(await file.arrayBuffer());
145
+ await signedPutRequest({
146
+ endpoint,
147
+ bucket,
148
+ key,
149
+ body,
150
+ contentType: file.type,
151
+ accessKeyId,
152
+ secretAccessKey
153
+ });
154
+ return `${publicUrl.replace(/\/$/, "")}/${key}`;
155
+ };
156
+ }
157
+
158
+ // src/runtime.ts
159
+ var _runtime = null;
160
+ var _bakedConfig = null;
161
+ function setBakedConfig(config) {
162
+ _bakedConfig = config;
163
+ }
164
+ function setCanciaRuntime(runtime) {
165
+ _runtime = runtime;
166
+ }
167
+ function buildRuntimeFromBaked(baked) {
168
+ const projectRoot = baked.projectRoot || process.cwd();
169
+ const token = process.env.CANCIA_TOKEN?.trim() || "";
170
+ const secret = baked.public ? void 0 : token || void 0;
171
+ const deployHook = process.env.CANCIA_DEPLOY_HOOK || void 0;
172
+ const storageV2 = buildStorageV2(baked, projectRoot);
173
+ const storage = storageV2 ? storageV2.kv : lazyJsonFileAdapter(projectRoot);
174
+ const uploadHandler = buildUploadHandler(baked, projectRoot);
175
+ return {
176
+ storage,
177
+ storageV2,
178
+ projectRoot,
179
+ schemasPath: baked.schemasPath,
180
+ defaultLocale: baked.defaultLocale,
181
+ locales: baked.locales,
182
+ secret,
183
+ uploadHandler,
184
+ deployHook,
185
+ maxUploadMB: baked.maxUploadMB
186
+ };
187
+ }
188
+ function lazyJsonFileAdapter(projectRoot) {
189
+ return createJsonFileAdapter(projectRoot + "/cancia-content.json");
190
+ }
191
+ function buildStorageV2(baked, projectRoot) {
192
+ const desc = baked.storage;
193
+ if (!desc) return void 0;
194
+ if (desc.kind === "git-backed") {
195
+ const local = createJsonFileAdapterV2({ projectRoot });
196
+ return createGitBackedAdapter({
197
+ local,
198
+ repo: desc.repo,
199
+ branch: desc.branch,
200
+ token: process.env.CANCIA_GITHUB_TOKEN,
201
+ committer: desc.committer,
202
+ projectRoot,
203
+ debounceMs: desc.debounceMs,
204
+ commitMessage: desc.commitMessage
205
+ });
206
+ }
207
+ return createJsonFileAdapterV2({ projectRoot });
208
+ }
209
+ function buildUploadHandler(baked, projectRoot) {
210
+ if (baked.r2) {
211
+ const env = process.env;
212
+ const resolved = {
213
+ accountId: baked.r2.accountId ?? env.R2_ACCOUNT_ID ?? "",
214
+ bucket: baked.r2.bucket ?? env.R2_BUCKET_NAME ?? env.R2_BUCKET ?? "",
215
+ accessKeyId: env.R2_ACCESS_KEY_ID ?? "",
216
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY ?? "",
217
+ publicUrl: baked.r2.publicUrl ?? env.R2_PUBLIC_URL ?? "",
218
+ prefix: baked.r2.prefix
219
+ };
220
+ return makeR2UploadHandler(resolved);
221
+ }
222
+ return makeLocalUploadHandler({
223
+ uploadDir: projectRoot + "/public/uploads",
224
+ maxMB: baked.maxUploadMB
225
+ });
226
+ }
227
+ function getCanciaRuntime() {
228
+ if (_runtime) return _runtime;
229
+ if (_bakedConfig) {
230
+ _runtime = buildRuntimeFromBaked(_bakedConfig);
231
+ return _runtime;
232
+ }
233
+ throw new Error(
234
+ "[cancia] Runtime not initialised and no baked config available. This is a bug \u2014 the virtual:cancia/runtime module should have called setBakedConfig() at import time."
235
+ );
236
+ }
237
+
238
+ export {
239
+ makeLocalUploadHandler,
240
+ makeUploadRoute,
241
+ makeR2UploadHandler,
242
+ setBakedConfig,
243
+ setCanciaRuntime,
244
+ getCanciaRuntime
245
+ };
@@ -0,0 +1,63 @@
1
+ // src/storage/sqlite.ts
2
+ import { createRequire } from "module";
3
+ var require2 = createRequire(import.meta.url);
4
+ var _db = null;
5
+ function createDB(dbPath) {
6
+ const Database = require2("better-sqlite3");
7
+ const sqlite = new Database(dbPath);
8
+ sqlite.pragma("journal_mode = WAL");
9
+ sqlite.exec(`
10
+ CREATE TABLE IF NOT EXISTS cancia_content (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ site TEXT NOT NULL,
13
+ key TEXT NOT NULL,
14
+ lang TEXT NOT NULL,
15
+ value TEXT NOT NULL,
16
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
17
+ UNIQUE(site, key, lang)
18
+ )
19
+ `);
20
+ return {
21
+ get: sqlite.prepare(
22
+ "SELECT value FROM cancia_content WHERE site=? AND key=? AND lang=?"
23
+ ),
24
+ set: sqlite.prepare(
25
+ `INSERT INTO cancia_content (site, key, lang, value, updated_at)
26
+ VALUES (?, ?, ?, ?, unixepoch())
27
+ ON CONFLICT(site, key, lang) DO UPDATE SET value=excluded.value, updated_at=unixepoch()`
28
+ ),
29
+ getAll: sqlite.prepare(
30
+ "SELECT key, lang, value FROM cancia_content WHERE site=?"
31
+ ),
32
+ delete: sqlite.prepare(
33
+ "DELETE FROM cancia_content WHERE site=? AND key=? AND lang=?"
34
+ )
35
+ };
36
+ }
37
+ function getDB(dbPath) {
38
+ if (!_db) _db = createDB(dbPath);
39
+ return _db;
40
+ }
41
+ function createSQLiteAdapter(dbPath) {
42
+ const path = dbPath ?? process.cwd() + "/cancia.db";
43
+ return {
44
+ async get(site, key, lang) {
45
+ const row = getDB(path).get.get(site, key, lang);
46
+ return row?.value ?? null;
47
+ },
48
+ async set(site, key, lang, value) {
49
+ getDB(path).set.run(site, key, lang, value);
50
+ },
51
+ async getAll(site) {
52
+ const rows = getDB(path).getAll.all(site);
53
+ return Object.fromEntries(rows.map((r) => [`${r.key}.${r.lang}`, r.value]));
54
+ },
55
+ async delete(site, key, lang) {
56
+ getDB(path).delete.run(site, key, lang);
57
+ }
58
+ };
59
+ }
60
+
61
+ export {
62
+ createSQLiteAdapter
63
+ };
@@ -0,0 +1,48 @@
1
+ /** Minimal fetch signature — matches the global `fetch` we depend on. */
2
+ type FetchLike = (input: string, init?: {
3
+ method?: string;
4
+ headers?: Record<string, string>;
5
+ body?: string;
6
+ }) => Promise<{
7
+ ok: boolean;
8
+ status: number;
9
+ json(): Promise<unknown>;
10
+ text(): Promise<string>;
11
+ }>;
12
+ interface GitHubCommitter {
13
+ name: string;
14
+ email: string;
15
+ }
16
+ interface GitHubClientOptions {
17
+ /** "owner/name" */
18
+ repo: string;
19
+ /** Branch to commit onto, e.g. "main". */
20
+ branch: string;
21
+ /** Fine-grained PAT with contents:write on the one repo. */
22
+ token: string;
23
+ /** Optional committer identity. GitHub uses the token's user if omitted. */
24
+ committer?: GitHubCommitter;
25
+ /** Injectable fetch (defaults to global fetch) — tests mock this. */
26
+ fetch?: FetchLike;
27
+ /** API base — defaults to https://api.github.com. Overridable for tests. */
28
+ apiBase?: string;
29
+ }
30
+ interface CommitFile {
31
+ /** Repo-relative path, forward slashes, no leading slash. */
32
+ path: string;
33
+ /** Raw file content (UTF-8 text). Encoded to base64 before PUT. */
34
+ content: string;
35
+ }
36
+ interface GitHubClient {
37
+ /** Current blob sha for `path`, or null if the file doesn't exist yet. */
38
+ getFileSha(path: string): Promise<string | null>;
39
+ /**
40
+ * Commit each file to the branch. Updates pass the current sha; creates omit
41
+ * it. Resolves once every PUT succeeds; rejects (without partial silence) if
42
+ * any PUT fails so the caller can keep the batch dirty and retry.
43
+ */
44
+ commitFiles(files: CommitFile[], message: string): Promise<void>;
45
+ }
46
+ declare function createGitHubClient(opts: GitHubClientOptions): GitHubClient;
47
+
48
+ export { type CommitFile as C, type FetchLike as F, type GitHubCommitter as G, type GitHubClient as a, type GitHubClientOptions as b, createGitHubClient as c };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import { C as CanciaStorage, a as CanciaStorageV2 } from './types-BMlLS-OS.js';
3
3
  export { b as CanciaKVStore, c as CanciaListStore, d as CanciaPageStore, L as ListEntry, P as PageMeta, e as PageRecord, f as PageSEO, R as Rev, g as RevConflictError } from './types-BMlLS-OS.js';
4
4
  import { U as UploadHandler } from './upload-DwCGjXbz.js';
5
5
  export { m as makeLocalUploadHandler } from './upload-DwCGjXbz.js';
6
+ import { G as GitHubCommitter } from './github-client-BAZ1pW24.js';
6
7
  export { CanciaLoaderOptions, canciaLoader } from './loader/index.js';
7
8
  export { FieldDescription, FieldMeta, FieldMetaBase, FieldWidget, ListDescription, ListSchema, SchemasModule, defineField, defineList, describeList } from './schema/index.js';
8
9
  export { GitBackedContentPaths, GitBackedControls, GitBackedOptions, GitBackedStorage, createGitBackedAdapter, createJsonFileAdapter, createJsonFileAdapterV2, createSQLiteAdapter } from './storage/index.js';
@@ -89,6 +90,29 @@ interface CanciaIntegrationOptions {
89
90
  * (named or default) — a record of list name → defineList() output.
90
91
  */
91
92
  schemasPath?: string;
93
+ /**
94
+ * Git-backed storage, described SERIALISABLY so a production SSR server can
95
+ * reconstruct the adapter in its own (fresh) process from this config + the
96
+ * `CANCIA_GITHUB_TOKEN` env var. Prefer this over passing a live
97
+ * `storageV2: createGitBackedAdapter(...)` object: a live adapter constructed
98
+ * during config evaluation cannot cross the build→deploy process boundary,
99
+ * so it would silently degrade to local-only in production. When `git` is
100
+ * set, Cancia builds the git-backed adapter (wrapping a JSON-file v2 local
101
+ * store) lazily at first request. The token is NEVER baked — it is read from
102
+ * `process.env.CANCIA_GITHUB_TOKEN` at runtime.
103
+ */
104
+ git?: {
105
+ /** "owner/name" of the GitHub repo whose builds carry the content. */
106
+ repo: string;
107
+ /** Branch to commit onto. Default "main". */
108
+ branch?: string;
109
+ /** Optional committer identity for commits. */
110
+ committer?: GitHubCommitter;
111
+ /** Quiet window (ms) before a flush fires. Default 3000. */
112
+ debounceMs?: number;
113
+ /** Commit message for content updates. */
114
+ commitMessage?: string;
115
+ };
92
116
  }
93
117
  declare function canciaIntegration(opts?: CanciaIntegrationOptions): AstroIntegration;
94
118
 
package/dist/index.js CHANGED
@@ -6,8 +6,11 @@ import {
6
6
  } from "./chunk-IIGDU5SV.js";
7
7
  import "./chunk-NG5GJME5.js";
8
8
  import {
9
+ makeLocalUploadHandler,
10
+ makeR2UploadHandler,
11
+ makeUploadRoute,
9
12
  setCanciaRuntime
10
- } from "./chunk-DGCGIEFD.js";
13
+ } from "./chunk-7MPOERVU.js";
11
14
  import {
12
15
  defineField,
13
16
  defineList,
@@ -18,9 +21,11 @@ import {
18
21
  canciaLoader
19
22
  } from "./chunk-337LJIKX.js";
20
23
  import {
21
- createGitBackedAdapter,
22
24
  createSQLiteAdapter
23
- } from "./chunk-SXKZ2WUL.js";
25
+ } from "./chunk-AE4SIY24.js";
26
+ import {
27
+ createGitBackedAdapter
28
+ } from "./chunk-5ELSN6LI.js";
24
29
  import {
25
30
  createJsonFileAdapter,
26
31
  createJsonFileAdapterV2
@@ -29,14 +34,11 @@ import {
29
34
  RevConflictError
30
35
  } from "./chunk-7IA5B5CF.js";
31
36
  import "./chunk-BOIQNZAO.js";
32
- import {
33
- detectImageType,
34
- isValidSite
35
- } from "./chunk-5IPHDIC6.js";
37
+ import "./chunk-5IPHDIC6.js";
36
38
 
37
39
  // src/integration.ts
38
40
  import { loadEnv } from "vite";
39
- import { fileURLToPath } from "url";
41
+ import { fileURLToPath, pathToFileURL } from "url";
40
42
 
41
43
  // src/routes/content.ts
42
44
  function makeContentRoute(storage, secret) {
@@ -84,57 +86,6 @@ function makeContentRoute(storage, secret) {
84
86
  };
85
87
  }
86
88
 
87
- // src/routes/upload.ts
88
- import { writeFile, mkdir } from "fs/promises";
89
- import { join } from "path";
90
- import { randomUUID } from "crypto";
91
- function makeLocalUploadHandler(opts) {
92
- const uploadDir = opts.uploadDir ?? join(process.cwd(), "public/uploads");
93
- const publicUrlBase = opts.publicUrlBase ?? "/uploads";
94
- const maxBytes = (opts.maxMB ?? 10) * 1024 * 1024;
95
- return async (file, site, detected) => {
96
- const dest = join(uploadDir, site);
97
- await mkdir(dest, { recursive: true });
98
- const name = `${randomUUID()}.${detected.ext}`;
99
- await writeFile(join(dest, name), Buffer.from(await file.arrayBuffer()));
100
- return `${publicUrlBase}/${site}/${name}`;
101
- };
102
- }
103
- function makeUploadRoute(uploadHandler, secret, maxMB = 10) {
104
- return async ({ request }) => {
105
- if (secret) {
106
- const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
107
- if (token !== secret) {
108
- return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
109
- }
110
- }
111
- const form = await request.formData().catch(() => null);
112
- const file = form?.get("file");
113
- const site = form?.get("site");
114
- if (!(file instanceof File)) {
115
- return new Response(JSON.stringify({ error: "Missing file field" }), { status: 400 });
116
- }
117
- if (typeof site !== "string" || !isValidSite(site)) {
118
- return new Response(
119
- JSON.stringify({ error: "Invalid site (allowed: a-z, 0-9, and . _ -; must start alphanumeric)" }),
120
- { status: 400 }
121
- );
122
- }
123
- if (file.size > maxMB * 1024 * 1024) {
124
- return new Response(JSON.stringify({ error: `File too large (max ${maxMB}MB)` }), { status: 413 });
125
- }
126
- const buf = new Uint8Array(await file.arrayBuffer());
127
- const detected = detectImageType(buf);
128
- if (!detected) {
129
- return new Response(JSON.stringify({ error: "File type not allowed" }), { status: 415 });
130
- }
131
- const url = await uploadHandler(file, site, detected);
132
- return new Response(JSON.stringify({ url }), {
133
- headers: { "Content-Type": "application/json" }
134
- });
135
- };
136
- }
137
-
138
89
  // src/routes/publish.ts
139
90
  function makePublishRoute(deployHook, secret) {
140
91
  return async ({ request }) => {
@@ -227,109 +178,15 @@ function makeAuthRoute(secret) {
227
178
  };
228
179
  }
229
180
 
230
- // src/routes/upload-r2.ts
231
- import { createHmac, createHash } from "crypto";
232
- import { randomUUID as randomUUID2 } from "crypto";
233
- function sha256hex(data) {
234
- return createHash("sha256").update(data).digest("hex");
235
- }
236
- function hmacSha256(key, data) {
237
- return createHmac("sha256", key).update(data).digest();
238
- }
239
- function getSigningKey(secretKey, date, region, service) {
240
- const kDate = hmacSha256(Buffer.from(`AWS4${secretKey}`, "utf8"), date);
241
- const kRegion = hmacSha256(kDate, region);
242
- const kService = hmacSha256(kRegion, service);
243
- const kSigning = hmacSha256(kService, "aws4_request");
244
- return kSigning;
245
- }
246
- async function signedPutRequest(opts) {
247
- const { endpoint, bucket, key, body, contentType, accessKeyId, secretAccessKey } = opts;
248
- const region = "auto";
249
- const service = "s3";
250
- const now = /* @__PURE__ */ new Date();
251
- const isoDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15) + "Z";
252
- const shortDate = isoDate.slice(0, 8);
253
- const url = `${endpoint}/${bucket}/${key}`;
254
- const host = new URL(endpoint).host;
255
- const payloadHash = sha256hex(body);
256
- const headers = {
257
- "content-type": contentType,
258
- "host": host,
259
- "x-amz-content-sha256": payloadHash,
260
- "x-amz-date": isoDate
261
- };
262
- const signedHeaders = Object.keys(headers).sort().join(";");
263
- const canonicalHeaders = Object.keys(headers).sort().map((k) => `${k}:${headers[k]}
264
- `).join("");
265
- const canonicalRequest = [
266
- "PUT",
267
- `/${bucket}/${key}`,
268
- "",
269
- canonicalHeaders,
270
- signedHeaders,
271
- payloadHash
272
- ].join("\n");
273
- const credentialScope = `${shortDate}/${region}/${service}/aws4_request`;
274
- const stringToSign = [
275
- "AWS4-HMAC-SHA256",
276
- isoDate,
277
- credentialScope,
278
- sha256hex(canonicalRequest)
279
- ].join("\n");
280
- const signingKey = getSigningKey(secretAccessKey, shortDate, region, service);
281
- const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
282
- const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
283
- let res;
284
- try {
285
- res = await fetch(url, {
286
- method: "PUT",
287
- headers: { ...headers, Authorization: authHeader },
288
- body: new Uint8Array(body)
289
- });
290
- } catch (err) {
291
- throw new Error(`R2 upload: network error reaching ${url} \u2014 ${err}`);
292
- }
293
- if (!res.ok) {
294
- const text = await res.text().catch(() => res.statusText);
295
- throw new Error(`R2 upload failed (${res.status}): ${text}`);
296
- }
297
- }
298
- function makeR2UploadHandler(opts) {
299
- const {
300
- accountId,
301
- bucket,
302
- accessKeyId,
303
- secretAccessKey,
304
- publicUrl,
305
- prefix = "uploads"
306
- } = opts;
307
- const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
308
- return async (file, _site, detected) => {
309
- const key = `${prefix}/${randomUUID2()}.${detected.ext}`;
310
- const body = Buffer.from(await file.arrayBuffer());
311
- await signedPutRequest({
312
- endpoint,
313
- bucket,
314
- key,
315
- body,
316
- contentType: file.type,
317
- accessKeyId,
318
- secretAccessKey
319
- });
320
- return `${publicUrl.replace(/\/$/, "")}/${key}`;
321
- };
322
- }
323
-
324
181
  // src/token.ts
325
182
  import { randomBytes } from "crypto";
326
183
  import { readFileSync, writeFileSync, existsSync } from "fs";
327
- import { join as join2 } from "path";
184
+ import { join } from "path";
328
185
  function generateToken() {
329
186
  return randomBytes(32).toString("hex");
330
187
  }
331
188
  function ensureToken(rootPath) {
332
- const envPath = join2(rootPath, ".env");
189
+ const envPath = join(rootPath, ".env");
333
190
  let contents = "";
334
191
  if (existsSync(envPath)) {
335
192
  contents = readFileSync(envPath, "utf-8");
@@ -356,6 +213,7 @@ function canciaIntegration(opts = {}) {
356
213
  let resolvedUploadHandler;
357
214
  let resolvedDeployHook;
358
215
  let resolvedLocales = ["en"];
216
+ let bakedConfig = null;
359
217
  return {
360
218
  name: "@cancia/astro",
361
219
  hooks: {
@@ -385,6 +243,42 @@ function canciaIntegration(opts = {}) {
385
243
  \x1B[32mcancia\x1B[0m No CANCIA_TOKEN found \u2014 generated one for you.`);
386
244
  }
387
245
  const hasDeployHook = !!(opts.deployHook ?? env.CANCIA_DEPLOY_HOOK ?? process.env.CANCIA_DEPLOY_HOOK);
246
+ let storageDescriptor;
247
+ if (opts.git) {
248
+ storageDescriptor = {
249
+ kind: "git-backed",
250
+ repo: opts.git.repo,
251
+ branch: opts.git.branch,
252
+ committer: opts.git.committer,
253
+ debounceMs: opts.git.debounceMs,
254
+ commitMessage: opts.git.commitMessage
255
+ };
256
+ } else if (opts.storageV2 === null) {
257
+ storageDescriptor = void 0;
258
+ } else {
259
+ storageDescriptor = { kind: "json-file", dbPath: opts.dbPath };
260
+ }
261
+ let r2Baked;
262
+ if (opts.r2) {
263
+ const r2Opts = typeof opts.r2 === "function" ? opts.r2() : opts.r2;
264
+ r2Baked = {
265
+ accountId: r2Opts.accountId,
266
+ bucket: r2Opts.bucket,
267
+ publicUrl: r2Opts.publicUrl,
268
+ prefix: r2Opts.prefix
269
+ };
270
+ }
271
+ bakedConfig = {
272
+ projectRoot: resolvedRootPath,
273
+ schemasPath: opts.schemasPath,
274
+ defaultLocale: resolvedLocales[0],
275
+ locales: resolvedLocales,
276
+ maxUploadMB: opts.maxUploadMB ?? 10,
277
+ public: opts.public ?? false,
278
+ hasDeployHook,
279
+ storage: storageDescriptor,
280
+ r2: r2Baked
281
+ };
388
282
  injectScript(
389
283
  "head-inline",
390
284
  `window.__CANCIA__=${JSON.stringify({
@@ -410,6 +304,8 @@ function canciaIntegration(opts = {}) {
410
304
  injectRoute({ pattern: "/api/cancia/schemas", entrypoint: endpointPath("schemas"), prerender: false });
411
305
  injectRoute({ pattern: "/api/cancia/lists/[listName]", entrypoint: endpointPath("lists"), prerender: false });
412
306
  injectRoute({ pattern: "/api/cancia/lists/[listName]/[id]", entrypoint: endpointPath("lists"), prerender: false });
307
+ const runtimeModulePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
308
+ const RESOLVED_VIRTUAL_ID = "\0virtual:cancia/runtime";
413
309
  updateConfig({
414
310
  vite: {
415
311
  plugins: [
@@ -417,8 +313,18 @@ function canciaIntegration(opts = {}) {
417
313
  name: "vite-plugin-cancia-runtime",
418
314
  resolveId(id) {
419
315
  if (id === "virtual:cancia/runtime") {
420
- return fileURLToPath(new URL("./runtime.js", import.meta.url));
316
+ return RESOLVED_VIRTUAL_ID;
421
317
  }
318
+ },
319
+ load(id) {
320
+ if (id !== RESOLVED_VIRTUAL_ID) return;
321
+ const importUrl = pathToFileURL(runtimeModulePath).href;
322
+ const bakedLiteral = JSON.stringify(bakedConfig ?? null);
323
+ return [
324
+ `import { setBakedConfig, getCanciaRuntime } from ${JSON.stringify(importUrl)};`,
325
+ `setBakedConfig(${bakedLiteral});`,
326
+ `export { getCanciaRuntime };`
327
+ ].join("\n");
422
328
  }
423
329
  }
424
330
  ]
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { C as CanciaStorage, a as CanciaStorageV2 } from './types-BMlLS-OS.js';
2
2
  import { U as UploadHandler } from './upload-DwCGjXbz.js';
3
+ import { G as GitHubCommitter } from './github-client-BAZ1pW24.js';
3
4
 
4
5
  interface CanciaRuntime {
5
6
  /** v1 KV-only storage. Kept for the existing /content endpoint. */
@@ -28,7 +29,51 @@ interface CanciaRuntime {
28
29
  deployHook: string | undefined;
29
30
  maxUploadMB: number;
30
31
  }
32
+ /** Describes which storage adapter to build lazily in the server process. */
33
+ type BakedStorageDescriptor = {
34
+ kind: "json-file";
35
+ dbPath?: string;
36
+ } | {
37
+ kind: "git-backed";
38
+ repo: string;
39
+ branch?: string;
40
+ committer?: GitHubCommitter;
41
+ debounceMs?: number;
42
+ commitMessage?: string;
43
+ };
44
+ /** R2 config baked minus its secrets (accessKeyId/secretAccessKey come from env). */
45
+ interface BakedR2Config {
46
+ accountId?: string;
47
+ bucket?: string;
48
+ publicUrl?: string;
49
+ prefix?: string;
50
+ }
51
+ interface BakedConfig {
52
+ projectRoot: string;
53
+ schemasPath: string | undefined;
54
+ defaultLocale: string;
55
+ locales: string[];
56
+ maxUploadMB: number;
57
+ /** When true, endpoints skip auth (public demos). Baked, never a secret. */
58
+ public: boolean;
59
+ /** Whether a deploy hook exists — the value itself is read from env. */
60
+ hasDeployHook: boolean;
61
+ /** How to build the v2 storage adapter at runtime. Absent = no v2 storage. */
62
+ storage: BakedStorageDescriptor | undefined;
63
+ /** When set, build an R2 upload handler; secrets come from env at runtime. */
64
+ r2: BakedR2Config | undefined;
65
+ }
66
+ /**
67
+ * Called by the generated virtual:cancia/runtime module at import time with the
68
+ * build-baked, non-secret config. Enables lazy self-init in a fresh process.
69
+ */
70
+ declare function setBakedConfig(config: BakedConfig): void;
71
+ /**
72
+ * Eagerly set the runtime. Used in dev (astro:server:setup) and in-process
73
+ * builds where the resolved runtime already exists. Production falls through
74
+ * to lazy init instead.
75
+ */
31
76
  declare function setCanciaRuntime(runtime: CanciaRuntime): void;
32
77
  declare function getCanciaRuntime(): CanciaRuntime;
33
78
 
34
- export { type CanciaRuntime, getCanciaRuntime, setCanciaRuntime };
79
+ export { type BakedConfig, type BakedR2Config, type BakedStorageDescriptor, type CanciaRuntime, getCanciaRuntime, setBakedConfig, setCanciaRuntime };
package/dist/runtime.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import {
2
2
  getCanciaRuntime,
3
+ setBakedConfig,
3
4
  setCanciaRuntime
4
- } from "./chunk-DGCGIEFD.js";
5
+ } from "./chunk-7MPOERVU.js";
6
+ import "./chunk-5ELSN6LI.js";
7
+ import "./chunk-ST44VULL.js";
8
+ import "./chunk-7IA5B5CF.js";
9
+ import "./chunk-5IPHDIC6.js";
5
10
  export {
6
11
  getCanciaRuntime,
12
+ setBakedConfig,
7
13
  setCanciaRuntime
8
14
  };
@@ -1,5 +1,7 @@
1
1
  import { C as CanciaStorage, a as CanciaStorageV2 } from '../types-BMlLS-OS.js';
2
2
  export { b as CanciaKVStore, c as CanciaListStore, d as CanciaPageStore, L as ListEntry, P as PageMeta, e as PageRecord, f as PageSEO, R as Rev, g as RevConflictError } from '../types-BMlLS-OS.js';
3
+ import { G as GitHubCommitter, a as GitHubClient, F as FetchLike } from '../github-client-BAZ1pW24.js';
4
+ export { C as CommitFile, b as GitHubClientOptions, c as createGitHubClient } from '../github-client-BAZ1pW24.js';
3
5
 
4
6
  declare function createJsonFileAdapter(filePath?: string): CanciaStorage;
5
7
 
@@ -17,53 +19,6 @@ declare function createJsonFileAdapterV2(opts?: JsonFileV2Options): CanciaStorag
17
19
 
18
20
  declare function createSQLiteAdapter(dbPath?: string): CanciaStorage;
19
21
 
20
- /** Minimal fetch signature — matches the global `fetch` we depend on. */
21
- type FetchLike = (input: string, init?: {
22
- method?: string;
23
- headers?: Record<string, string>;
24
- body?: string;
25
- }) => Promise<{
26
- ok: boolean;
27
- status: number;
28
- json(): Promise<unknown>;
29
- text(): Promise<string>;
30
- }>;
31
- interface GitHubCommitter {
32
- name: string;
33
- email: string;
34
- }
35
- interface GitHubClientOptions {
36
- /** "owner/name" */
37
- repo: string;
38
- /** Branch to commit onto, e.g. "main". */
39
- branch: string;
40
- /** Fine-grained PAT with contents:write on the one repo. */
41
- token: string;
42
- /** Optional committer identity. GitHub uses the token's user if omitted. */
43
- committer?: GitHubCommitter;
44
- /** Injectable fetch (defaults to global fetch) — tests mock this. */
45
- fetch?: FetchLike;
46
- /** API base — defaults to https://api.github.com. Overridable for tests. */
47
- apiBase?: string;
48
- }
49
- interface CommitFile {
50
- /** Repo-relative path, forward slashes, no leading slash. */
51
- path: string;
52
- /** Raw file content (UTF-8 text). Encoded to base64 before PUT. */
53
- content: string;
54
- }
55
- interface GitHubClient {
56
- /** Current blob sha for `path`, or null if the file doesn't exist yet. */
57
- getFileSha(path: string): Promise<string | null>;
58
- /**
59
- * Commit each file to the branch. Updates pass the current sha; creates omit
60
- * it. Resolves once every PUT succeeds; rejects (without partial silence) if
61
- * any PUT fails so the caller can keep the batch dirty and retry.
62
- */
63
- commitFiles(files: CommitFile[], message: string): Promise<void>;
64
- }
65
- declare function createGitHubClient(opts: GitHubClientOptions): GitHubClient;
66
-
67
22
  interface GitBackedContentPaths {
68
23
  /** KV file path. Default <projectRoot>/cancia-content.json. */
69
24
  kvPath?: string;
@@ -126,4 +81,4 @@ type GitBackedStorage = CanciaStorageV2 & {
126
81
  };
127
82
  declare function createGitBackedAdapter(opts: GitBackedOptions): GitBackedStorage;
128
83
 
129
- export { CanciaStorage, CanciaStorageV2, type CommitFile, type GitBackedContentPaths, type GitBackedControls, type GitBackedOptions, type GitBackedStorage, type GitHubClient, type GitHubClientOptions, type GitHubCommitter, createGitBackedAdapter, createGitHubClient, createJsonFileAdapter, createJsonFileAdapterV2, createSQLiteAdapter };
84
+ export { CanciaStorage, CanciaStorageV2, type GitBackedContentPaths, type GitBackedControls, type GitBackedOptions, type GitBackedStorage, GitHubClient, GitHubCommitter, createGitBackedAdapter, createJsonFileAdapter, createJsonFileAdapterV2, createSQLiteAdapter };
@@ -1,8 +1,10 @@
1
1
  import {
2
- createGitBackedAdapter,
3
- createGitHubClient,
4
2
  createSQLiteAdapter
5
- } from "../chunk-SXKZ2WUL.js";
3
+ } from "../chunk-AE4SIY24.js";
4
+ import {
5
+ createGitBackedAdapter,
6
+ createGitHubClient
7
+ } from "../chunk-5ELSN6LI.js";
6
8
  import {
7
9
  createJsonFileAdapter,
8
10
  createJsonFileAdapterV2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cancia/astro",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Astro integration for Cancia CMS — inline editing with zero separate server",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,14 +0,0 @@
1
- // src/runtime.ts
2
- var _runtime = null;
3
- function setCanciaRuntime(runtime) {
4
- _runtime = runtime;
5
- }
6
- function getCanciaRuntime() {
7
- if (!_runtime) throw new Error("[cancia] Runtime not initialised. This is a bug.");
8
- return _runtime;
9
- }
10
-
11
- export {
12
- setCanciaRuntime,
13
- getCanciaRuntime
14
- };