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
|
||||
|
||||
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
|
||||
high quality but each is bound up with a UI or with sync semantics that aren't
|
||||
useful for scripted access. Pulling individual photos out of an Ente account
|
||||
from a script (for backup, migration, archival, or programmatic processing) is
|
||||
awkward without a small TypeScript library that does just the cryptography and
|
||||
nothing else.
|
||||
story. The shipping clients (mobile Flutter, web React, desktop Electron, and Go
|
||||
CLI) work, but they are slow, buggy, and difficult to script against. The
|
||||
Flutter app fails to sync reliably. The web app is heavy. The desktop app is the
|
||||
web app inside a slow Electron wrapper. The Go CLI is the closest thing to a
|
||||
usable tool, but it is awkward to integrate from anything that is not a shell.
|
||||
|
||||
This project exists to provide that library. It is deliberately scoped to read
|
||||
operations: log in, walk the account, decrypt and save files. Upload, sharing,
|
||||
deletion, and sync state management are out of scope for the first release.
|
||||
quack is the first step in fixing that. This repo ships a small, correct,
|
||||
well-tested implementation of Ente's cryptographic protocol and its read-only
|
||||
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
|
||||
|
||||
@@ -85,17 +96,17 @@ quack/
|
||||
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
||||
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.
|
||||
2. Argon2id (`crypto_pwhash`) over the password and a server-issued
|
||||
`kekSalt`, with server-issued `memLimit` and `opsLimit`, produces a
|
||||
32-byte Key Encryption Key (KEK).
|
||||
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
|
||||
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
|
||||
Encryption Key (KEK).
|
||||
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
|
||||
`loginctx`. That 16-byte value is the SRP password.
|
||||
4. After SRP completes (or after email-OTP fallback), the server returns a
|
||||
blob of "key attributes" plus an encrypted auth token.
|
||||
4. After SRP completes (or after email-OTP fallback), the server returns a blob
|
||||
of "key attributes" plus an encrypted auth token.
|
||||
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
||||
yields the 32-byte master key.
|
||||
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
|
||||
subsequent calls.
|
||||
|
||||
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using
|
||||
the master key (for owned collections). Per-file keys are decrypted with
|
||||
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
|
||||
master key (for owned collections). Per-file keys are decrypted with
|
||||
`crypto_secretbox_open_easy` using the collection key. File metadata is a
|
||||
secretbox under the file key. File content is a chunked
|
||||
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a
|
||||
4 MiB plaintext chunk size and a 17-byte authentication overhead per chunk.
|
||||
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a 4 MiB
|
||||
plaintext chunk size and a 17-byte authentication overhead per chunk.
|
||||
|
||||
### HTTP API
|
||||
|
||||
@@ -121,102 +132,463 @@ Production endpoints:
|
||||
- Thumbnail CDN: `https://thumbnails.ente.io/?fileID=<id>`
|
||||
|
||||
A custom API endpoint is configurable for self-hosted servers via the
|
||||
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route
|
||||
through `<endpoint>/files/download/<id>` instead of the dedicated CDN host.
|
||||
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route through
|
||||
`<endpoint>/files/download/<id>` instead of the dedicated CDN host.
|
||||
|
||||
Required request headers on every authenticated call:
|
||||
|
||||
- `X-Auth-Token`: the decrypted auth token from login.
|
||||
- `X-Client-Package`: identifies the client. quack uses
|
||||
`berlin.sneak.quack`.
|
||||
- `X-Client-Package`: identifies the client. quack uses `berlin.sneak.quack`.
|
||||
|
||||
Endpoints used:
|
||||
|
||||
- `GET /users/srp/attributes?email=<email>` — fetch SRP + KDF parameters.
|
||||
- `POST /users/srp/create-session` — begin SRP handshake.
|
||||
- `POST /users/srp/verify-session` — complete SRP, receive 2FA challenge or
|
||||
the encrypted token + key attributes.
|
||||
- `POST /users/ott` and `POST /users/verify-email` — email OTP fallback path.
|
||||
- `POST /users/two-factor/verify` — TOTP second factor.
|
||||
- `GET /collections/v2?sinceTime=<usec>` — list collections changed since
|
||||
- `GET /users/srp/attributes?email=<email>`: fetch SRP and KDF parameters.
|
||||
- `POST /users/srp/create-session`: begin SRP handshake.
|
||||
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
|
||||
encrypted token plus key attributes.
|
||||
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
||||
- `POST /users/two-factor/verify`: TOTP second factor.
|
||||
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
||||
microsecond timestamp; pass 0 for a full enumeration.
|
||||
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>` — list
|
||||
files in a collection; paginate while `hasMore` is true.
|
||||
- `GET https://files.ente.io/?fileID=<id>` — download encrypted file bytes.
|
||||
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
||||
collection; paginate while `hasMore` is true.
|
||||
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
|
||||
|
||||
### Session persistence
|
||||
|
||||
After login, quack writes an encrypted session blob to
|
||||
`$XDG_CONFIG_HOME/quack/session.json` (default
|
||||
`~/.config/quack/session.json`) containing the auth token, the user's master
|
||||
key, the user's secret key, and the user's email. The session file is itself
|
||||
encrypted with a key derived from a per-machine random value stored in the OS
|
||||
keychain when available, falling back to a key file at mode `0600` in the
|
||||
same config directory. The master key and secret key are never written to
|
||||
disk in cleartext.
|
||||
`$XDG_CONFIG_HOME/quack/session.json` (default `~/.config/quack/session.json`)
|
||||
containing the auth token, the user's master key, the user's secret key, and the
|
||||
user's email. The session file is itself encrypted with a key derived from a
|
||||
per-machine random value stored in the OS keychain when available, falling back
|
||||
to a key file at mode `0600` in the same config directory. The master key and
|
||||
secret key are never written to disk in cleartext.
|
||||
|
||||
### CLI surface
|
||||
|
||||
- `quack login` — interactive login, writes session.
|
||||
- `quack logout` — deletes the session.
|
||||
- `quack whoami` — prints the logged-in email.
|
||||
- `quack collections` — list collections (id, name, type, file count).
|
||||
- `quack files --collection <id>` — list files in a collection (id, name,
|
||||
type, creation time, size).
|
||||
- `quack get <fileID> --out <dir>` — download and decrypt a file.
|
||||
- `quack get-thumb <fileID> --out <dir>` — download and decrypt a
|
||||
thumbnail.
|
||||
- `quack login`: interactive login, writes session.
|
||||
- `quack logout`: deletes the session.
|
||||
- `quack whoami`: prints the logged-in email.
|
||||
- `quack collections`: list collections (id, name, type, file count).
|
||||
- `quack files --collection <id>`: list files in a collection (id, name, type,
|
||||
creation time, size).
|
||||
- `quack get <fileID> --out <dir>`: download and decrypt a file.
|
||||
- `quack get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
|
||||
|
||||
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
|
||||
|
||||
Phase 1: scaffolding (this commit and the next)
|
||||
Phase 1: scaffolding
|
||||
|
||||
- [x] `git init`, write README
|
||||
- [ ] Create `initial-scaffolding` feature branch
|
||||
- [ ] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`,
|
||||
`.editorconfig`, `.prettierrc`, `.prettierignore`, `.dockerignore`
|
||||
- [ ] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`,
|
||||
`docker`, `hooks`, plus `build`, `dev`, `clean`
|
||||
- [ ] Add `Dockerfile` running `make check` against pinned node image
|
||||
- [ ] Add `.gitea/workflows/check.yml` running `docker build .`
|
||||
- [ ] Add `package.json`, `tsconfig.json`, install pinned versions of
|
||||
`typescript`, `libsodium-wrappers-sumo`, `secure-remote-password`,
|
||||
`commander`, `vitest`, `prettier`, `eslint`, `@types/node`
|
||||
- [ ] Smoke test: `make check` and `make docker` both pass
|
||||
- [x] Create `initial-scaffolding` feature branch
|
||||
- [x] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`, `.editorconfig`,
|
||||
`.prettierrc`, `.prettierignore`, `.dockerignore`
|
||||
- [x] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
||||
`hooks`, plus `build`, `dev`, `clean`
|
||||
- [x] Add `Dockerfile` running `make check` against pinned node image
|
||||
- [x] Add `.gitea/workflows/check.yml` running `docker build .`
|
||||
- [x] Add `package.json`, `tsconfig.json`, pinned dev versions of `typescript`,
|
||||
`prettier`, `eslint`, `typescript-eslint`, `vitest`, `@types/node` (the
|
||||
runtime deps `libsodium-wrappers-sumo`, `secure-remote-password`,
|
||||
`commander`, etc. land with their respective implementation phases)
|
||||
- [x] Smoke test: `make check` and `make docker` both pass
|
||||
|
||||
Phase 2: crypto primitives
|
||||
|
||||
- [ ] Wrap libsodium init as an awaitable singleton
|
||||
- [ ] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
|
||||
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`,
|
||||
16 bytes)
|
||||
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`, 16
|
||||
bytes)
|
||||
- [ ] `decryptBox(ciphertext, nonce, key)` for secretbox
|
||||
- [ ] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
|
||||
- [ ] `decryptStream(reader, header, key, writer)` for chunked secretstream
|
||||
(4 MiB plaintext chunks, 17-byte overhead)
|
||||
- [ ] `initStreamPull` and `pullStreamChunk` for chunked secretstream (4 MiB
|
||||
plaintext chunks, 17-byte overhead)
|
||||
- [ ] Round-trip tests against vectors generated by libsodium directly
|
||||
|
||||
Phase 3: SRP + auth
|
||||
|
||||
- [ ] SRP-6a client using `secure-remote-password` with the same group as
|
||||
the server
|
||||
- [ ] `loginViaSRP(email, password)` returning either a 2FA challenge or
|
||||
`KeyAttributes + encryptedToken`
|
||||
- [ ] `loginViaEmailOTP(email)` for accounts without SRP enabled
|
||||
- [ ] SRP-6a client using `secure-remote-password` with the same group as the
|
||||
server
|
||||
- [ ] `beginLogin(email, password)` returning a `LoginChallenge`
|
||||
- [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
|
||||
- [ ] `submitTOTP(sessionID, code)`
|
||||
- [ ] `unwrapMasterKey(keyAttributes, password)` returning master key,
|
||||
secret key, public key, and decrypted token
|
||||
- [ ] `unwrapAuth(response, password)` returning master key, secret key, public
|
||||
key, and decrypted token
|
||||
- [ ] Tests against recorded HTTP fixtures
|
||||
|
||||
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
|
||||
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||
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
|
||||
|
||||
@@ -229,18 +601,16 @@ Phase 5: collections and files
|
||||
|
||||
Phase 6: download
|
||||
|
||||
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts
|
||||
it chunk by chunk, writes plaintext to `outPath`. Resolves the
|
||||
filename from the decrypted metadata title when no `outPath` is
|
||||
supplied.
|
||||
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts it
|
||||
chunk by chunk, writes plaintext to `outPath`. Resolves the filename from
|
||||
the decrypted metadata title when no `outPath` is supplied.
|
||||
- [ ] `downloadThumbnail(fileID, outPath)` for the thumbnail CDN
|
||||
- [ ] Live integration test against a throwaway Ente account if one is
|
||||
available
|
||||
- [ ] Live integration test against a throwaway Ente account if one is available
|
||||
|
||||
Phase 7: session persistence
|
||||
|
||||
- [ ] Write session blob encrypted with a key from the OS keychain
|
||||
(`keytar`) or a `0600` keyfile fallback
|
||||
- [ ] `SessionStore` writing an encrypted session blob with a key from the OS
|
||||
keychain (`keytar`) or a `0600` keyfile fallback
|
||||
- [ ] `Client.fromSavedSession()` and `Client.saveSession()`
|
||||
- [ ] `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
|
||||
- [ ] `--json` output for every command
|
||||
- [ ] Reasonable progress output for long downloads (only when stdout is a
|
||||
TTY)
|
||||
- [ ] Reasonable progress output for long downloads (only when stdout is a TTY)
|
||||
|
||||
Phase 9: docs and 1.0
|
||||
|
||||
@@ -257,15 +626,23 @@ Phase 9: docs and 1.0
|
||||
- [ ] All TODO items above checked
|
||||
- [ ] 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
|
||||
|
||||
The cryptographic protocol and wire format implemented here are Ente's, taken
|
||||
from the Ente open source clients at
|
||||
<https://github.com/ente-io/ente>. No code is imported or vendored from those
|
||||
projects; any reference code that is copied is rewritten in TypeScript in
|
||||
this repository. Protocol fidelity is verified against the upstream
|
||||
implementations in `web/packages/base/`, `mobile/apps/photos/lib/`, and
|
||||
`cli/`.
|
||||
from the Ente open source clients at <https://github.com/ente-io/ente>. No code
|
||||
is imported or vendored from those projects; any reference code that is copied
|
||||
is rewritten in TypeScript in this repository. Protocol fidelity is verified
|
||||
against the upstream implementations in `web/packages/base/`,
|
||||
`mobile/apps/photos/lib/`, and `cli/`.
|
||||
|
||||
## 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