@takuhon/cloudflare 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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Takuhon contributors
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,10 @@
1
+ @takuhon/cloudflare
2
+ Copyright 2026 Takuhon contributors
3
+
4
+ This product includes software developed by the Takuhon project
5
+ (https://github.com/takuhon-dev/takuhon).
6
+
7
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
8
+
9
+ Third-party dependencies retain their own licenses; see this package's
10
+ node_modules listing for details.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @takuhon/cloudflare
2
+
3
+ Cloudflare Workers adapter for Takuhon. Wires the framework-agnostic Hono
4
+ handlers from `@takuhon/api` to a Workers KV-backed profile store, the colo-
5
+ local edge cache, and `console.log`-based audit logging.
6
+
7
+ ## Routes
8
+
9
+ | Method | Path | Source |
10
+ | -------- | --------------------------- | -------------------------------------------- |
11
+ | `GET` | `/` | `@takuhon/api` `createPublicApp` |
12
+ | `GET` | `/api/profile` | `@takuhon/api` `createPublicApp` (KV-backed) |
13
+ | `GET` | `/api/schema` | `@takuhon/api` `createPublicApp` |
14
+ | `GET` | `/api/jsonld` | `@takuhon/api` `createPublicApp` |
15
+ | `GET` | `/takuhon.json` | `@takuhon/api` `createPublicApp` |
16
+ | `GET` | `/.well-known/takuhon.json` | `@takuhon/api` `createPublicApp` |
17
+ | `GET` | `/admin` | `@takuhon/api` `createAdminUiApp` (HTML) |
18
+ | `PUT` | `/api/admin/profile` | `@takuhon/api` `createAdminApiApp` |
19
+ | `DELETE` | `/api/admin/profile` | `@takuhon/api` `createAdminApiApp` |
20
+
21
+ `POST` / `PATCH` on admin paths returns `405 Method Not Allowed`. Schema
22
+ validation failures return `422 Unprocessable Entity` with an `errors[]`
23
+ list of JSON-Schema-style fragment pointers.
24
+
25
+ ## Local development
26
+
27
+ ```sh
28
+ pnpm install
29
+ pnpm --filter @takuhon/cloudflare dev # wrangler dev
30
+ ```
31
+
32
+ Visit `http://127.0.0.1:8787/`. The first request returns the bundled
33
+ onboarding fixture; the admin UI at `/admin` lets you replace it once you
34
+ provision an admin token (next section).
35
+
36
+ ## Production deploy
37
+
38
+ ### 1. Create the KV namespace
39
+
40
+ ```sh
41
+ wrangler kv namespace create TAKUHON_KV
42
+ wrangler kv namespace create TAKUHON_KV --preview
43
+ ```
44
+
45
+ Copy the printed ids into `wrangler.toml` (replace the
46
+ `REPLACE_WITH_*_NAMESPACE_ID` placeholders).
47
+
48
+ ### 2. Provision the admin token
49
+
50
+ The admin bearer token must never live in `wrangler.toml` or source
51
+ control. Set it as a Wrangler secret:
52
+
53
+ ```sh
54
+ # 32 bytes of entropy, base64-encoded (43 chars).
55
+ TOKEN=$(openssl rand -base64 32)
56
+ echo "$TOKEN" | wrangler secret put TAKUHON_ADMIN_TOKEN
57
+ ```
58
+
59
+ Store `$TOKEN` securely (1Password, vault, etc.). Without it, every
60
+ `PUT/DELETE /api/admin/profile` returns `401 Unauthorized`.
61
+
62
+ #### Token rotation
63
+
64
+ ```sh
65
+ # Generate a new token, push it as the new secret, then update any clients
66
+ # that hold the old one. There is no grace period — once the secret
67
+ # changes, requests bearing the old token are rejected.
68
+ echo "$NEW_TOKEN" | wrangler secret put TAKUHON_ADMIN_TOKEN
69
+ ```
70
+
71
+ ### 3. Pin the admin Origin allowlist (optional but recommended)
72
+
73
+ Edit `wrangler.toml` to set the origins that may issue browser-borne admin
74
+ writes:
75
+
76
+ ```toml
77
+ [vars]
78
+ TAKUHON_ADMIN_ORIGIN = "https://admin.example.com,https://localhost:3000"
79
+ ```
80
+
81
+ Empty value disables the check — acceptable when `/admin` is the only
82
+ admin UI surface and you trust same-origin requests. Requests without an
83
+ `Origin` header (curl, native clients) are always allowed; the bearer
84
+ token is the primary auth boundary.
85
+
86
+ ### 4. Configure Rate Limiting Rules
87
+
88
+ `/api/admin/*` is **not** rate-limited in code. Apply a Cloudflare WAF rule
89
+ in the dashboard:
90
+
91
+ 1. Open **Security → WAF → Rate limiting Rules**.
92
+ 2. Add a rule matching `URI Path` starts with `/api/admin/`.
93
+ 3. Set the characteristic to the `Authorization` header (so each token
94
+ gets its own budget).
95
+ 4. Limit: `10 requests per 1 minute`.
96
+ 5. Action: `Block` with a custom JSON response body that mirrors RFC 7807
97
+ `application/problem+json` (or simply `Block` with default 429).
98
+
99
+ ### 5. Deploy
100
+
101
+ ```sh
102
+ pnpm --filter @takuhon/cloudflare typecheck # local TS check
103
+ wrangler deploy
104
+ ```
105
+
106
+ ## Operational notes
107
+
108
+ ### Audit log retrieval
109
+
110
+ All admin auth attempts and profile mutations emit one line of JSON to
111
+ `console.log`. Tail them in real time:
112
+
113
+ ```sh
114
+ wrangler tail
115
+ ```
116
+
117
+ For long-term retention, configure **Workers → Logpush** to ship to R2,
118
+ S3, or a SIEM. Recommended retention: 90 days.
119
+
120
+ The actor identity in every event is `sha256:<hex>` over the presented
121
+ bearer token. The raw token never leaves the request boundary.
122
+
123
+ ### Edge cache invalidation
124
+
125
+ After a successful admin write, the Worker calls `caches.default.delete`
126
+ for `/`, `/api/profile`, `/api/profile?lang=en|ja`, `/api/jsonld`,
127
+ `/api/jsonld?lang=en|ja`, and `/takuhon.json`. **This clears the current
128
+ colo's cache only**; other colos honour the response's `Cache-Control`
129
+ `s-maxage=300` (5 minutes) before refreshing.
130
+
131
+ If you need immediate global invalidation, hit the Cloudflare REST API
132
+ manually after a write (`POST /zones/{zone_id}/purge_cache`). Doing it
133
+ in-Worker requires a zone-scoped API token that we don't bundle by
134
+ default; expanding to multi-lang or a configurable lang list happens as
135
+ part of a later phase.
136
+
137
+ ### Admin UI security
138
+
139
+ The `/admin` HTML editor runs under a tight CSP:
140
+
141
+ - `script-src 'self' 'nonce-<n>'` — no inline scripts without the
142
+ per-request nonce; no `unsafe-inline`.
143
+ - `style-src 'self' 'nonce-<n>'` — same for styles.
144
+ - `require-trusted-types-for 'script'` — DOM-XSS sinks are blocked even
145
+ if the nonce is leaked.
146
+ - `img-src 'self' blob:` — only same-origin or client-side previews.
147
+
148
+ The HTML is single-page, no build step: load `/admin`, paste the token,
149
+ edit JSON, save. The token never appears in the URL or cookies.
150
+
151
+ ### Disabling admin entirely
152
+
153
+ Leave `TAKUHON_ADMIN_TOKEN` unset. Every admin write returns `401`. The
154
+ `/admin` UI is still served but its Save / Delete actions will fail
155
+ identically; treat that as a feature-flag for read-only deployments.
156
+
157
+ ## Limitations & deferred work
158
+
159
+ | Concern | Status | Tracked phase |
160
+ | ---------------------------------- | ------------------------------------- | ------------- |
161
+ | `PATCH /api/admin/profile` | 405 (intentionally not implemented) | Phase 5+ |
162
+ | `POST /api/admin/assets` (R2) | Not yet wired | Phase 3.5 |
163
+ | Global cache purge (REST API) | Colo-local only via `Cache.delete` | Phase 5+ |
164
+ | CORS preflight for cross-origin | Not handled (admin UI is same-origin) | Phase 5+ |
165
+ | CLI scaffolding (`create-takuhon`) | Minimal Wrangler bootstrap | Phase 3.6 |
@@ -0,0 +1,49 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Takuhon } from '@takuhon/core';
3
+
4
+ interface Env {
5
+ TAKUHON_KV: KVNamespace;
6
+ /**
7
+ * Admin bearer token. Provision via `wrangler secret put TAKUHON_ADMIN_TOKEN`.
8
+ * Leave unset to disable admin writes entirely (every PUT/DELETE returns 401).
9
+ */
10
+ TAKUHON_ADMIN_TOKEN?: string;
11
+ /**
12
+ * Comma-separated Origin allowlist for browser-originating admin requests.
13
+ * Empty / unset disables the check (deploy without a configured allowlist is
14
+ * acceptable when the admin UI is same-origin; documented in the README).
15
+ */
16
+ TAKUHON_ADMIN_ORIGIN?: string;
17
+ }
18
+ /** Options accepted by {@link createTakuhonWorker}. */
19
+ interface CreateTakuhonWorkerOptions {
20
+ /**
21
+ * Lazy producer for the fallback Takuhon document served when KV has no
22
+ * stored profile yet. Called at most once per Worker invocation, on the
23
+ * cold path where the storage layer returns no entry. Implementations
24
+ * typically import a bundled `takuhon.json`, validate it once, and return
25
+ * the resulting value.
26
+ */
27
+ readonly fallback: () => Takuhon;
28
+ }
29
+ /**
30
+ * Build a Cloudflare Worker handler for the takuhon adapter. Wires
31
+ * `@takuhon/api`'s public/admin app factories to the KV-backed storage,
32
+ * Cloudflare edge cache purger, and console audit logger that ship with
33
+ * this package.
34
+ *
35
+ * This is the entry point used by projects scaffolded with
36
+ * `create-takuhon`: their `src/index.ts` imports `createTakuhonWorker`,
37
+ * passes a `fallback` that loads the project's own `takuhon.json`, and
38
+ * `export default`s the returned handler. The default export of this
39
+ * module is a convenience that calls the same factory with the monorepo's
40
+ * bundled `personal-profile` fixture.
41
+ */
42
+ declare function createTakuhonWorker(opts: CreateTakuhonWorkerOptions): {
43
+ fetch: (request: Request, env: Env) => Response | Promise<Response>;
44
+ };
45
+ declare const _default: {
46
+ fetch: (request: Request, env: Env) => Response | Promise<Response>;
47
+ };
48
+
49
+ export { type CreateTakuhonWorkerOptions, type Env, createTakuhonWorker, _default as default };
package/dist/index.js ADDED
@@ -0,0 +1,341 @@
1
+ // src/index.ts
2
+ import {
3
+ ERROR_SLUGS,
4
+ createAdminApiApp,
5
+ createAdminUiApp,
6
+ createPublicApp,
7
+ problemResponse
8
+ } from "@takuhon/api";
9
+ import { validate } from "@takuhon/core";
10
+ import { Hono } from "hono";
11
+
12
+ // ../../examples/personal-profile/takuhon.json
13
+ var takuhon_default = {
14
+ schemaVersion: "0.1.0",
15
+ profile: {
16
+ displayName: {
17
+ en: "Pat Rivera",
18
+ ja: "\u30D1\u30C3\u30C8\u30FB\u30EA\u30D9\u30E9"
19
+ },
20
+ tagline: {
21
+ en: "Open-source maintainer and accessibility advocate",
22
+ ja: "\u30AA\u30FC\u30D7\u30F3\u30BD\u30FC\u30B9\u30E1\u30F3\u30C6\u30CA / \u30A2\u30AF\u30BB\u30B7\u30D3\u30EA\u30C6\u30A3\u5B9F\u8DF5\u8005"
23
+ },
24
+ bio: {
25
+ en: "Pat Rivera is a fictional persona used to exercise every field of the takuhon profile schema. They maintain a handful of open-source libraries focused on accessibility tooling and frequently speak at local meetups.",
26
+ ja: "Pat Rivera \u306F takuhon \u30D7\u30ED\u30D5\u30A3\u30FC\u30EB schema \u306E\u5168\u30D5\u30A3\u30FC\u30EB\u30C9\u3092\u793A\u3059\u305F\u3081\u306E\u67B6\u7A7A\u306E\u4EBA\u7269\u3067\u3059\u3002\u30A2\u30AF\u30BB\u30B7\u30D3\u30EA\u30C6\u30A3\u7CFB\u306E\u30AA\u30FC\u30D7\u30F3\u30BD\u30FC\u30B9\u30E9\u30A4\u30D6\u30E9\u30EA\u3092\u4FDD\u5B88\u3057\u3001\u5730\u57DF\u30B3\u30DF\u30E5\u30CB\u30C6\u30A3\u3067\u767B\u58C7\u3057\u3066\u3044\u307E\u3059\u3002"
27
+ },
28
+ avatar: {
29
+ url: "/assets/avatar.webp",
30
+ alt: {
31
+ en: "Pat Rivera smiling, wearing round glasses, in front of a soft gradient.",
32
+ ja: "\u30BD\u30D5\u30C8\u306A\u30B0\u30E9\u30C7\u30FC\u30B7\u30E7\u30F3\u3092\u80CC\u666F\u306B\u5FAE\u7B11\u3080 Pat Rivera\u3002\u4E38\u773C\u93E1\u3092\u7740\u7528\u3002"
33
+ }
34
+ },
35
+ location: {
36
+ country: "PT",
37
+ region: "Lisbon",
38
+ locality: {
39
+ en: "Lisbon",
40
+ ja: "\u30EA\u30B9\u30DC\u30F3"
41
+ },
42
+ display: {
43
+ en: "Lisbon, Portugal",
44
+ ja: "\u30DD\u30EB\u30C8\u30AC\u30EB\u30FB\u30EA\u30B9\u30DC\u30F3"
45
+ }
46
+ }
47
+ },
48
+ links: [
49
+ {
50
+ id: "website",
51
+ type: "website",
52
+ label: { en: "Personal site" },
53
+ url: "https://example.com/pat",
54
+ featured: true,
55
+ order: 0
56
+ },
57
+ {
58
+ id: "github",
59
+ type: "github",
60
+ url: "https://github.com/example-pat",
61
+ featured: true,
62
+ order: 1
63
+ },
64
+ {
65
+ id: "mastodon",
66
+ type: "mastodon",
67
+ url: "https://example.social/@pat",
68
+ order: 2
69
+ },
70
+ {
71
+ id: "blog",
72
+ type: "blog",
73
+ label: {
74
+ en: "Notes on accessible UI",
75
+ ja: "\u30A2\u30AF\u30BB\u30B7\u30D6\u30EB UI \u306E\u899A\u3048\u66F8\u304D"
76
+ },
77
+ url: "https://example.com/pat/blog",
78
+ order: 3
79
+ },
80
+ {
81
+ id: "newsletter",
82
+ type: "custom",
83
+ label: {
84
+ en: "Weekly newsletter",
85
+ ja: "\u9031\u6B21\u30CB\u30E5\u30FC\u30B9\u30EC\u30BF\u30FC"
86
+ },
87
+ url: "https://example.com/pat/newsletter",
88
+ iconUrl: "https://example.com/assets/icons/newsletter.svg",
89
+ order: 4
90
+ }
91
+ ],
92
+ careers: [
93
+ {
94
+ id: "stellar-ux",
95
+ organization: {
96
+ en: "Stellar UX Studio",
97
+ ja: "\u30B9\u30C6\u30E9 UX \u30B9\u30BF\u30B8\u30AA"
98
+ },
99
+ role: {
100
+ en: "Principal Accessibility Engineer",
101
+ ja: "\u30D7\u30EA\u30F3\u30B7\u30D1\u30EB\u30FB\u30A2\u30AF\u30BB\u30B7\u30D3\u30EA\u30C6\u30A3\u30A8\u30F3\u30B8\u30CB\u30A2"
102
+ },
103
+ description: {
104
+ en: "Lead the accessibility engineering practice across product surfaces, drive WCAG 2.2 conformance reviews, and mentor a team of five engineers.",
105
+ ja: "\u30D7\u30ED\u30C0\u30AF\u30C8\u5168\u4F53\u306E\u30A2\u30AF\u30BB\u30B7\u30D3\u30EA\u30C6\u30A3\u8A2D\u8A08\u3092\u7D71\u62EC\u3002WCAG 2.2 \u9069\u5408\u30EC\u30D3\u30E5\u30FC\u3092\u4E3B\u5C0E\u3057\u30015 \u540D\u306E\u30A8\u30F3\u30B8\u30CB\u30A2\u3092\u30E1\u30F3\u30BF\u30EA\u30F3\u30B0\u3002"
106
+ },
107
+ startDate: "2023-04",
108
+ endDate: null,
109
+ isCurrent: true,
110
+ url: "https://example.com/stellar",
111
+ order: 0
112
+ },
113
+ {
114
+ id: "harbor-labs",
115
+ organization: {
116
+ en: "Harbor Labs",
117
+ ja: "\u30CF\u30FC\u30D0\u30FC\u30E9\u30DC"
118
+ },
119
+ role: {
120
+ en: "Senior Frontend Engineer",
121
+ ja: "\u30B7\u30CB\u30A2\u30D5\u30ED\u30F3\u30C8\u30A8\u30F3\u30C9\u30A8\u30F3\u30B8\u30CB\u30A2"
122
+ },
123
+ description: {
124
+ en: "Built the design system foundation and an accessible component library used across nine internal products.",
125
+ ja: "\u30C7\u30B6\u30A4\u30F3\u30B7\u30B9\u30C6\u30E0\u57FA\u76E4\u3068\u3001\u793E\u5185 9 \u30D7\u30ED\u30C0\u30AF\u30C8\u3067\u5229\u7528\u3055\u308C\u308B\u30A2\u30AF\u30BB\u30B7\u30D6\u30EB\u306A\u30B3\u30F3\u30DD\u30FC\u30CD\u30F3\u30C8\u30E9\u30A4\u30D6\u30E9\u30EA\u3092\u8A2D\u8A08\u3002"
126
+ },
127
+ startDate: "2019-06",
128
+ endDate: "2023-03",
129
+ order: 1
130
+ }
131
+ ],
132
+ projects: [
133
+ {
134
+ id: "axe-helpers",
135
+ title: {
136
+ en: "axe-helpers",
137
+ ja: "axe-helpers"
138
+ },
139
+ description: {
140
+ en: "A tiny set of utilities that wraps axe-core for use in component-level integration tests.",
141
+ ja: "\u30B3\u30F3\u30DD\u30FC\u30CD\u30F3\u30C8\u5358\u4F4D\u306E\u7D71\u5408\u30C6\u30B9\u30C8\u5411\u3051\u306B axe-core \u3092\u30E9\u30C3\u30D7\u3059\u308B\u5C0F\u898F\u6A21\u30E6\u30FC\u30C6\u30A3\u30EA\u30C6\u30A3\u96C6\u3002"
142
+ },
143
+ url: "https://example.com/axe-helpers",
144
+ tags: ["accessibility", "testing", "typescript"],
145
+ relatedCareerId: "stellar-ux",
146
+ startDate: "2023-09",
147
+ highlighted: true,
148
+ order: 0
149
+ },
150
+ {
151
+ id: "color-contrast-cli",
152
+ title: {
153
+ en: "color-contrast-cli",
154
+ ja: "color-contrast-cli"
155
+ },
156
+ description: {
157
+ en: "Command-line tool that audits design tokens for WCAG contrast ratios.",
158
+ ja: "\u30C7\u30B6\u30A4\u30F3\u30C8\u30FC\u30AF\u30F3\u306E WCAG \u30B3\u30F3\u30C8\u30E9\u30B9\u30C8\u6BD4\u3092\u76E3\u67FB\u3059\u308B\u30B3\u30DE\u30F3\u30C9\u30E9\u30A4\u30F3\u30C4\u30FC\u30EB\u3002"
159
+ },
160
+ url: "https://example.com/color-contrast-cli",
161
+ tags: ["accessibility", "cli", "design-tokens"],
162
+ startDate: "2021-02",
163
+ endDate: "2022-08",
164
+ order: 1
165
+ },
166
+ {
167
+ id: "meetup-talks",
168
+ title: {
169
+ en: "Local meetup talks",
170
+ ja: "\u5730\u57DF\u30B3\u30DF\u30E5\u30CB\u30C6\u30A3\u767B\u58C7"
171
+ },
172
+ order: 2
173
+ }
174
+ ],
175
+ skills: [
176
+ { id: "typescript", label: "TypeScript", category: "programming", order: 0 },
177
+ { id: "react", label: "React", category: "programming", order: 1 },
178
+ { id: "wcag-2-2", label: "WCAG 2.2", category: "design", order: 2 },
179
+ { id: "aria", label: "ARIA", category: "design", order: 3 },
180
+ { id: "storybook", label: "Storybook", category: "programming", order: 4 },
181
+ { id: "playwright", label: "Playwright", category: "programming", order: 5 },
182
+ { id: "design-tokens", label: "Design tokens", category: "design", order: 6 },
183
+ { id: "portuguese", label: "Portuguese (B2)", category: "language", order: 7 }
184
+ ],
185
+ contact: {
186
+ email: "pat@example.com",
187
+ showEmail: false,
188
+ formUrl: "https://example.com/pat/contact"
189
+ },
190
+ settings: {
191
+ defaultLocale: "en",
192
+ fallbackLocale: "en",
193
+ availableLocales: ["en", "ja"],
194
+ theme: "default",
195
+ showPoweredBy: true,
196
+ enableJsonLd: true,
197
+ enableApi: true,
198
+ enableAnalytics: false
199
+ },
200
+ meta: {
201
+ createdAt: "2026-01-15T09:00:00Z",
202
+ updatedAt: "2026-05-12T08:30:00Z",
203
+ generator: "Takuhon",
204
+ contentLicense: {
205
+ spdxId: "CC-BY-4.0",
206
+ url: "https://creativecommons.org/licenses/by/4.0/",
207
+ attribution: {
208
+ name: "Pat Rivera",
209
+ url: "https://example.com/pat"
210
+ }
211
+ }
212
+ }
213
+ };
214
+
215
+ // src/admin/cloudflare-cache-purger.ts
216
+ var CloudflareCachePurger = class {
217
+ /**
218
+ * `getCache` is a thunk so the Workers-only `caches` global is touched
219
+ * lazily — public-only requests on this Worker never run admin handlers
220
+ * and must not pay (or fail under Node tests) for the lookup.
221
+ */
222
+ constructor(getCache, opts) {
223
+ this.getCache = getCache;
224
+ this.origin = opts.origin.replace(/\/$/, "");
225
+ this.langs = opts.langs ?? ["en", "ja"];
226
+ }
227
+ getCache;
228
+ origin;
229
+ langs;
230
+ async profileUpdated() {
231
+ await this.purge();
232
+ }
233
+ async profileDeleted() {
234
+ await this.purge();
235
+ }
236
+ async purge() {
237
+ const cache = this.getCache();
238
+ const targets = ["/", "/api/profile", "/api/jsonld", "/takuhon.json"];
239
+ for (const lang of this.langs) {
240
+ const q = `?lang=${encodeURIComponent(lang)}`;
241
+ targets.push(`/api/profile${q}`, `/api/jsonld${q}`);
242
+ }
243
+ for (const path of targets) {
244
+ await cache.delete(new Request(this.origin + path), { ignoreMethod: true });
245
+ }
246
+ }
247
+ };
248
+
249
+ // src/admin/console-audit-logger.ts
250
+ var consoleAuditLogger = (event) => {
251
+ console.log(JSON.stringify(event));
252
+ };
253
+
254
+ // src/kv-storage.ts
255
+ import { ConflictError, NotFoundError } from "@takuhon/core";
256
+ var KV_KEY = "TAKUHON_DATA";
257
+ var KvTakuhonStorage = class {
258
+ constructor(kv) {
259
+ this.kv = kv;
260
+ }
261
+ kv;
262
+ async getProfile() {
263
+ const result = await this.kv.getWithMetadata(KV_KEY, "json");
264
+ if (result.value === null || !result.metadata?.version) {
265
+ throw new NotFoundError(`No profile is stored at KV key "${KV_KEY}".`);
266
+ }
267
+ return { data: result.value, version: result.metadata.version };
268
+ }
269
+ async saveProfile(data, ifMatch) {
270
+ if (ifMatch !== void 0) {
271
+ const current = await this.kv.getWithMetadata(KV_KEY, "json");
272
+ const currentVersion = current.metadata?.version;
273
+ if (currentVersion !== ifMatch) {
274
+ throw new ConflictError(
275
+ `If-Match preconditioned on version "${ifMatch}" but current is "${currentVersion ?? "absent"}".`,
276
+ { currentVersion }
277
+ );
278
+ }
279
+ }
280
+ const version = crypto.randomUUID();
281
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
282
+ await this.kv.put(KV_KEY, JSON.stringify(data), {
283
+ metadata: { version, updatedAt }
284
+ });
285
+ return { version };
286
+ }
287
+ async deleteProfile() {
288
+ await this.kv.delete(KV_KEY);
289
+ }
290
+ };
291
+
292
+ // src/index.ts
293
+ function parseOrigins(raw) {
294
+ if (raw === void 0 || raw === "") return [];
295
+ return raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
296
+ }
297
+ function createTakuhonWorker(opts) {
298
+ return {
299
+ fetch(request, env) {
300
+ const url = new URL(request.url);
301
+ const storage = new KvTakuhonStorage(env.TAKUHON_KV);
302
+ const cachePurger = new CloudflareCachePurger(() => caches.default, {
303
+ origin: url.origin
304
+ });
305
+ const auditLogger = consoleAuditLogger;
306
+ const router = new Hono();
307
+ router.notFound(
308
+ (c) => problemResponse(c, {
309
+ slug: ERROR_SLUGS.notFound,
310
+ status: 404,
311
+ title: "Not Found",
312
+ detail: `No route matches ${new URL(c.req.url).pathname}.`
313
+ })
314
+ );
315
+ router.route(
316
+ "/api/admin",
317
+ createAdminApiApp({
318
+ storage,
319
+ getAdminToken: () => env.TAKUHON_ADMIN_TOKEN,
320
+ getAdminOrigins: () => parseOrigins(env.TAKUHON_ADMIN_ORIGIN),
321
+ cachePurger,
322
+ auditLogger
323
+ })
324
+ );
325
+ router.route("/admin", createAdminUiApp());
326
+ router.route("/", createPublicApp({ storage, fallback: opts.fallback }));
327
+ return router.fetch(request, env);
328
+ }
329
+ };
330
+ }
331
+ function bundledFallback() {
332
+ const r = validate(takuhon_default);
333
+ if (!r.ok) throw new Error("Bundled fixture failed validation.");
334
+ return r.data;
335
+ }
336
+ var index_default = createTakuhonWorker({ fallback: bundledFallback });
337
+ export {
338
+ createTakuhonWorker,
339
+ index_default as default
340
+ };
341
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../../../examples/personal-profile/takuhon.json","../src/admin/cloudflare-cache-purger.ts","../src/admin/console-audit-logger.ts","../src/kv-storage.ts"],"sourcesContent":["import {\n ERROR_SLUGS,\n createAdminApiApp,\n createAdminUiApp,\n createPublicApp,\n problemResponse,\n type AuditLogger,\n type CachePurger,\n} from '@takuhon/api';\nimport { validate, type Takuhon } from '@takuhon/core';\nimport { Hono } from 'hono';\n\nimport exampleJson from '../../../examples/personal-profile/takuhon.json' with { type: 'json' };\n\nimport { CloudflareCachePurger } from './admin/cloudflare-cache-purger.js';\nimport { consoleAuditLogger } from './admin/console-audit-logger.js';\nimport { KvTakuhonStorage } from './kv-storage.js';\n\nexport interface Env {\n TAKUHON_KV: KVNamespace;\n /**\n * Admin bearer token. Provision via `wrangler secret put TAKUHON_ADMIN_TOKEN`.\n * Leave unset to disable admin writes entirely (every PUT/DELETE returns 401).\n */\n TAKUHON_ADMIN_TOKEN?: string;\n /**\n * Comma-separated Origin allowlist for browser-originating admin requests.\n * Empty / unset disables the check (deploy without a configured allowlist is\n * acceptable when the admin UI is same-origin; documented in the README).\n */\n TAKUHON_ADMIN_ORIGIN?: string;\n}\n\n/** Options accepted by {@link createTakuhonWorker}. */\nexport interface CreateTakuhonWorkerOptions {\n /**\n * Lazy producer for the fallback Takuhon document served when KV has no\n * stored profile yet. Called at most once per Worker invocation, on the\n * cold path where the storage layer returns no entry. Implementations\n * typically import a bundled `takuhon.json`, validate it once, and return\n * the resulting value.\n */\n readonly fallback: () => Takuhon;\n}\n\nfunction parseOrigins(raw: string | undefined): string[] {\n if (raw === undefined || raw === '') return [];\n return raw\n .split(',')\n .map((s) => s.trim())\n .filter((s) => s !== '');\n}\n\n/**\n * Build a Cloudflare Worker handler for the takuhon adapter. Wires\n * `@takuhon/api`'s public/admin app factories to the KV-backed storage,\n * Cloudflare edge cache purger, and console audit logger that ship with\n * this package.\n *\n * This is the entry point used by projects scaffolded with\n * `create-takuhon`: their `src/index.ts` imports `createTakuhonWorker`,\n * passes a `fallback` that loads the project's own `takuhon.json`, and\n * `export default`s the returned handler. The default export of this\n * module is a convenience that calls the same factory with the monorepo's\n * bundled `personal-profile` fixture.\n */\nexport function createTakuhonWorker(opts: CreateTakuhonWorkerOptions): {\n fetch: (request: Request, env: Env) => Response | Promise<Response>;\n} {\n return {\n fetch(request: Request, env: Env): Response | Promise<Response> {\n const url = new URL(request.url);\n const storage = new KvTakuhonStorage(env.TAKUHON_KV);\n const cachePurger: CachePurger = new CloudflareCachePurger(() => caches.default, {\n origin: url.origin,\n });\n const auditLogger: AuditLogger = consoleAuditLogger;\n\n const router = new Hono();\n router.notFound((c) =>\n problemResponse(c, {\n slug: ERROR_SLUGS.notFound,\n status: 404,\n title: 'Not Found',\n detail: `No route matches ${new URL(c.req.url).pathname}.`,\n }),\n );\n router.route(\n '/api/admin',\n createAdminApiApp({\n storage,\n getAdminToken: () => env.TAKUHON_ADMIN_TOKEN,\n getAdminOrigins: () => parseOrigins(env.TAKUHON_ADMIN_ORIGIN),\n cachePurger,\n auditLogger,\n }),\n );\n router.route('/admin', createAdminUiApp());\n router.route('/', createPublicApp({ storage, fallback: opts.fallback }));\n\n return router.fetch(request, env);\n },\n };\n}\n\nfunction bundledFallback(): Takuhon {\n const r = validate(exampleJson);\n if (!r.ok) throw new Error('Bundled fixture failed validation.');\n return r.data;\n}\n\nexport default createTakuhonWorker({ fallback: bundledFallback });\n","{\n \"schemaVersion\": \"0.1.0\",\n \"profile\": {\n \"displayName\": {\n \"en\": \"Pat Rivera\",\n \"ja\": \"パット・リベラ\"\n },\n \"tagline\": {\n \"en\": \"Open-source maintainer and accessibility advocate\",\n \"ja\": \"オープンソースメンテナ / アクセシビリティ実践者\"\n },\n \"bio\": {\n \"en\": \"Pat Rivera is a fictional persona used to exercise every field of the takuhon profile schema. They maintain a handful of open-source libraries focused on accessibility tooling and frequently speak at local meetups.\",\n \"ja\": \"Pat Rivera は takuhon プロフィール schema の全フィールドを示すための架空の人物です。アクセシビリティ系のオープンソースライブラリを保守し、地域コミュニティで登壇しています。\"\n },\n \"avatar\": {\n \"url\": \"/assets/avatar.webp\",\n \"alt\": {\n \"en\": \"Pat Rivera smiling, wearing round glasses, in front of a soft gradient.\",\n \"ja\": \"ソフトなグラデーションを背景に微笑む Pat Rivera。丸眼鏡を着用。\"\n }\n },\n \"location\": {\n \"country\": \"PT\",\n \"region\": \"Lisbon\",\n \"locality\": {\n \"en\": \"Lisbon\",\n \"ja\": \"リスボン\"\n },\n \"display\": {\n \"en\": \"Lisbon, Portugal\",\n \"ja\": \"ポルトガル・リスボン\"\n }\n }\n },\n \"links\": [\n {\n \"id\": \"website\",\n \"type\": \"website\",\n \"label\": { \"en\": \"Personal site\" },\n \"url\": \"https://example.com/pat\",\n \"featured\": true,\n \"order\": 0\n },\n {\n \"id\": \"github\",\n \"type\": \"github\",\n \"url\": \"https://github.com/example-pat\",\n \"featured\": true,\n \"order\": 1\n },\n {\n \"id\": \"mastodon\",\n \"type\": \"mastodon\",\n \"url\": \"https://example.social/@pat\",\n \"order\": 2\n },\n {\n \"id\": \"blog\",\n \"type\": \"blog\",\n \"label\": {\n \"en\": \"Notes on accessible UI\",\n \"ja\": \"アクセシブル UI の覚え書き\"\n },\n \"url\": \"https://example.com/pat/blog\",\n \"order\": 3\n },\n {\n \"id\": \"newsletter\",\n \"type\": \"custom\",\n \"label\": {\n \"en\": \"Weekly newsletter\",\n \"ja\": \"週次ニュースレター\"\n },\n \"url\": \"https://example.com/pat/newsletter\",\n \"iconUrl\": \"https://example.com/assets/icons/newsletter.svg\",\n \"order\": 4\n }\n ],\n \"careers\": [\n {\n \"id\": \"stellar-ux\",\n \"organization\": {\n \"en\": \"Stellar UX Studio\",\n \"ja\": \"ステラ UX スタジオ\"\n },\n \"role\": {\n \"en\": \"Principal Accessibility Engineer\",\n \"ja\": \"プリンシパル・アクセシビリティエンジニア\"\n },\n \"description\": {\n \"en\": \"Lead the accessibility engineering practice across product surfaces, drive WCAG 2.2 conformance reviews, and mentor a team of five engineers.\",\n \"ja\": \"プロダクト全体のアクセシビリティ設計を統括。WCAG 2.2 適合レビューを主導し、5 名のエンジニアをメンタリング。\"\n },\n \"startDate\": \"2023-04\",\n \"endDate\": null,\n \"isCurrent\": true,\n \"url\": \"https://example.com/stellar\",\n \"order\": 0\n },\n {\n \"id\": \"harbor-labs\",\n \"organization\": {\n \"en\": \"Harbor Labs\",\n \"ja\": \"ハーバーラボ\"\n },\n \"role\": {\n \"en\": \"Senior Frontend Engineer\",\n \"ja\": \"シニアフロントエンドエンジニア\"\n },\n \"description\": {\n \"en\": \"Built the design system foundation and an accessible component library used across nine internal products.\",\n \"ja\": \"デザインシステム基盤と、社内 9 プロダクトで利用されるアクセシブルなコンポーネントライブラリを設計。\"\n },\n \"startDate\": \"2019-06\",\n \"endDate\": \"2023-03\",\n \"order\": 1\n }\n ],\n \"projects\": [\n {\n \"id\": \"axe-helpers\",\n \"title\": {\n \"en\": \"axe-helpers\",\n \"ja\": \"axe-helpers\"\n },\n \"description\": {\n \"en\": \"A tiny set of utilities that wraps axe-core for use in component-level integration tests.\",\n \"ja\": \"コンポーネント単位の統合テスト向けに axe-core をラップする小規模ユーティリティ集。\"\n },\n \"url\": \"https://example.com/axe-helpers\",\n \"tags\": [\"accessibility\", \"testing\", \"typescript\"],\n \"relatedCareerId\": \"stellar-ux\",\n \"startDate\": \"2023-09\",\n \"highlighted\": true,\n \"order\": 0\n },\n {\n \"id\": \"color-contrast-cli\",\n \"title\": {\n \"en\": \"color-contrast-cli\",\n \"ja\": \"color-contrast-cli\"\n },\n \"description\": {\n \"en\": \"Command-line tool that audits design tokens for WCAG contrast ratios.\",\n \"ja\": \"デザイントークンの WCAG コントラスト比を監査するコマンドラインツール。\"\n },\n \"url\": \"https://example.com/color-contrast-cli\",\n \"tags\": [\"accessibility\", \"cli\", \"design-tokens\"],\n \"startDate\": \"2021-02\",\n \"endDate\": \"2022-08\",\n \"order\": 1\n },\n {\n \"id\": \"meetup-talks\",\n \"title\": {\n \"en\": \"Local meetup talks\",\n \"ja\": \"地域コミュニティ登壇\"\n },\n \"order\": 2\n }\n ],\n \"skills\": [\n { \"id\": \"typescript\", \"label\": \"TypeScript\", \"category\": \"programming\", \"order\": 0 },\n { \"id\": \"react\", \"label\": \"React\", \"category\": \"programming\", \"order\": 1 },\n { \"id\": \"wcag-2-2\", \"label\": \"WCAG 2.2\", \"category\": \"design\", \"order\": 2 },\n { \"id\": \"aria\", \"label\": \"ARIA\", \"category\": \"design\", \"order\": 3 },\n { \"id\": \"storybook\", \"label\": \"Storybook\", \"category\": \"programming\", \"order\": 4 },\n { \"id\": \"playwright\", \"label\": \"Playwright\", \"category\": \"programming\", \"order\": 5 },\n { \"id\": \"design-tokens\", \"label\": \"Design tokens\", \"category\": \"design\", \"order\": 6 },\n { \"id\": \"portuguese\", \"label\": \"Portuguese (B2)\", \"category\": \"language\", \"order\": 7 }\n ],\n \"contact\": {\n \"email\": \"pat@example.com\",\n \"showEmail\": false,\n \"formUrl\": \"https://example.com/pat/contact\"\n },\n \"settings\": {\n \"defaultLocale\": \"en\",\n \"fallbackLocale\": \"en\",\n \"availableLocales\": [\"en\", \"ja\"],\n \"theme\": \"default\",\n \"showPoweredBy\": true,\n \"enableJsonLd\": true,\n \"enableApi\": true,\n \"enableAnalytics\": false\n },\n \"meta\": {\n \"createdAt\": \"2026-01-15T09:00:00Z\",\n \"updatedAt\": \"2026-05-12T08:30:00Z\",\n \"generator\": \"Takuhon\",\n \"contentLicense\": {\n \"spdxId\": \"CC-BY-4.0\",\n \"url\": \"https://creativecommons.org/licenses/by/4.0/\",\n \"attribution\": {\n \"name\": \"Pat Rivera\",\n \"url\": \"https://example.com/pat\"\n }\n }\n }\n}\n","import type { CachePurger } from '@takuhon/api';\n\nexport interface CloudflareCachePurgerOptions {\n /**\n * Absolute origin (e.g. `https://example.com`) used to build the URLs\n * passed to `Cache.delete`. The Worker derives this from the incoming\n * request's URL so the same code works under any production hostname.\n */\n origin: string;\n /**\n * Locale codes to include when purging language-keyed cache entries.\n * Cloudflare caches `?lang=` query variants as distinct keys; we purge\n * a representative set on every write. Adapters can extend the list to\n * cover other locales the deploy serves.\n */\n langs?: string[];\n}\n\n/**\n * `CachePurger` backed by Cloudflare's colo-local `caches.default`.\n *\n * Limitations (documented in the adapter README):\n * - Cloudflare's `Cache.delete` clears the current colo only, not the\n * entire edge. Other colos honour the response's `Cache-Control`\n * `s-maxage` (5 minutes today) before refreshing.\n * - Truly global invalidation requires the REST `/purge_cache` API,\n * which needs a zone-scoped token; that's deferred to a later phase.\n */\nexport class CloudflareCachePurger implements CachePurger {\n private readonly origin: string;\n private readonly langs: string[];\n\n /**\n * `getCache` is a thunk so the Workers-only `caches` global is touched\n * lazily — public-only requests on this Worker never run admin handlers\n * and must not pay (or fail under Node tests) for the lookup.\n */\n constructor(\n private readonly getCache: () => Cache,\n opts: CloudflareCachePurgerOptions,\n ) {\n this.origin = opts.origin.replace(/\\/$/, '');\n this.langs = opts.langs ?? ['en', 'ja'];\n }\n\n async profileUpdated(): Promise<void> {\n await this.purge();\n }\n\n async profileDeleted(): Promise<void> {\n await this.purge();\n }\n\n private async purge(): Promise<void> {\n const cache = this.getCache();\n const targets = ['/', '/api/profile', '/api/jsonld', '/takuhon.json'];\n for (const lang of this.langs) {\n const q = `?lang=${encodeURIComponent(lang)}`;\n targets.push(`/api/profile${q}`, `/api/jsonld${q}`);\n }\n for (const path of targets) {\n await cache.delete(new Request(this.origin + path), { ignoreMethod: true });\n }\n }\n}\n","import type { AuditEvent, AuditLogger } from '@takuhon/api';\n\n/**\n * `AuditLogger` that writes a single line of JSON per event to `console.log`.\n *\n * Cloudflare captures these via Workers Tail / Logpush, where they can be\n * routed to R2, S3, or any downstream SIEM. Token bodies never reach this\n * sink — the upstream middleware only emits `sha256:<hex>` digests in\n * `actor.tokenHash`.\n */\nexport const consoleAuditLogger: AuditLogger = (event: AuditEvent): void => {\n console.log(JSON.stringify(event));\n};\n","import { ConflictError, NotFoundError, type Takuhon, type TakuhonStorage } from '@takuhon/core';\n\nexport const KV_KEY = 'TAKUHON_DATA';\n\nexport interface KvMetadata {\n version: string;\n updatedAt: string;\n}\n\n/**\n * Cloudflare KV implementation of the `TakuhonStorage` contract. Stores the\n * profile document as JSON under a single key (`TAKUHON_DATA`) and tracks the\n * optimistic-locking token inside KV value metadata.\n *\n * `version` is a fresh UUIDv4 on every successful write. Callers compare it\n * verbatim against the `If-Match` precondition; mismatches raise\n * `ConflictError` with `currentVersion` so the API layer can build the RFC\n * 7807 envelope without an extra round trip.\n */\nexport class KvTakuhonStorage implements TakuhonStorage {\n constructor(private readonly kv: KVNamespace) {}\n\n async getProfile(): Promise<{ data: Takuhon; version: string }> {\n const result = await this.kv.getWithMetadata<Takuhon, KvMetadata>(KV_KEY, 'json');\n if (result.value === null || !result.metadata?.version) {\n throw new NotFoundError(`No profile is stored at KV key \"${KV_KEY}\".`);\n }\n return { data: result.value, version: result.metadata.version };\n }\n\n async saveProfile(data: Takuhon, ifMatch?: string): Promise<{ version: string }> {\n if (ifMatch !== undefined) {\n const current = await this.kv.getWithMetadata<Takuhon, KvMetadata>(KV_KEY, 'json');\n const currentVersion = current.metadata?.version;\n if (currentVersion !== ifMatch) {\n throw new ConflictError(\n `If-Match preconditioned on version \"${ifMatch}\" but current is \"${currentVersion ?? 'absent'}\".`,\n { currentVersion },\n );\n }\n }\n const version = crypto.randomUUID();\n const updatedAt = new Date().toISOString();\n await this.kv.put(KV_KEY, JSON.stringify(data), {\n metadata: { version, updatedAt } satisfies KvMetadata,\n });\n return { version };\n }\n\n async deleteProfile(): Promise<void> {\n await this.kv.delete(KV_KEY);\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@takuhon/cloudflare",
3
+ "version": "0.1.0",
4
+ "description": "Cloudflare Workers adapter for takuhon — public API, Workers Assets, KV, R2 integrations",
5
+ "license": "Apache-2.0",
6
+ "author": "Takuhon contributors",
7
+ "homepage": "https://github.com/takuhon-dev/takuhon/tree/main/adapters/cloudflare#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/takuhon-dev/takuhon.git",
11
+ "directory": "adapters/cloudflare"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/takuhon-dev/takuhon/issues"
15
+ },
16
+ "keywords": [
17
+ "takuhon",
18
+ "profile",
19
+ "cloudflare-workers",
20
+ "workers",
21
+ "adapter",
22
+ "hono"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE",
37
+ "NOTICE"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.0.0"
44
+ },
45
+ "dependencies": {
46
+ "hono": "^4.12.19",
47
+ "@takuhon/core": "0.1.0",
48
+ "@takuhon/api": "0.1.0"
49
+ },
50
+ "devDependencies": {
51
+ "@cloudflare/workers-types": "^4.20251101.0",
52
+ "wrangler": "^4.30.0"
53
+ },
54
+ "scripts": {
55
+ "typecheck": "tsc",
56
+ "build": "tsup",
57
+ "test": "vitest run",
58
+ "dev": "wrangler dev"
59
+ }
60
+ }