Merge initial scaffolding
Initial repo scaffolding per sneak/prompts NEW_REPO_CHECKLIST: WTFPL LICENSE, REPO_POLICIES.md, editor and prettier dotfiles, JS toolchain (TypeScript, ESLint, Prettier, Vitest with pinned versions), Makefile, Dockerfile (node:22-alpine pinned by sha256), Gitea Actions workflow. make check and make docker both pass.
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
9
.gitea/workflows/check.yml
Normal file
9
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: check
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- run: docker build .
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# TypeScript / build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
.vitest-cache/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# quack runtime data (in case anyone runs the CLI from inside the repo)
|
||||||
|
.quack/
|
||||||
|
|
||||||
|
# Local Claude Code settings (per-developer)
|
||||||
|
.claude/
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# node 22-alpine, 2026-02-22
|
||||||
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
||||||
|
|
||||||
|
RUN apk add --no-cache make
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN make check
|
||||||
13
LICENSE
Normal file
13
LICENSE
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
Version 2, December 2004
|
||||||
|
|
||||||
|
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim or modified
|
||||||
|
copies of this license document, and changing it is allowed as long
|
||||||
|
as the name is changed.
|
||||||
|
|
||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||||
41
Makefile
Normal file
41
Makefile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.PHONY: test lint fmt fmt-check check build dev clean docker hooks
|
||||||
|
|
||||||
|
# Use `timeout` (GNU coreutils) when available so `make test` is hard-capped.
|
||||||
|
# On macOS without coreutils this is empty and the cap is skipped.
|
||||||
|
TIMEOUT := $(shell command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
|
||||||
|
|
||||||
|
YARN := yarn run
|
||||||
|
|
||||||
|
test:
|
||||||
|
@$(TIMEOUT) $(if $(TIMEOUT),30s,) $(YARN) vitest run --reporter=dot || \
|
||||||
|
{ echo "--- Rerunning with verbose for details ---"; \
|
||||||
|
$(YARN) vitest run --reporter=verbose; exit 1; }
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@$(YARN) eslint .
|
||||||
|
@$(YARN) prettier --check .
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
@$(YARN) prettier --write .
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
@$(YARN) prettier --check .
|
||||||
|
|
||||||
|
check: test lint fmt-check
|
||||||
|
|
||||||
|
build:
|
||||||
|
@$(YARN) tsc
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@$(YARN) tsc --watch
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -rf dist coverage .vitest-cache *.tsbuildinfo
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -t quack .
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
@printf '#!/bin/sh\nset -e\nmake check\n' > .git/hooks/pre-commit
|
||||||
|
@chmod +x .git/hooks/pre-commit
|
||||||
|
@echo "Installed pre-commit hook (runs make check)."
|
||||||
557
README.md
557
README.md
@@ -43,16 +43,27 @@ await client.downloadFile(file, "./out/");
|
|||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Ente is one of very few photo services with a credible end-to-end encryption
|
Ente is one of very few photo services with a credible end-to-end encryption
|
||||||
story. The official clients (mobile Flutter app, web React app, and Go CLI) are
|
story. The shipping clients (mobile Flutter, web React, desktop Electron, and Go
|
||||||
high quality but each is bound up with a UI or with sync semantics that aren't
|
CLI) work, but they are slow, buggy, and difficult to script against. The
|
||||||
useful for scripted access. Pulling individual photos out of an Ente account
|
Flutter app fails to sync reliably. The web app is heavy. The desktop app is the
|
||||||
from a script (for backup, migration, archival, or programmatic processing) is
|
web app inside a slow Electron wrapper. The Go CLI is the closest thing to a
|
||||||
awkward without a small TypeScript library that does just the cryptography and
|
usable tool, but it is awkward to integrate from anything that is not a shell.
|
||||||
nothing else.
|
|
||||||
|
|
||||||
This project exists to provide that library. It is deliberately scoped to read
|
quack is the first step in fixing that. This repo ships a small, correct,
|
||||||
operations: log in, walk the account, decrypt and save files. Upload, sharing,
|
well-tested implementation of Ente's cryptographic protocol and its read-only
|
||||||
deletion, and sync state management are out of scope for the first release.
|
API surface, plus a CLI that proves the library is enough to do real work
|
||||||
|
without a UI.
|
||||||
|
|
||||||
|
The longer-term goal of this project is a simple desktop client for Ente, built
|
||||||
|
on this library in Electron (or a comparable runtime), with two priorities above
|
||||||
|
everything else: correctness and stability. Performance and simplicity follow
|
||||||
|
from those. Features will be added only after the protocol layer is correct, the
|
||||||
|
local cache is reliable, and the UI is responsive on a five-year-old laptop.
|
||||||
|
|
||||||
|
This first release is deliberately scoped to read operations: log in, walk the
|
||||||
|
account, decrypt and save files. Upload, sharing, deletion, and bidirectional
|
||||||
|
sync are out of scope. Adding them later is straightforward; doing them right
|
||||||
|
requires the protocol layer to be correct first.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -85,17 +96,17 @@ quack/
|
|||||||
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
||||||
required for `crypto_pwhash` / Argon2id). No hand-rolled crypto.
|
required for `crypto_pwhash` / Argon2id). No hand-rolled crypto.
|
||||||
|
|
||||||
The key hierarchy, derived during login, looks like this:
|
The key hierarchy, derived during login, is:
|
||||||
|
|
||||||
1. The user enters their password.
|
1. The user enters their password.
|
||||||
2. Argon2id (`crypto_pwhash`) over the password and a server-issued
|
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
|
||||||
`kekSalt`, with server-issued `memLimit` and `opsLimit`, produces a
|
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
|
||||||
32-byte Key Encryption Key (KEK).
|
Encryption Key (KEK).
|
||||||
3. SRP login: a 16-byte SRP login subkey is derived from the KEK using
|
3. SRP login: a 16-byte SRP login subkey is derived from the KEK using
|
||||||
`crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context
|
`crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context
|
||||||
`loginctx`. That 16-byte value is the SRP password.
|
`loginctx`. That 16-byte value is the SRP password.
|
||||||
4. After SRP completes (or after email-OTP fallback), the server returns a
|
4. After SRP completes (or after email-OTP fallback), the server returns a blob
|
||||||
blob of "key attributes" plus an encrypted auth token.
|
of "key attributes" plus an encrypted auth token.
|
||||||
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
||||||
yields the 32-byte master key.
|
yields the 32-byte master key.
|
||||||
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
|
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
|
||||||
@@ -105,12 +116,12 @@ The key hierarchy, derived during login, looks like this:
|
|||||||
yields the URL-safe base64 auth token used in `X-Auth-Token` for all
|
yields the URL-safe base64 auth token used in `X-Auth-Token` for all
|
||||||
subsequent calls.
|
subsequent calls.
|
||||||
|
|
||||||
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using
|
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
|
||||||
the master key (for owned collections). Per-file keys are decrypted with
|
master key (for owned collections). Per-file keys are decrypted with
|
||||||
`crypto_secretbox_open_easy` using the collection key. File metadata is a
|
`crypto_secretbox_open_easy` using the collection key. File metadata is a
|
||||||
secretbox under the file key. File content is a chunked
|
secretbox under the file key. File content is a chunked
|
||||||
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a
|
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a 4 MiB
|
||||||
4 MiB plaintext chunk size and a 17-byte authentication overhead per chunk.
|
plaintext chunk size and a 17-byte authentication overhead per chunk.
|
||||||
|
|
||||||
### HTTP API
|
### HTTP API
|
||||||
|
|
||||||
@@ -121,102 +132,463 @@ Production endpoints:
|
|||||||
- Thumbnail CDN: `https://thumbnails.ente.io/?fileID=<id>`
|
- Thumbnail CDN: `https://thumbnails.ente.io/?fileID=<id>`
|
||||||
|
|
||||||
A custom API endpoint is configurable for self-hosted servers via the
|
A custom API endpoint is configurable for self-hosted servers via the
|
||||||
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route
|
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route through
|
||||||
through `<endpoint>/files/download/<id>` instead of the dedicated CDN host.
|
`<endpoint>/files/download/<id>` instead of the dedicated CDN host.
|
||||||
|
|
||||||
Required request headers on every authenticated call:
|
Required request headers on every authenticated call:
|
||||||
|
|
||||||
- `X-Auth-Token`: the decrypted auth token from login.
|
- `X-Auth-Token`: the decrypted auth token from login.
|
||||||
- `X-Client-Package`: identifies the client. quack uses
|
- `X-Client-Package`: identifies the client. quack uses `berlin.sneak.quack`.
|
||||||
`berlin.sneak.quack`.
|
|
||||||
|
|
||||||
Endpoints used:
|
Endpoints used:
|
||||||
|
|
||||||
- `GET /users/srp/attributes?email=<email>` — fetch SRP + KDF parameters.
|
- `GET /users/srp/attributes?email=<email>`: fetch SRP and KDF parameters.
|
||||||
- `POST /users/srp/create-session` — begin SRP handshake.
|
- `POST /users/srp/create-session`: begin SRP handshake.
|
||||||
- `POST /users/srp/verify-session` — complete SRP, receive 2FA challenge or
|
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
|
||||||
the encrypted token + key attributes.
|
encrypted token plus key attributes.
|
||||||
- `POST /users/ott` and `POST /users/verify-email` — email OTP fallback path.
|
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
||||||
- `POST /users/two-factor/verify` — TOTP second factor.
|
- `POST /users/two-factor/verify`: TOTP second factor.
|
||||||
- `GET /collections/v2?sinceTime=<usec>` — list collections changed since
|
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
||||||
microsecond timestamp; pass 0 for a full enumeration.
|
microsecond timestamp; pass 0 for a full enumeration.
|
||||||
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>` — list
|
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
||||||
files in a collection; paginate while `hasMore` is true.
|
collection; paginate while `hasMore` is true.
|
||||||
- `GET https://files.ente.io/?fileID=<id>` — download encrypted file bytes.
|
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
|
||||||
|
|
||||||
### Session persistence
|
### Session persistence
|
||||||
|
|
||||||
After login, quack writes an encrypted session blob to
|
After login, quack writes an encrypted session blob to
|
||||||
`$XDG_CONFIG_HOME/quack/session.json` (default
|
`$XDG_CONFIG_HOME/quack/session.json` (default `~/.config/quack/session.json`)
|
||||||
`~/.config/quack/session.json`) containing the auth token, the user's master
|
containing the auth token, the user's master key, the user's secret key, and the
|
||||||
key, the user's secret key, and the user's email. The session file is itself
|
user's email. The session file is itself encrypted with a key derived from a
|
||||||
encrypted with a key derived from a per-machine random value stored in the OS
|
per-machine random value stored in the OS keychain when available, falling back
|
||||||
keychain when available, falling back to a key file at mode `0600` in the
|
to a key file at mode `0600` in the same config directory. The master key and
|
||||||
same config directory. The master key and secret key are never written to
|
secret key are never written to disk in cleartext.
|
||||||
disk in cleartext.
|
|
||||||
|
|
||||||
### CLI surface
|
### CLI surface
|
||||||
|
|
||||||
- `quack login` — interactive login, writes session.
|
- `quack login`: interactive login, writes session.
|
||||||
- `quack logout` — deletes the session.
|
- `quack logout`: deletes the session.
|
||||||
- `quack whoami` — prints the logged-in email.
|
- `quack whoami`: prints the logged-in email.
|
||||||
- `quack collections` — list collections (id, name, type, file count).
|
- `quack collections`: list collections (id, name, type, file count).
|
||||||
- `quack files --collection <id>` — list files in a collection (id, name,
|
- `quack files --collection <id>`: list files in a collection (id, name, type,
|
||||||
type, creation time, size).
|
creation time, size).
|
||||||
- `quack get <fileID> --out <dir>` — download and decrypt a file.
|
- `quack get <fileID> --out <dir>`: download and decrypt a file.
|
||||||
- `quack get-thumb <fileID> --out <dir>` — download and decrypt a
|
- `quack get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
|
||||||
thumbnail.
|
|
||||||
|
|
||||||
All commands accept `--json` for machine-readable output.
|
All commands accept `--json` for machine-readable output.
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
The complete public surface of the library, expressed as TypeScript
|
||||||
|
declarations. Every exported name is listed here. Anything not listed is
|
||||||
|
internal.
|
||||||
|
|
||||||
|
### Type aliases
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/model/types.ts
|
||||||
|
export type Bytes = Uint8Array;
|
||||||
|
export type Base64 = string; // standard base64 unless noted
|
||||||
|
export type Base64URL = string; // URL-safe base64
|
||||||
|
export type Microseconds = number; // unix epoch microseconds (int64-ish)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crypto module
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/crypto/index.ts
|
||||||
|
|
||||||
|
// Lazily initializes libsodium. Safe to call repeatedly; the first call
|
||||||
|
// performs the init, subsequent calls are no-ops.
|
||||||
|
export function init(): Promise<void>;
|
||||||
|
|
||||||
|
// Argon2id over a UTF-8 password and a 16-byte salt, producing a 32-byte
|
||||||
|
// key. memLimit is in bytes, opsLimit is the iteration count, both as
|
||||||
|
// returned by the server in SRP / key attributes.
|
||||||
|
export function deriveKEK(
|
||||||
|
password: string,
|
||||||
|
salt: Bytes,
|
||||||
|
opsLimit: number,
|
||||||
|
memLimit: number,
|
||||||
|
): Promise<Bytes>;
|
||||||
|
|
||||||
|
// crypto_kdf_derive_from_key with subkey id 1 and context "loginctx",
|
||||||
|
// returning the first 16 bytes. Used as the SRP password.
|
||||||
|
export function deriveLoginSubkey(kek: Bytes): Bytes;
|
||||||
|
|
||||||
|
// crypto_secretbox_open_easy. Returns plaintext or throws on auth failure.
|
||||||
|
export function decryptBox(ciphertext: Bytes, nonce: Bytes, key: Bytes): Bytes;
|
||||||
|
|
||||||
|
// crypto_box_seal_open. Used to recover the auth token after login.
|
||||||
|
export function decryptSealed(
|
||||||
|
ciphertext: Bytes,
|
||||||
|
publicKey: Bytes,
|
||||||
|
secretKey: Bytes,
|
||||||
|
): Bytes;
|
||||||
|
|
||||||
|
// crypto_secretstream_xchacha20poly1305_init_pull. Returned state is opaque
|
||||||
|
// and threaded through pullStreamChunk.
|
||||||
|
export function initStreamPull(header: Bytes, key: Bytes): StreamPullState;
|
||||||
|
|
||||||
|
// crypto_secretstream_xchacha20poly1305_pull. Tag values follow libsodium's
|
||||||
|
// constants: 0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL.
|
||||||
|
export function pullStreamChunk(
|
||||||
|
state: StreamPullState,
|
||||||
|
ciphertext: Bytes,
|
||||||
|
): { plaintext: Bytes; tag: number };
|
||||||
|
|
||||||
|
// Convenience helpers. fromBase64 accepts both standard and URL-safe.
|
||||||
|
export function fromBase64(s: Base64 | Base64URL): Bytes;
|
||||||
|
export function toBase64(b: Bytes): Base64;
|
||||||
|
export function toBase64URL(b: Bytes): Base64URL;
|
||||||
|
|
||||||
|
// Plaintext chunk size used by Ente for file content streams.
|
||||||
|
export const STREAM_CHUNK_SIZE: number; // 4 * 1024 * 1024
|
||||||
|
|
||||||
|
// Encrypted-chunk overhead: secretstream auth tag (16) + tag byte (1).
|
||||||
|
export const STREAM_CHUNK_OVERHEAD: number; // 17
|
||||||
|
|
||||||
|
export interface StreamPullState {
|
||||||
|
/* opaque */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth module
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/auth/types.ts
|
||||||
|
|
||||||
|
export interface KDFParams {
|
||||||
|
kekSalt: Base64;
|
||||||
|
memLimit: number;
|
||||||
|
opsLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyAttributes {
|
||||||
|
kekSalt: Base64;
|
||||||
|
encryptedKey: Base64;
|
||||||
|
keyDecryptionNonce: Base64;
|
||||||
|
publicKey: Base64;
|
||||||
|
encryptedSecretKey: Base64;
|
||||||
|
secretKeyDecryptionNonce: Base64;
|
||||||
|
memLimit: number;
|
||||||
|
opsLimit: number;
|
||||||
|
masterKeyEncryptedWithRecoveryKey?: Base64;
|
||||||
|
masterKeyDecryptionNonce?: Base64;
|
||||||
|
recoveryKeyEncryptedWithMasterKey?: Base64;
|
||||||
|
recoveryKeyDecryptionNonce?: Base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SRPAttributes {
|
||||||
|
srpUserID: string;
|
||||||
|
srpSalt: Base64;
|
||||||
|
memLimit: number;
|
||||||
|
opsLimit: number;
|
||||||
|
kekSalt: Base64;
|
||||||
|
isEmailMFAEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationResponse {
|
||||||
|
id: number; // user ID
|
||||||
|
keyAttributes?: KeyAttributes;
|
||||||
|
encryptedToken?: Base64URL; // sealed-box-encrypted to user's pubkey
|
||||||
|
twoFactorSessionID?: string;
|
||||||
|
passkeySessionID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginChallenge =
|
||||||
|
| { kind: "complete"; response: AuthorizationResponse }
|
||||||
|
| { kind: "totp"; sessionID: string }
|
||||||
|
| { kind: "passkey"; sessionID: string }
|
||||||
|
| { kind: "emailOTP" };
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/auth/index.ts
|
||||||
|
|
||||||
|
// Begin login. Returns a challenge that tells the caller what to do next:
|
||||||
|
// supply a TOTP code, supply an email OTP, follow a passkey URL, or stop
|
||||||
|
// because login is already complete.
|
||||||
|
export function beginLogin(
|
||||||
|
api: ApiClient,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<LoginChallenge>;
|
||||||
|
|
||||||
|
// Submit a TOTP code from an authenticator app. Returns the final
|
||||||
|
// AuthorizationResponse on success.
|
||||||
|
export function submitTOTP(
|
||||||
|
api: ApiClient,
|
||||||
|
sessionID: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<AuthorizationResponse>;
|
||||||
|
|
||||||
|
// Request and submit an email-delivered one-time code. Two calls, because
|
||||||
|
// the first triggers email delivery and the second verifies it.
|
||||||
|
export function requestEmailOTP(api: ApiClient, email: string): Promise<void>;
|
||||||
|
export function submitEmailOTP(
|
||||||
|
api: ApiClient,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<AuthorizationResponse>;
|
||||||
|
|
||||||
|
// Given an AuthorizationResponse and the user's password, decrypt the master
|
||||||
|
// key, secret key, and auth token. Throws on bad password or tampered data.
|
||||||
|
export function unwrapAuth(
|
||||||
|
response: AuthorizationResponse,
|
||||||
|
password: string,
|
||||||
|
): Promise<{
|
||||||
|
masterKey: Bytes;
|
||||||
|
secretKey: Bytes;
|
||||||
|
publicKey: Bytes;
|
||||||
|
token: string; // base64 URL-safe; goes into X-Auth-Token
|
||||||
|
}>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model module
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/model/index.ts
|
||||||
|
|
||||||
|
export type CollectionType =
|
||||||
|
| "album"
|
||||||
|
| "folder"
|
||||||
|
| "favorites"
|
||||||
|
| "uncategorized"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export interface Collection {
|
||||||
|
id: number;
|
||||||
|
ownerID: number;
|
||||||
|
key: Bytes; // decrypted
|
||||||
|
name: string; // decrypted
|
||||||
|
type: CollectionType;
|
||||||
|
updationTime: Microseconds;
|
||||||
|
isShared: boolean; // true if owner != current user
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileType = "image" | "video" | "livePhoto" | "unknown";
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
title: string;
|
||||||
|
fileType: FileType;
|
||||||
|
creationTime: Microseconds;
|
||||||
|
modificationTime: Microseconds;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
hash?: string; // base64 of file SHA256
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileBlob {
|
||||||
|
decryptionHeader: Base64;
|
||||||
|
size?: number; // size of the encrypted body, if known from server
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnteFile {
|
||||||
|
id: number;
|
||||||
|
collectionID: number;
|
||||||
|
ownerID: number;
|
||||||
|
key: Bytes; // decrypted file key
|
||||||
|
metadata: FileMetadata;
|
||||||
|
file: FileBlob;
|
||||||
|
thumbnail: FileBlob;
|
||||||
|
updationTime: Microseconds;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP client
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/api/client.ts
|
||||||
|
|
||||||
|
export interface ApiClientOptions {
|
||||||
|
apiOrigin?: string; // default https://api.ente.io
|
||||||
|
filesOrigin?: string; // default https://files.ente.io
|
||||||
|
thumbsOrigin?: string; // default https://thumbnails.ente.io
|
||||||
|
authToken?: string;
|
||||||
|
fetch?: typeof fetch; // injectable for tests
|
||||||
|
userAgent?: string; // default "quack/<version>"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly requestID?: string;
|
||||||
|
readonly body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
constructor(opts?: ApiClientOptions);
|
||||||
|
setAuthToken(token: string): void;
|
||||||
|
clearAuthToken(): void;
|
||||||
|
|
||||||
|
getJSON<T>(
|
||||||
|
path: string,
|
||||||
|
query?: Record<string, string | number | undefined>,
|
||||||
|
): Promise<T>;
|
||||||
|
postJSON<T>(path: string, body: unknown): Promise<T>;
|
||||||
|
|
||||||
|
// Streaming download from the file CDN. Caller is responsible for
|
||||||
|
// consuming the stream.
|
||||||
|
getFileStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
|
||||||
|
getThumbnailStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/session/index.ts
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
email: string;
|
||||||
|
userID: number;
|
||||||
|
token: string; // base64 URL-safe
|
||||||
|
masterKey: Bytes; // 32 bytes, never serialized in cleartext
|
||||||
|
secretKey: Bytes; // 32 bytes, never serialized in cleartext
|
||||||
|
publicKey: Bytes; // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStoreOptions {
|
||||||
|
path?: string; // default $XDG_CONFIG_HOME/quack/session.json
|
||||||
|
keychainService?: string; // default "berlin.sneak.quack"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionStore {
|
||||||
|
constructor(opts?: SessionStoreOptions);
|
||||||
|
load(): Promise<Session | null>;
|
||||||
|
save(s: Session): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/client.ts
|
||||||
|
|
||||||
|
export interface LoginPrompt {
|
||||||
|
password: () => Promise<string>;
|
||||||
|
emailOTP?: () => Promise<string>; // called when account uses email OTP
|
||||||
|
totp?: () => Promise<string>; // called when TOTP is required
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientOptions extends ApiClientOptions {
|
||||||
|
sessionStore?: SessionStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadResult {
|
||||||
|
path: string;
|
||||||
|
bytesWritten: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Client {
|
||||||
|
// Static constructors. Both end with a Client that has a populated
|
||||||
|
// session and a ready ApiClient.
|
||||||
|
static login(
|
||||||
|
email: string,
|
||||||
|
prompt: LoginPrompt,
|
||||||
|
opts?: ClientOptions,
|
||||||
|
): Promise<Client>;
|
||||||
|
static fromSavedSession(opts?: ClientOptions): Promise<Client>;
|
||||||
|
|
||||||
|
readonly api: ApiClient;
|
||||||
|
readonly session: Readonly<Session>;
|
||||||
|
|
||||||
|
// Account.
|
||||||
|
whoami(): { email: string; userID: number };
|
||||||
|
saveSession(): Promise<void>;
|
||||||
|
logout(): Promise<void>; // clears session on disk and in memory
|
||||||
|
|
||||||
|
// Collections.
|
||||||
|
listCollections(opts?: { sinceTime?: Microseconds }): Promise<Collection[]>;
|
||||||
|
getCollection(id: number): Promise<Collection>;
|
||||||
|
|
||||||
|
// Files.
|
||||||
|
listFiles(
|
||||||
|
collectionID: number,
|
||||||
|
opts?: { sinceTime?: Microseconds },
|
||||||
|
): Promise<EnteFile[]>;
|
||||||
|
getFile(fileID: number): Promise<EnteFile>;
|
||||||
|
|
||||||
|
// Downloads. If outPath is omitted, a path is constructed from the
|
||||||
|
// decrypted metadata title in the current working directory. If outPath
|
||||||
|
// is a directory, the filename is taken from the metadata title. If
|
||||||
|
// outPath is a file path, that file is written.
|
||||||
|
downloadFile(
|
||||||
|
file: EnteFile | number,
|
||||||
|
outPath?: string,
|
||||||
|
): Promise<DownloadResult>;
|
||||||
|
downloadThumbnail(
|
||||||
|
file: EnteFile | number,
|
||||||
|
outPath?: string,
|
||||||
|
): Promise<DownloadResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public exports (`src/index.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { Client } from "./client";
|
||||||
|
export { ApiClient, ApiError } from "./api/client";
|
||||||
|
export { SessionStore } from "./session";
|
||||||
|
export * from "./model";
|
||||||
|
export type {
|
||||||
|
Session,
|
||||||
|
LoginPrompt,
|
||||||
|
ClientOptions,
|
||||||
|
DownloadResult,
|
||||||
|
} from "./client";
|
||||||
|
```
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
Phase 1: scaffolding (this commit and the next)
|
Phase 1: scaffolding
|
||||||
|
|
||||||
- [x] `git init`, write README
|
- [x] `git init`, write README
|
||||||
- [ ] Create `initial-scaffolding` feature branch
|
- [x] Create `initial-scaffolding` feature branch
|
||||||
- [ ] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`,
|
- [x] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`, `.editorconfig`,
|
||||||
`.editorconfig`, `.prettierrc`, `.prettierignore`, `.dockerignore`
|
`.prettierrc`, `.prettierignore`, `.dockerignore`
|
||||||
- [ ] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`,
|
- [x] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
||||||
`docker`, `hooks`, plus `build`, `dev`, `clean`
|
`hooks`, plus `build`, `dev`, `clean`
|
||||||
- [ ] Add `Dockerfile` running `make check` against pinned node image
|
- [x] Add `Dockerfile` running `make check` against pinned node image
|
||||||
- [ ] Add `.gitea/workflows/check.yml` running `docker build .`
|
- [x] Add `.gitea/workflows/check.yml` running `docker build .`
|
||||||
- [ ] Add `package.json`, `tsconfig.json`, install pinned versions of
|
- [x] Add `package.json`, `tsconfig.json`, pinned dev versions of `typescript`,
|
||||||
`typescript`, `libsodium-wrappers-sumo`, `secure-remote-password`,
|
`prettier`, `eslint`, `typescript-eslint`, `vitest`, `@types/node` (the
|
||||||
`commander`, `vitest`, `prettier`, `eslint`, `@types/node`
|
runtime deps `libsodium-wrappers-sumo`, `secure-remote-password`,
|
||||||
- [ ] Smoke test: `make check` and `make docker` both pass
|
`commander`, etc. land with their respective implementation phases)
|
||||||
|
- [x] Smoke test: `make check` and `make docker` both pass
|
||||||
|
|
||||||
Phase 2: crypto primitives
|
Phase 2: crypto primitives
|
||||||
|
|
||||||
- [ ] Wrap libsodium init as an awaitable singleton
|
- [ ] Wrap libsodium init as an awaitable singleton
|
||||||
- [ ] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
|
- [ ] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
|
||||||
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`,
|
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`, 16
|
||||||
16 bytes)
|
bytes)
|
||||||
- [ ] `decryptBox(ciphertext, nonce, key)` for secretbox
|
- [ ] `decryptBox(ciphertext, nonce, key)` for secretbox
|
||||||
- [ ] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
|
- [ ] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
|
||||||
- [ ] `decryptStream(reader, header, key, writer)` for chunked secretstream
|
- [ ] `initStreamPull` and `pullStreamChunk` for chunked secretstream (4 MiB
|
||||||
(4 MiB plaintext chunks, 17-byte overhead)
|
plaintext chunks, 17-byte overhead)
|
||||||
- [ ] Round-trip tests against vectors generated by libsodium directly
|
- [ ] Round-trip tests against vectors generated by libsodium directly
|
||||||
|
|
||||||
Phase 3: SRP + auth
|
Phase 3: SRP + auth
|
||||||
|
|
||||||
- [ ] SRP-6a client using `secure-remote-password` with the same group as
|
- [ ] SRP-6a client using `secure-remote-password` with the same group as the
|
||||||
the server
|
server
|
||||||
- [ ] `loginViaSRP(email, password)` returning either a 2FA challenge or
|
- [ ] `beginLogin(email, password)` returning a `LoginChallenge`
|
||||||
`KeyAttributes + encryptedToken`
|
- [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
|
||||||
- [ ] `loginViaEmailOTP(email)` for accounts without SRP enabled
|
|
||||||
- [ ] `submitTOTP(sessionID, code)`
|
- [ ] `submitTOTP(sessionID, code)`
|
||||||
- [ ] `unwrapMasterKey(keyAttributes, password)` returning master key,
|
- [ ] `unwrapAuth(response, password)` returning master key, secret key, public
|
||||||
secret key, public key, and decrypted token
|
key, and decrypted token
|
||||||
- [ ] Tests against recorded HTTP fixtures
|
- [ ] Tests against recorded HTTP fixtures
|
||||||
|
|
||||||
Phase 4: HTTP client + endpoints
|
Phase 4: HTTP client + endpoints
|
||||||
|
|
||||||
- [ ] Tiny fetch wrapper that attaches `X-Auth-Token` and `X-Client-Package`
|
- [ ] `ApiClient` that attaches `X-Auth-Token` and `X-Client-Package`
|
||||||
- [ ] Typed wrappers for the endpoints listed above
|
- [ ] Typed wrappers for the endpoints listed above
|
||||||
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
errors
|
errors
|
||||||
- [ ] Error type that surfaces the server's error code and request id
|
- [ ] `ApiError` that surfaces the server's error code and request id
|
||||||
|
|
||||||
Phase 5: collections and files
|
Phase 5: collections and files
|
||||||
|
|
||||||
@@ -229,18 +601,16 @@ Phase 5: collections and files
|
|||||||
|
|
||||||
Phase 6: download
|
Phase 6: download
|
||||||
|
|
||||||
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts
|
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts it
|
||||||
it chunk by chunk, writes plaintext to `outPath`. Resolves the
|
chunk by chunk, writes plaintext to `outPath`. Resolves the filename from
|
||||||
filename from the decrypted metadata title when no `outPath` is
|
the decrypted metadata title when no `outPath` is supplied.
|
||||||
supplied.
|
|
||||||
- [ ] `downloadThumbnail(fileID, outPath)` for the thumbnail CDN
|
- [ ] `downloadThumbnail(fileID, outPath)` for the thumbnail CDN
|
||||||
- [ ] Live integration test against a throwaway Ente account if one is
|
- [ ] Live integration test against a throwaway Ente account if one is available
|
||||||
available
|
|
||||||
|
|
||||||
Phase 7: session persistence
|
Phase 7: session persistence
|
||||||
|
|
||||||
- [ ] Write session blob encrypted with a key from the OS keychain
|
- [ ] `SessionStore` writing an encrypted session blob with a key from the OS
|
||||||
(`keytar`) or a `0600` keyfile fallback
|
keychain (`keytar`) or a `0600` keyfile fallback
|
||||||
- [ ] `Client.fromSavedSession()` and `Client.saveSession()`
|
- [ ] `Client.fromSavedSession()` and `Client.saveSession()`
|
||||||
- [ ] `quack logout` deletes the session and the keychain entry
|
- [ ] `quack logout` deletes the session and the keychain entry
|
||||||
|
|
||||||
@@ -248,8 +618,7 @@ Phase 8: CLI
|
|||||||
|
|
||||||
- [ ] `commander`-based CLI that matches the surface in the Design section
|
- [ ] `commander`-based CLI that matches the surface in the Design section
|
||||||
- [ ] `--json` output for every command
|
- [ ] `--json` output for every command
|
||||||
- [ ] Reasonable progress output for long downloads (only when stdout is a
|
- [ ] Reasonable progress output for long downloads (only when stdout is a TTY)
|
||||||
TTY)
|
|
||||||
|
|
||||||
Phase 9: docs and 1.0
|
Phase 9: docs and 1.0
|
||||||
|
|
||||||
@@ -257,15 +626,23 @@ Phase 9: docs and 1.0
|
|||||||
- [ ] All TODO items above checked
|
- [ ] All TODO items above checked
|
||||||
- [ ] Tag `v1.0.0`
|
- [ ] Tag `v1.0.0`
|
||||||
|
|
||||||
|
Phase 10 and beyond: desktop client (separate repo)
|
||||||
|
|
||||||
|
- [ ] Spike Electron app skeleton consuming this library
|
||||||
|
- [ ] Local cache (SQLite) keyed on `(collectionID, fileID, updationTime)`
|
||||||
|
- [ ] Background sync worker that streams new files into the cache
|
||||||
|
- [ ] Read-only gallery UI: thumbnails, full-image view, basic search
|
||||||
|
- [ ] Add upload, delete, and share back into the library before the desktop UI
|
||||||
|
exposes them
|
||||||
|
|
||||||
## Source attribution
|
## Source attribution
|
||||||
|
|
||||||
The cryptographic protocol and wire format implemented here are Ente's, taken
|
The cryptographic protocol and wire format implemented here are Ente's, taken
|
||||||
from the Ente open source clients at
|
from the Ente open source clients at <https://github.com/ente-io/ente>. No code
|
||||||
<https://github.com/ente-io/ente>. No code is imported or vendored from those
|
is imported or vendored from those projects; any reference code that is copied
|
||||||
projects; any reference code that is copied is rewritten in TypeScript in
|
is rewritten in TypeScript in this repository. Protocol fidelity is verified
|
||||||
this repository. Protocol fidelity is verified against the upstream
|
against the upstream implementations in `web/packages/base/`,
|
||||||
implementations in `web/packages/base/`, `mobile/apps/photos/lib/`, and
|
`mobile/apps/photos/lib/`, and `cli/`.
|
||||||
`cli/`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
358
REPO_POLICIES.md
Normal file
358
REPO_POLICIES.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
---
|
||||||
|
title: Repository Policies
|
||||||
|
last_modified: 2026-03-18
|
||||||
|
---
|
||||||
|
|
||||||
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
|
style conventions are in separate documents:
|
||||||
|
|
||||||
|
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||||
|
(general, bash, Docker)
|
||||||
|
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||||
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Cross-project documentation (such as this file) must include
|
||||||
|
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||||
|
with the authoritative source as policies evolve.
|
||||||
|
|
||||||
|
- **ALL external references must be pinned by cryptographic hash.** This
|
||||||
|
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||||
|
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||||
|
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||||
|
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||||
|
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||||
|
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||||
|
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||||
|
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||||
|
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||||
|
install scripts are arbitrary remote code execution. This is the single most
|
||||||
|
important rule in this document. Double-check every external reference in
|
||||||
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
|
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||||
|
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||||
|
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
|
source of truth for how these operations are run.
|
||||||
|
|
||||||
|
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||||
|
the required targets above, it should have targets for every common operation:
|
||||||
|
running a local development server (`make run`, `make dev`), re-initializing
|
||||||
|
or migrating the database (`make db-reset`, `make migrate`), building
|
||||||
|
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||||
|
developer would do regularly. If someone checks out the repo and types
|
||||||
|
`make<tab>`, they should see every meaningful operation available. A new
|
||||||
|
contributor should be able to understand the entire development workflow by
|
||||||
|
reading the Makefile.
|
||||||
|
|
||||||
|
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||||
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
|
stage before the final image is assembled.
|
||||||
|
|
||||||
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||||
|
repos use a multistage build where linting runs in an independent stage based
|
||||||
|
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||||
|
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||||
|
then declares an explicit dependency on the lint stage via
|
||||||
|
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||||
|
linting before proceeding to compilation and tests. This ensures lint failures
|
||||||
|
surface in seconds rather than minutes, without blocking on dependency
|
||||||
|
download or compilation in the build stage.
|
||||||
|
|
||||||
|
The standard pattern for a Go repo Dockerfile is:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine@sha256:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||||
|
includes both Go and the linter), so there is no need to install the
|
||||||
|
linter separately.
|
||||||
|
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||||
|
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||||
|
this line, the build stage would not wait for lint to finish and a lint
|
||||||
|
failure might not fail the overall build.
|
||||||
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
|
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||||
|
create placeholder files so the embed directives resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
The lint stage should not depend on the actual build output — it exists to
|
||||||
|
fail fast.
|
||||||
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
|
`vips-dev`), install them in the lint stage with `apk add`.
|
||||||
|
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||||
|
build stage, not the lint stage, because they may require compiled
|
||||||
|
artifacts or heavier dependencies.
|
||||||
|
|
||||||
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
|
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
||||||
|
a successful build implies all checks pass.
|
||||||
|
|
||||||
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
|
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||||
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
|
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
||||||
|
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
||||||
|
target to install the pre-commit hook.
|
||||||
|
|
||||||
|
- All repos with software must have tests that run via the platform-standard
|
||||||
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
|
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||||
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
|
`make test` to be a no-op.
|
||||||
|
|
||||||
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
|
Makefile.
|
||||||
|
|
||||||
|
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||||
|
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||||
|
show full output. This keeps CI logs and `docker build` output clean on
|
||||||
|
success (just package/suite summaries) while providing full diagnostic detail
|
||||||
|
on failure (every test case, every assertion). The general shell pattern:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -timeout 30s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 30s -race -v ./...; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||||
|
already proved the tests are broken, so the build must not pass even if a
|
||||||
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
|
for diagnostic output.
|
||||||
|
|
||||||
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
|
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||||
|
directories.
|
||||||
|
|
||||||
|
- `main` must always pass `make check`, no exceptions.
|
||||||
|
|
||||||
|
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||||
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
|
Fetch the standard `.gitignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
|
a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
|
- Never force-push to `main`.
|
||||||
|
|
||||||
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
|
feature branch.
|
||||||
|
|
||||||
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
|
manually by the user. Fetch from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||||
|
|
||||||
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
|
with the version and date (YYYY-MM-DD).
|
||||||
|
|
||||||
|
- Use `yarn`, not `npm`.
|
||||||
|
|
||||||
|
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||||
|
|
||||||
|
- Simple projects should be configured with environment variables.
|
||||||
|
|
||||||
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
|
- `README.md` is the primary documentation. Required sections:
|
||||||
|
- **Description**: First line must include the project name, purpose,
|
||||||
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Rationale**: Why does this exist?
|
||||||
|
- **Design**: How is the program structured?
|
||||||
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
|
the todo list in the README so a new agent can pick up where the last one
|
||||||
|
left off.
|
||||||
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||||
|
`LICENSE` file in the repo root and a License section in the README.
|
||||||
|
- **Author**: [@sneak](https://sneak.berlin).
|
||||||
|
|
||||||
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
|
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||||
|
committing.
|
||||||
|
|
||||||
|
- Use SemVer.
|
||||||
|
|
||||||
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
|
the binary.
|
||||||
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
|
settings.
|
||||||
|
|
||||||
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
|
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||||
|
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||||
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
|
subdirectory names:
|
||||||
|
- `bin/` — executable scripts and tools
|
||||||
|
- `cmd/` — Go command entrypoints
|
||||||
|
- `configs/` — configuration templates and examples
|
||||||
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
|
- `internal/` — Go internal packages
|
||||||
|
- `internal/db/migrations/` — database migrations
|
||||||
|
- `pkg/` — Go library packages
|
||||||
|
- `share/` — systemd units, data files
|
||||||
|
- `static/` — static assets (images, fonts, etc.)
|
||||||
|
- `web/` — web frontend source
|
||||||
|
|
||||||
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||||
|
templates. Fetch them from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||||
|
|
||||||
|
- New repos must contain at minimum:
|
||||||
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
|
- `Makefile`
|
||||||
|
- `Dockerfile`, `.dockerignore`
|
||||||
|
- `.gitea/workflows/check.yml`
|
||||||
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
|
- Python: `pyproject.toml`
|
||||||
10
eslint.config.mjs
Normal file
10
eslint.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "node_modules/", "coverage/", ".vitest-cache/"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
);
|
||||||
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "quack",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "TypeScript client library and CLI for the Ente end-to-end encrypted photo service",
|
||||||
|
"license": "WTFPL",
|
||||||
|
"author": "@sneak <https://sneak.berlin>",
|
||||||
|
"homepage": "https://git.eeqj.de/sneak/quack",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.eeqj.de/sneak/quack.git"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"quack": "./dist/bin/quack.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"fmt": "prettier --write .",
|
||||||
|
"fmt-check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.38.0",
|
||||||
|
"@types/node": "22.18.13",
|
||||||
|
"eslint": "9.38.0",
|
||||||
|
"prettier": "3.8.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.46.2",
|
||||||
|
"vitest": "2.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/index.ts
Normal file
1
src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const VERSION = "0.0.0";
|
||||||
9
test/smoke.test.ts
Normal file
9
test/smoke.test.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { VERSION } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("quack", () => {
|
||||||
|
it("exports a version string", () => {
|
||||||
|
expect(typeof VERSION).toBe("string");
|
||||||
|
expect(VERSION.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "bin/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user