Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/play-metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Upload Google Play descriptions

on:
workflow_dispatch:
inputs:
metadata_commit:
description: "Full commit SHA containing the reviewed descriptions for the Play release"
required: true
type: string

permissions:
contents: read

concurrency:
group: google-play-descriptions
cancel-in-progress: false

jobs:
upload:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4.10"
bundler-cache: true
- name: Upload reviewed descriptions
env:
PLAY_METADATA_COMMIT: ${{ inputs.metadata_commit }}
SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }}
run: bundle exec fastlane android play_metadata
- name: Next step
run: echo 'Descriptions uploaded without submission. Check both languages and the matching release in Play Console before sending for review.' >> "$GITHUB_STEP_SUMMARY"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ google-services.json
.DS_Store
fastlane/report.xml
DevServer/.env
ReviewServer/.env
ReviewServer/credentials.json

.venv/
__pycache__/
Expand Down
7 changes: 7 additions & 0 deletions ReviewServer/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
message487.158.160.132.57.nip.io {
encode gzip
request_body {
max_size 1MB
}
reverse_proxy 172.30.48.2:5678
}
80 changes: 80 additions & 0 deletions ReviewServer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Google Play SMS review server

Dedicated synthetic-data n8n instance for reproducing Android → computer SMS transfer.
The deployed site is configured in [Caddyfile](Caddyfile); the VM directory is
`/opt/message487-review`. Keep it running throughout review. It is separate from the
MegaProxy SSH review account on the same VM.

The n8n account owns only this disposable review instance. It has no personal workflows,
requires no invitation/OTP/payment, and has no scheduled expiry. Ordinary app users still
provide their own receiver. Never use this shared review account for private messages.

## Configuration and startup

Use Docker Compose and Caddy. On the small review VM, swap supplements RAM; this is a
low-volume test receiver. Resource limits and execution retention live in [compose.yaml](compose.yaml).
The Docker network has no external route. Caddy connects directly to the fixed container
address in Compose; n8n has no published host port. Outbound integrations are intentionally
unavailable on this instance.

Create a private `.env` containing `REVIEW_HOST`, `REVIEW_EMAIL` and
`REVIEW_PASSWORD_HASH`. Use a unique reviewer password and a bcrypt hash; single-quote the
hash in `.env` so Compose does not interpolate its dollar signs. Create `credentials.json`
using the structure of [the local fixture](../DevServer/credentials/header-auth.json),
keeping credential ID `message487-header-auth` but replacing its value with `Bearer ` plus
a newly generated secret token. Do not deploy the public development credentials.
Keep the server directory accessible only to the administrator; the mounted JSON files must
be readable by the container's `node` user. Neither secret file belongs in Git.

On a fresh instance, from its directory:

```sh
sudo docker compose run --rm --no-deps n8n import:credentials --input=/bootstrap/credentials.json
sudo docker compose run --rm --no-deps n8n import:workflow --input=/bootstrap/workflow.json
sudo docker compose run --rm --no-deps n8n publish:workflow --id=message487-review
sudo docker compose up -d --wait
```

Imports replace the matching IDs. Stop this instance before intentionally reimporting;
ordinary restarts need only `docker compose up -d --wait`. Preserve its volume and generated
encryption key. Back up the existing Caddy configuration and add the site block without
removing other routes; validate before reloading Caddy. Ports 80/443 must be reachable for
certificate issuance and HTTPS. Do not change the host's existing SSH restrictions.

## Reviewer procedure

Private credentials and copyable instructions are stored locally in
`~/.my-tokens/message487-play-review-158.160.132.57.{json,txt}`. The two existing Play Console
App access instructions were updated to this instance on 2 October 2026. Recheck them before
resubmitting; the old n8n Cloud video is not a demonstration of this instance.

1. Sign in on the computer and open **Message487 — Read SMS on computer**.
The workflow is already published; no setup or manual execution is required.
2. In Android **Connection**, save the HTTPS webhook URL and token without the `Bearer `
prefix. Keep **n8n confirmation** on.
3. In **Sources**, leave Notifications off, enable Incoming SMS and grant SMS permission.
4. Receive a new synthetic SMS. For the dedicated emulator:
`adb -s emulator-5560 emu sms send +15551234567 'A fresh review message'`.
5. On the computer, open **Executions → newest run → Logs → Read SMS on computer → Output**
and choose **Table** or **Schema**. Expand the Logs panel if the output is hidden.
6. Match Sender and Message with the phone's SMS app, and Event ID with
**Message487 Journal → newest SMS**. A connection-test event alone does not test SMS access.

Execution history contains webhook data, including authentication headers, and is accessible
to the dedicated reviewer account. Retention is bounded by the settings in Compose;
pruning is asynchronous. Remove test history through n8n when no longer needed. The receiver
acknowledges valid events but does not deduplicate retries or provide a general messaging inbox.

## Verification

```sh
python3 ReviewServer/check.py ~/.my-tokens/message487-play-review-158.160.132.57.json
```

This checks HTTPS, missing/wrong tokens, invalid payloads and a valid synthetic ACK. It does
not replace the Android SMS procedure. If a local HTTP proxy is incompatible with Python,
set `NO_PROXY` to the review hostname for that command.

For a fresh video, keep the phone and computer output readable together and show the
permission, incoming SMS, matching message and event ID, and stopping capture. Use the
release being reviewed; do not expose passwords, tokens or webhook request headers.
44 changes: 44 additions & 0 deletions ReviewServer/check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import argparse
import datetime
import json
import uuid
import urllib.error
import urllib.request
from pathlib import Path

