twofold 0.4.1

One document, two views. Markdown share service for humans and agents.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
openapi: "3.1.0"

info:
  title: Twofold
  version: "0.4.0"
  description: |
    One document, two views. Twofold is a self-hosted markdown share service that serves
    styled HTML to humans and raw markdown to agents from the same document.

    ## Authentication

    Write operations (create, update, delete, list) require a Bearer token in the
    Authorization header. Read operations on the human-facing routes are public unless
    the document is password-protected.

    ```
    Authorization: Bearer <token>
    ```

    ## Frontmatter

    Documents may include a YAML frontmatter block at the start:

    ```markdown
    ---
    title: My Document
    slug: custom-slug
    theme: dark
    expiry: 7d
    password: secret
    description: A short summary
    ---
    # Document body
    ```

    Frontmatter fields:

    | Field       | Type   | Description                                              |
    |-------------|--------|----------------------------------------------------------|
    | title       | string | Document title (overrides H1 extraction)                 |
    | slug        | string | Custom URL slug (alphanumeric + hyphen, 3-128 chars)     |
    | theme       | string | Rendering theme: `clean`, `dark`, `paper`, `minimal`    |
    | expiry      | string | Expiry duration: `30m`, `24h`, `7d`, `2w` (min 5m)      |
    | password    | string | Password-protect the human view                          |
    | description | string | Short description (returned in API responses)            |

  contact:
    name: Geoff Baum
    url: https://github.com/gabaum10/twofold

servers:
  - url: http://localhost:3000
    description: Local development server

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Admin token (TWOFOLD_TOKEN env var) or a managed token created via
        `twofold token create`. All tokens have full access.

  schemas:
    DocumentCreated:
      type: object
      required: [url, slug, api_url, title, created_at]
      properties:
        url:
          type: string
          format: uri
          description: Human-facing URL for the document.
          example: "http://localhost:3000/board-q1"
        slug:
          type: string
          description: URL slug (auto-generated or custom).
          example: "board-q1"
        api_url:
          type: string
          format: uri
          description: Agent API URL for raw markdown retrieval.
          example: "http://localhost:3000/api/v1/documents/board-q1"
        title:
          type: string
          description: Document title (frontmatter > H1 > slug).
          example: "Board Report Q1"
        description:
          type: string
          nullable: true
          description: Short description from frontmatter.
          example: "Q1 summary for the board"
        created_at:
          type: string
          format: date-time
          description: ISO 8601 UTC creation timestamp.
          example: "2026-05-10T03:22:00Z"
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 UTC expiry timestamp. Null if no expiry.
          example: "2026-05-17T03:22:00Z"

    DocumentSummary:
      type: object
      required: [slug, title, created_at]
      properties:
        slug:
          type: string
          example: "board-q1"
        title:
          type: string
          example: "Board Report Q1"
        description:
          type: string
          nullable: true
          example: "Q1 summary for the board"
        created_at:
          type: string
          format: date-time
          example: "2026-05-10T03:22:00Z"
        expires_at:
          type: string
          format: date-time
          nullable: true
          example: null

    DocumentList:
      type: object
      required: [documents, total, limit, offset]
      properties:
        documents:
          type: array
          items:
            $ref: "#/components/schemas/DocumentSummary"
        total:
          type: integer
          description: Total count of non-expired documents (for pagination).
          example: 42
        limit:
          type: integer
          description: The limit applied to this response.
          example: 20
        offset:
          type: integer
          description: The offset applied to this response.
          example: 0

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message.
          example: "Not found"

