diff --git a/README.md b/README.md index afa6eab..882b59e 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ -# sabbath-school-api-docs \ No newline at end of file +# Sabbath School API documentation + +This repository contains an **unofficial, reverse-engineered** OpenAPI 3.0 +specification for the backend API consumed by the Sabbath School mobile and web +applications. + +The maintainers of Adventech do not currently publish an API spec; this file is +an attempt to give external developers, integrators, and future contributors a +starting point. + +## Source of truth + +The spec is generated by reading the typed Retrofit interfaces in the Android +client: + +- `common/auth/src/main/kotlin/app/ss/auth/api/SSAuthApi.kt` +- `common/auth/src/main/kotlin/app/ss/auth/api/SSTokenApi.kt` +- `libraries/lessons/api/src/main/kotlin/ss/lessons/api/ResourcesApi.kt` +- `libraries/lessons/api/src/main/kotlin/ss/lessons/api/SSLessonsApi.kt` +- `libraries/lessons/api/src/main/kotlin/ss/lessons/api/SSMediaApi.kt` +- `libraries/lessons/api/src/main/kotlin/ss/lessons/api/SSQuarterliesApi.kt` + +And reconciled with the iOS client at +`Adventech/sabbath-school-ios/dev/Sabbath School/API/API.swift`. + +The hosts (`https://sabbath-school.adventech.io`, +`https://sabbath-school-stage.adventech.io`) are taken from the `SSConstants.kt` +file in the Android client. + +## Caveats + +1. **This is not authoritative.** If the maintainers publish an official spec, + that supersedes this file. +2. **Most paths return the SPA fallback** at the time of writing. Only + `/api/v{2,3}/auth/*` is publicly callable. Data endpoints are reachable from + the mobile clients but not from arbitrary external callers. +3. **`auth/refresh` is currently broken** (HTTP 500). See + https://github.com/Adventech/sabbath-school-ios/issues/204. + +## How to use + +Validate the spec locally: + +```bash +# Using redocly CLI +npx @redocly/cli lint openapi.yaml + +# Using swagger-cli +npx swagger-cli validate openapi.yaml +``` + +Render with Redoc: + +```bash +npx @redocly/cli preview-docs openapi.yaml +``` + +## Contributing + +If you spot an endpoint that is not covered, or a schema that has drifted, +open a PR against `openapi.yaml` with the relevant Retrofit interface quoted +in the PR description. The maintainers are more likely to accept a doc PR +than a code PR for non-trivial behaviour, so keep diffs small and tied to a +specific source file. + +## Authorship + +Initial draft: 2026-08-01, by an external contributor reverse-engineering the +public Android and iOS source. No API keys, refresh tokens, or user data +were stored beyond ephemeral anonymous sign-in responses used to verify +endpoint shapes. \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..02ad2f5 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,608 @@ +openapi: 3.0.3 +info: + title: Sabbath School API + description: | + Backend API for the Sabbath School application family (iOS, Android, web) + operated by Adventech Ministry. This specification is reverse-engineered + from the public Android and iOS source code. It is **not** an authoritative + contract; treat it as documentation of the de-facto surface. + + The mobile clients hardcode the production and stage hosts (see + `Adventech/sabbath-school-android/common/misc/src/main/java/ss/misc/SSConstants.kt` + and `Adventech/sabbath-school-ios/dev/Sabbath School/Common/Configuration/Constants.swift`). + + **Auth model**: all endpoints require a Firebase auth access token in the + `x-ss-auth-access-token` header, except `POST /api/v{2,3}/auth/signin/anonymous`, + which mints one. Anonymous sign-in returns a Firebase `uid` and tokens. + + **Known gaps**: + - `POST /api/v{2,3}/auth/refresh` returns HTTP 500 (see + https://github.com/Adventech/sabbath-school-ios/issues/204). + - Most paths under `/api/*` return the Cloudflare Pages SPA fallback + (`index.html`) instead of JSON, except `/api/v{2,3}/auth/*`. + version: 0.1.0-draft + contact: + name: Adventech Ministry + url: https://github.com/Adventech + license: + name: MIT + url: https://github.com/Adventech/sabbath-school-api-docs/blob/main/LICENSE + +servers: + - url: https://sabbath-school.adventech.io + description: Production + - url: https://sabbath-school-stage.adventech.io + description: Stage + +security: + - firebaseAccessToken: [] + +paths: + # --------------------------------------------------------------------------- + # Auth + # --------------------------------------------------------------------------- + + /api/v2/auth/signin/anonymous: + post: + summary: Anonymous sign-in + description: | + Mints a fresh anonymous Firebase user. The response is the same shape + returned by Firebase Identity Toolkit for `accounts:signUp`. + security: [] + responses: + '200': + description: Anonymous user record + content: + application/json: + schema: + $ref: '#/components/schemas/UserModel' + + /api/v3/auth/signin/anonymous: + post: + summary: Anonymous sign-in (v3) + description: Same as v2; surfaced by the iOS client for parity. + security: [] + responses: + '200': + description: Anonymous user record + content: + application/json: + schema: + $ref: '#/components/schemas/UserModel' + + /api/v2/auth/signin/google: + post: + summary: Google sign-in + description: Exchanges a Google auth credential for a Firebase user record. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuthRequest' + responses: + '200': + description: User record + content: + application/json: + schema: + $ref: '#/components/schemas/UserModel' + + /api/v2/auth/refresh: + post: + summary: Refresh the access token (BROKEN, returns HTTP 500) + description: | + Called by the iOS client when the access token expires. As of 2026-08-01 + both v2 and v3 return HTTP 500 with body + `{"message":"Internal Server Error"}`. Clients fall back to anonymous + sign-in, which generates a new uid and orphans any server-side state + keyed to the old uid. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserModel' + responses: + '200': + description: Refreshed user record + content: + application/json: + schema: + $ref: '#/components/schemas/UserModel' + '500': + description: Currently broken. See issue #204. + + /api/v2/auth/delete: + post: + summary: Delete the authenticated user account + responses: + '200': + description: Deletion acknowledged + content: + application/json: {} + + # --------------------------------------------------------------------------- + # Resources (v3) + # --------------------------------------------------------------------------- + + /api/v3/resources/index.json: + get: + summary: List supported languages + operationId: languages + responses: + '200': + description: Language list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LanguageResponse' + + /api/v3/{language}/{type}/index.json: + get: + summary: Feed for a language and content type + operationId: feed + parameters: + - name: language + in: path + required: true + schema: { type: string } + - name: type + in: path + required: true + schema: + type: string + enum: [lessons, audios, videos] + responses: + '200': + description: Feed + content: + application/json: + schema: + $ref: '#/components/schemas/FeedResponse' + + /api/v3/{language}/{type}/feeds/{groupId}/index.json: + get: + summary: A single feed group within a feed + operationId: feedGroup + parameters: + - name: language + in: path + required: true + schema: { type: string } + - name: type + in: path + required: true + schema: { type: string } + - name: groupId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Feed group + content: + application/json: + schema: + $ref: '#/components/schemas/FeedGroup' + + /api/v3/{index}/sections/index.json: + get: + summary: A resource by index + operationId: resource + parameters: + - name: index + in: path + required: true + schema: { type: string } + responses: + '200': + description: Resource + content: + application/json: + schema: + $ref: '#/components/schemas/Resource' + + /api/v3/{index}/index.json: + get: + summary: A document or segment by index + description: | + Same path serves either a `ResourceDocument` or a `Segment`. The Android + Retrofit interfaces expose two separate methods (`document`, `segment`) + with identical path templates; the response type is determined by the + caller. + operationId: documentOrSegment + parameters: + - name: index + in: path + required: true + schema: { type: string } + responses: + '200': + description: Document or segment + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ResourceDocument' + - $ref: '#/components/schemas/Segment' + + /api/v3/resources/user/input/document/{documentId}: + get: + summary: User input (highlights, notes) for a document + operationId: userInput + parameters: + - name: documentId + in: path + required: true + schema: { type: string } + responses: + '200': + description: User input list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserInput' + + /api/v3/resources/user/input/{inputType}/{documentId}/{blockId}: + post: + summary: Persist user input + operationId: saveUserInput + parameters: + - name: inputType + in: path + required: true + schema: { type: string } + - name: documentId + in: path + required: true + schema: { type: string } + - name: blockId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserInputRequest' + responses: + '200': + description: Saved + content: + application/json: {} + + /api/v3/{index}/audio.json: + get: + summary: Audio assets for an index + operationId: audio + parameters: + - name: index + in: path + required: true + schema: { type: string } + responses: + '200': + description: Audio assets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AudioAux' + + /api/v3/{index}/video.json: + get: + summary: Video assets for an index + operationId: video + parameters: + - name: index + in: path + required: true + schema: { type: string } + responses: + '200': + description: Video assets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/VideoAux' + + # --------------------------------------------------------------------------- + # Lessons (v2) + # --------------------------------------------------------------------------- + + /api/v2/{lesson}/index.json: + get: + summary: Lesson info by path + operationId: getLessonInfo + parameters: + - name: lesson + in: path + required: true + schema: { type: string } + responses: + '200': + description: Lesson info + content: + application/json: + schema: + $ref: '#/components/schemas/SSLessonInfo' + + /api/v2/{lang}/quarterlies/index.json: + get: + summary: Quarterlies index for a language + operationId: getQuarterlies + parameters: + - name: lang + in: path + required: true + schema: { type: string } + responses: + '200': + description: Quarterlies index + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SSQuarterlyIndex' + + /api/v2/{lang}/quarterlies/{id}/index.json: + get: + summary: Quarterly info + operationId: getQuarterlyInfo + parameters: + - name: lang + in: path + required: true + schema: { type: string } + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Quarterly info + content: + application/json: + schema: + $ref: '#/components/schemas/SSQuarterlyInfo' + + /api/v2/video/languages.json: + get: + summary: Languages that have video lessons + operationId: getVideoLanguages + responses: + '200': + description: Language list + content: + application/json: + schema: + type: array + items: { type: string } + + /api/v2/{lang}/video/latest.json: + get: + summary: Latest video lessons for a language + operationId: getLatestVideo + parameters: + - name: lang + in: path + required: true + schema: { type: string } + responses: + '200': + description: Video metadata + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/VideosInfoModel' + + /api/v1/{lang}/quarterlies/{quarterly_id}/audio.json: + get: + summary: Audio lessons for a quarterly (legacy v1 endpoint) + operationId: getAudio + parameters: + - name: lang + in: path + required: true + schema: { type: string } + - name: quarterly_id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Audio lessons + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SSAudio' + +# ----------------------------------------------------------------------------- +# Components +# ----------------------------------------------------------------------------- + +components: + securitySchemes: + firebaseAccessToken: + type: apiKey + in: header + name: x-ss-auth-access-token + description: | + Firebase auth access token. Minted by `/api/v{2,3}/auth/signin/anonymous` + or any of the Google sign-in endpoints. + + schemas: + UserModel: + type: object + properties: + uid: { type: string } + displayName: { type: string, nullable: true } + photoURL: { type: string, nullable: true } + email: { type: string, nullable: true } + emailVerified: { type: boolean } + phoneNumber: { type: string, nullable: true } + isAnonymous: { type: boolean } + apiKey: { type: string } + stsTokenManager: + type: object + properties: + accessToken: { type: string } + refreshToken: { type: string } + required: [uid, isAnonymous, apiKey, stsTokenManager] + + AuthRequest: + type: object + properties: + idToken: { type: string } + # The Android source defines additional fields; the exact shape is not + # stable across versions. Treat this as a marker. + required: [idToken] + + LanguageResponse: + type: object + properties: + code: { type: string } + name: { type: string } + native: { type: string, nullable: true } + + FeedResponse: + type: object + properties: + groups: + type: array + items: + $ref: '#/components/schemas/FeedGroup' + + FeedGroup: + type: object + properties: + id: { type: string } + title: { type: string } + order: { type: integer } + feeds: + type: array + items: + type: object + + Resource: + type: object + properties: + id: { type: string } + title: { type: string } + index: { type: string } + sections: + type: array + items: + type: object + + ResourceDocument: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + # BlockKit schema; the actual shape is owned by + # https://github.com/Adventech/blockkit and is not exhaustively + # documented here. + additionalProperties: true + + Segment: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + segment: { type: string } + additionalProperties: true + + UserInput: + type: object + properties: + id: { type: string } + documentId: { type: string } + blockId: { type: string } + type: { type: string } + # The remainder of the shape is block-specific. + additionalProperties: true + + UserInputRequest: + type: object + additionalProperties: true + + AudioAux: + type: object + properties: + target: { type: string } + src: { type: string } + title: { type: string } + artist: { type: string } + + VideoAux: + type: object + properties: + target: { type: string } + src: { type: string } + title: { type: string } + artist: { type: string } + + SSLessonInfo: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + start_date: { type: string, format: date } + end_date: { type: string, format: date } + cover: { type: string } + pdf: { type: string } + # Full lesson metadata is large and version-dependent. + + SSQuarterlyIndex: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + cover: { type: string } + + SSQuarterlyInfo: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + start_date: { type: string, format: date } + end_date: { type: string, format: date } + cover: { type: string } + # Full quarterly metadata is large and version-dependent. + + SSAudio: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + src: { type: string } + + VideosInfoModel: + type: object + properties: + id: { type: string } + index: { type: string } + title: { type: string } + thumbnail: { type: string } \ No newline at end of file