parser = argparse.ArgumentParser(description='Check the live review webhook with synthetic data')
parser.add_argument('access_file', type=Path, help='Private JSON containing host and token')
args = parser.parse_args()
access = json.loads(args.access_file.read_text())
url = f'https://{access["host"]}/webhook/message487/receive'
event = {
'schema_version': 1,
'event_id': str(uuid.uuid4()),
'device_id': 'review-smoke-test',
'device_code': 'review-smoke-test',
'message_type': 'test',
'occurred_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'source': 'life.andre.message487',
'source_name': 'Message487',
'text': 'Synthetic webhook check; this is not an Android SMS',
}


def post(body, token=None):
headers = {'Content-Type': 'application/json'}
if token is not None:
headers['Authorization'] = f'Bearer {token}'
request = urllib.request.Request(url, json.dumps(body).encode(), headers)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, None


assert post(event)[0] in (401, 403), 'Unauthenticated requests must fail'
assert post(event, 'invalid-token')[0] in (401, 403), 'Wrong tokens must fail'
assert post({}, access['token'])[0] == 400, 'Invalid payloads must fail'
status, response = post(event, access['token'])
assert status == 200 and response == {'status': 'accepted', 'event_id': event['event_id']}
print(f'Webhook checks passed; synthetic event ID: {event["event_id"]}')
67 changes: 67 additions & 0 deletions ReviewServer/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: message487-review

services:
n8n:
image: docker.n8n.io/n8nio/n8n:2.38.1
restart: unless-stopped
networks:
default:
ipv4_address: 172.30.48.2
mem_limit: 640m
memswap_limit: 1g
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
environment:
NODE_OPTIONS: --max-old-space-size=384
N8N_HOST: ${REVIEW_HOST:?Set REVIEW_HOST}
N8N_PROTOCOL: https
N8N_EDITOR_BASE_URL: https://${REVIEW_HOST}
WEBHOOK_URL: https://${REVIEW_HOST}/
N8N_PROXY_HOPS: "1"
N8N_SECURE_COOKIE: "true"
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
N8N_TEMPLATES_ENABLED: "false"
N8N_PERSONALIZATION_ENABLED: "false"
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
N8N_BLOCK_ENV_ACCESS_IN_NODE: "true"
N8N_COMMUNITY_PACKAGES_ENABLED: "false"
NODES_EXCLUDE: '["n8n-nodes-base.executeCommand","n8n-nodes-base.readWriteFile","n8n-nodes-base.ssh"]'
N8N_INSTANCE_OWNER_MANAGED_BY_ENV: "true"
N8N_INSTANCE_OWNER_EMAIL: ${REVIEW_EMAIL:?Set REVIEW_EMAIL}
N8N_INSTANCE_OWNER_FIRST_NAME: Google Play
N8N_INSTANCE_OWNER_LAST_NAME: Reviewer
N8N_INSTANCE_OWNER_PASSWORD_HASH: ${REVIEW_PASSWORD_HASH:?Set REVIEW_PASSWORD_HASH}
EXECUTIONS_DATA_SAVE_ON_ERROR: all
EXECUTIONS_DATA_SAVE_ON_SUCCESS: all
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS: "true"
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "168"
EXECUTIONS_DATA_PRUNE_MAX_COUNT: "1000"
GENERIC_TIMEZONE: UTC
TZ: UTC
volumes:
- n8n-data:/home/node/.n8n
- ./workflow.json:/bootstrap/workflow.json:ro
- ./credentials.json:/bootstrap/credentials.json:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5678/healthz/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
logging:
driver: json-file
options:
max-size: 5m
max-file: "2"

volumes:
n8n-data:

networks:
default:
internal: true
ipam:
config:
- subnet: 172.30.48.0/24
131 changes: 131 additions & 0 deletions ReviewServer/workflow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
{
"id": "message487-review",
"name": "Message487 — Read SMS on computer",
"active": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "message487/receive",
"responseMode": "responseNode",
"options": {},
"authentication": "headerAuth"
},
"id": "webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
0
],
"webhookId": "message487-review",
"credentials": {
"httpHeaderAuth": {
"id": "message487-header-auth",
"name": "Message487 review webhook"
}
}
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { status: (typeof $json.body.event_id === 'string' && $json.body.event_id.length > 0 && $json.body.schema_version === 1 && ['test', 'notification', 'sms'].includes($json.body.message_type) && typeof $json.body.text === 'string') ? 'accepted' : 'rejected', event_id: $json.body.event_id ?? null } }}",
"options": {
"responseCode": "={{ (typeof $json.body.event_id === 'string' && $json.body.event_id.length > 0 && $json.body.schema_version === 1 && ['test', 'notification', 'sms'].includes($json.body.message_type) && typeof $json.body.text === 'string') ? 200 : 400 }}"
}
},
"id": "response",
"name": "Respond",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
440,
0
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "sender",
"name": "Sender",
"value": "={{ $json.body.sender }}",
"type": "string"
},
{
"id": "text",
"name": "Message",
"value": "={{ $json.body.text }}",
"type": "string"
},
{
"id": "occurred_at",
"name": "Received at",
"value": "={{ $json.body.occurred_at }}",
"type": "string"
},
{
"id": "device_code",
"name": "Phone",
"value": "={{ $json.body.device_code }}",
"type": "string"
},
{
"id": "event_id",
"name": "Event ID",
"value": "={{ $json.body.event_id }}",
"type": "string"
},
{
"id": "message_type",
"name": "Type",
"value": "={{ $json.body.message_type }}",
"type": "string"
}
]
},
"options": {}
},
"id": "read-sms",
"name": "Read SMS on computer",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
660,
0
]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Respond",
"type": "main",
"index": 0
}
]
]
},
"Respond": {
"main": [
[
{
"node": "Read SMS on computer",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"saveDataErrorExecution": "all",
"saveDataSuccessExecution": "all",
"saveManualExecutions": true
}
}
Loading
Loading