paths:
  /api/v1/documents:
    post:
      summary: Create a document
      operationId: createDocument
      description: |
        Publish a new markdown document. The request body is raw markdown text
        (Content-Type: text/markdown). Optional YAML frontmatter at the top of the
        body controls title, slug, theme, expiry, password, and description.

        Returns 409 Conflict if a custom slug is already in use.
        Returns 413 Payload Too Large if the body exceeds TWOFOLD_MAX_SIZE (default 1MB).
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          text/markdown:
            schema:
              type: string
              description: |
                Raw markdown content, optionally preceded by YAML frontmatter.
              example: |
                ---
                title: Board Report Q1
                slug: board-q1
                theme: clean
                expiry: 7d
                ---
                # Board Report Q1

                Revenue up 12% quarter-over-quarter.
      responses:
        "201":
          description: Document created successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentCreated"
        "400":
          description: |
            Bad request. Possible causes:
            - Empty request body
            - Invalid UTF-8 in body
            - Invalid frontmatter YAML
            - Invalid slug format (reserved, too short, invalid chars, starts/ends with hyphen)
            - Invalid expiry format or out of range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Custom slug already in use.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: Request body exceeds the configured maximum size.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    get:
      summary: List documents
      operationId: listDocuments
      description: |
        Returns a paginated list of non-expired document summaries, ordered by
        creation date descending (newest first).

        Does NOT return raw document content — metadata only.
        Expired documents are excluded from results and from the total count.
      security:
        - BearerAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: Maximum number of results to return. Capped at 100.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Number of results to skip. Values below 0 are treated as 0.
      responses:
        "200":
          description: Document list returned successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentList"
              example:
                documents:
                  - slug: "board-q1"
                    title: "Board Report Q1"
                    description: "Q1 summary"
                    created_at: "2026-05-10T03:22:00Z"
                    expires_at: null
                total: 1
                limit: 20
                offset: 0
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/documents/{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug (alphanumeric + hyphen).
        example: "board-q1"

    get:
      summary: Get document (agent view)
      operationId: getDocument
      description: |
        Returns the full raw markdown source exactly as it was POSTed,
        including any frontmatter and `<!-- @agent -->` sections.

        This endpoint is NOT password-gated — it is intended for agent access.
        Password-protected documents are readable here with a valid bearer token.

        Returns 410 Gone if the document has expired.
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Raw markdown content.
          content:
            text/markdown:
              schema:
                type: string
              example: |
                ---
                title: Board Report Q1
                ---
                # Board Report Q1

                Revenue up 12%.
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired and been deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    put:
      summary: Update a document
      operationId: updateDocument
      description: |
        Replace the content of an existing document. The slug in the URL is
        authoritative — any slug field in frontmatter is ignored on PUT.

        Expiry, theme, and password can be changed on update. To remove an expiry,
        omit the `expiry` frontmatter field. To remove a password, omit the
        `password` frontmatter field (or set it to an empty string).

        Returns 410 Gone if the document has expired.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          text/markdown:
            schema:
              type: string
              description: New markdown content for the document.
      responses:
        "200":
          description: Document updated successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentCreated"
        "400":
          description: Bad request (empty body, invalid frontmatter, invalid expiry).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    delete:
      summary: Delete a document
      operationId: deleteDocument
      description: |
        Permanently delete a document by slug. Returns 204 on success.
        Expired documents can still be explicitly deleted (cleanup use case).
        Returns 404 if the document does not exist.
      security:
        - BearerAuth: []
      responses:
        "204":
          description: Document deleted successfully. No response body.
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/openapi.yaml:
    get:
      summary: OpenAPI spec (YAML)
      operationId: getOpenApiYaml
      description: |
        Returns this OpenAPI specification as YAML (OpenAPI 3.1.0).
        Public — no authentication required.
      security: []
      responses:
        "200":
          description: OpenAPI 3.1.0 specification in YAML format.
          content:
            application/yaml:
              schema:
                type: string

  /api/v1/openapi.json:
    get:
      summary: OpenAPI spec (JSON)
      operationId: getOpenApiJson
      description: |
        Returns the OpenAPI specification as JSON (derived from the canonical YAML).
        Some tools prefer JSON over YAML. Public — no authentication required.
      security: []
      responses:
        "200":
          description: OpenAPI 3.1.0 specification in JSON format.
          content:
            application/json:
              schema:
                type: object

  /{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug.
        example: "board-q1"
      - name: raw
        in: query
        schema:
          type: string
          enum: ["1"]
        description: |
          When set to `1`, returns the raw markdown source instead of rendered HTML.
          Subject to the same password gate as the human view.

    get:
      summary: Human view (themed HTML or raw markdown)
      operationId: getHumanView
      description: |
        The primary human-facing endpoint. Returns a fully rendered, styled HTML page.

        - `<!-- @agent -->` / `<!-- @end -->` sections are stripped from the human view.
        - Frontmatter is stripped from the rendered output.
        - Code blocks are syntax-highlighted.
        - If the document is password-protected and the user has not authenticated,
          returns the password prompt page (200, not 401).

        With `?raw=1`: returns the full raw markdown source (password-gated).

        Returns 410 Gone if the document has expired.
      security: []
      responses:
        "200":
          description: |
            Rendered HTML page (or raw markdown if ?raw=1, or password prompt if protected).
          content:
            text/html:
              schema:
                type: string
            text/markdown:
              schema:
                type: string
        "302":
          description: Internal redirect (not exposed externally).
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /{slug}/full:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug.

    get:
      summary: Full rendered view (agent sections included)
      operationId: getFullView
      description: |
        Renders the full document including content from `<!-- @agent -->` sections.
        The marker comment lines themselves are stripped, but their content is kept.

        `<!-- @instructions -->` / `<!-- @end-instructions -->` blocks are removed
        entirely (both markers and content).

        Password-protected documents still require authentication via cookie.
        Returns 410 Gone if the document has expired.
      security: []
      responses:
        "200":
          description: Full rendered HTML page.
          content:
            text/html:
              schema:
                type: string
        "302":
          description: Password gate redirect (not exposed externally).
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /{slug}/unlock:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug.

    post:
      summary: Unlock a password-protected document
      operationId: unlockDocument
      description: |
        Verifies the password for a protected document. On success, sets an
        HttpOnly HMAC-signed cookie and redirects (303) to the document view.

        On failure, returns the password prompt page again with an error message.

        The auth cookie is valid for 1 hour.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
                  description: The document password.
      responses:
        "303":
          description: Password correct. Redirects to the document view with auth cookie set.
          headers:
            Location:
              schema:
                type: string
              description: URL of the document.
            Set-Cookie:
              schema:
                type: string
              description: HttpOnly HMAC-signed auth cookie, valid for 1 hour.
        "200":
          description: |
            Password incorrect. Returns password prompt page with error message.
            (Not a 4xx — the form is re-displayed.)
          content:
            text/html:
              schema:
                type: string
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"