Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0256a1d
Extract width constants and sync alert state
OSPFNeighbour Jul 2, 2026
4d4e8b4
Merge branch 'master' into master-dev
OSPFNeighbour Jul 13, 2026
efb5851
Add collaborative tasking layers (#384)
OSPFNeighbour Aug 3, 2026
dfdb7ec
Bump postcss from 8.5.10 to 8.5.25 (#383)
dependabot[bot] Aug 3, 2026
03c6945
Bump immutable from 4.3.8 to 4.3.9 (#382)
dependabot[bot] Aug 3, 2026
641b361
Bump tmp from 0.2.6 to 0.2.7 (#377)
dependabot[bot] Aug 3, 2026
d53f2ed
Bump js-yaml from 4.1.1 to 4.3.0 (#381)
dependabot[bot] Aug 3, 2026
0e9e535
Require Beacon auth on all lambda.lighthouse-extension.com Lambdas (#…
OSPFNeighbour Aug 3, 2026
138a067
Log Beacon-authenticated user on each lad_v2 Lambda invocation (#386)
OSPFNeighbour Aug 3, 2026
909b078
Fix map-layers index.json retry to catch S3's actual conflict status …
OSPFNeighbour Aug 3, 2026
cb2d7d3
Allow multiple trusted Beacon token issuers (#388)
OSPFNeighbour Aug 4, 2026
b0f260f
Add comments to collaborative map markers; replace marker icon set (#…
OSPFNeighbour Aug 4, 2026
b677759
fixed opslog length issues
OSPFNeighbour Aug 4, 2026
48558e1
Add per-layer permissions (read-only, delete, comments) to collaborat…
OSPFNeighbour Aug 4, 2026
d78cf83
Fix flash of unbound placeholder content before config modal shows (#…
OSPFNeighbour Aug 4, 2026
53799c4
Give collaborative layer markers their own map pane (#392)
OSPFNeighbour Aug 4, 2026
d349dc9
Add HQ/event attachment, moderator-managed permissions, and a subscri…
OSPFNeighbour Aug 5, 2026
903797d
Let layer owners/moderators edit collaborative layer permissions afte…
OSPFNeighbour Aug 6, 2026
704838a
Fix map popups rendering behind markers on the tasking map (#395)
OSPFNeighbour Aug 7, 2026
7ac6068
Let layer owners/moderators change a collaborative layer's HQ or even…
OSPFNeighbour Aug 7, 2026
7ee0c84
Move collaborative layer moderator editing into the settings modal (#…
OSPFNeighbour Aug 8, 2026
988afee
Improve collaborative layer moderator/HQ/event picker UX (#399)
OSPFNeighbour Aug 9, 2026
294bd2b
Clean up marker audit notice and show create-layer progress/success (…
OSPFNeighbour Aug 9, 2026
6c98aa0
Fix SMS send request built from the SMS modal (#402)
OSPFNeighbour Aug 10, 2026
8c19027
Fix BMB unit HQ coordinates in SES_HQs.geojson (#403)
OSPFNeighbour Aug 10, 2026
9d14a21
Bump brace-expansion from 1.1.13 to 1.1.18 (#401)
dependabot[bot] Aug 10, 2026
b78bddc
Fix wrong apiHost passed to transport incidents ops log lookup (#404)
OSPFNeighbour Aug 10, 2026
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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ dist
package
*.swp
.env

Lighthouse.zip
.DS_Store
*.DS_Store
Expand Down
177 changes: 177 additions & 0 deletions lambda/default-assets-v2/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* AWS Lambda — Default-Asset Mapping Store (v2, auth-gated)
*
* Stores each team's chosen default-asset as a small JSON file in S3,
* namespaced by the Beacon API URL so different environments
* (train / dev / prod) never collide.
*
* S3 structure:
* s3://{BUCKET}/{CONFIG_PREFIX}/{urlHash}/{teamId}.json
*
* Old objects are automatically expired by S3 Lifecycle policy.
*
* ── Routes ──
*
* GET /default-assets?apiUrl=…&teamIds=1,2,3
* → { "mapping": { "1": "assetA", "3": "assetC" } }
* Teams with no stored default are simply omitted from mapping.
*
* PUT /default-assets
* body: { "apiUrl": "…", "teamId": "…", "assetId": "…" }
* → { "saved": true, "teamId": "…", "assetId": "…" }
*
* Both routes require `Authorization: Bearer <Beacon access token>`.
*/

import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import crypto from 'crypto';
import { verifyBeaconToken } from './verifyBeaconToken.mjs';

// ── Config ──────────────────────────────────────────────────────────
const BUCKET = process.env.BUCKET_NAME || 'lighthouse-default-assets';
const CONFIG_PREFIX = process.env.CONFIG_PREFIX || '';

const s3 = new S3Client({});

// ── Helpers ─────────────────────────────────────────────────────────

/** Deterministic short hash of the API URL (namespace key). */
function hashApiUrl(apiUrl) {
return crypto.createHash('sha256').update(apiUrl.trim().toLowerCase()).digest('hex').slice(0, 16);
}

/** Build the S3 object key for one team. */
function s3Key(urlHash, teamId) {
const base = CONFIG_PREFIX ? `${CONFIG_PREFIX}/${urlHash}` : urlHash;
return `${base}/${teamId}.json`;
}

/** Read a single object; returns parsed JSON or null if missing. */
async function getObject(key) {
try {
const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
const body = await streamToString(res.Body);
return JSON.parse(body);
} catch (err) {
const code = err.name || err.Code || '';
const status = err.$metadata?.httpStatusCode;
if (code === 'NoSuchKey' || code === 'NotFound' || status === 404 || status === 403) {
return null;
}
// Unexpected error — log but don't throw; treat as missing
console.warn('getObject unexpected error for key:', key, err);
return null;
}
}

/** Convert a readable stream to a string. */
function streamToString(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (c) => chunks.push(c));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
});
}

/** Standard JSON response helper. */
function respond(statusCode, body) {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
body: JSON.stringify(body),
};
}

// ── Handler ─────────────────────────────────────────────────────────

export const handler = async (event) => {
const method = event.httpMethod || event.requestContext?.http?.method || 'GET';

// CORS preflight
if (method === "OPTIONS") {
return respond(204, '');
}

let claims;
try {
claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization);
} catch (err) {
return respond(401, { error: 'Unauthorized', message: err?.message || String(err) });
}
console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'default-assets-v2', userId: claims.sub || claims.client_id || 'unknown', method }));

try {
// ---------- GET: Bulk fetch for a list of team IDs ----------
if (method === 'GET') {
const qs = event.queryStringParameters || {};
const apiUrl = qs.apiUrl;
const teamIds = qs.teamIds;

if (!apiUrl || !teamIds) {
return respond(400, { error: 'Missing required query params: apiUrl, teamIds' });
}

const urlHash = hashApiUrl(apiUrl);
const ids = teamIds.split(',').map(s => s.trim()).filter(Boolean);

if (ids.length === 0) {
return respond(200, { mapping: {} });
}

// Fan-out reads (S3 handles concurrency well at this scale)
const entries = await Promise.all(
ids.map(async (id) => {
const data = await getObject(s3Key(urlHash, id));
return data ? [id, data.assetId] : null;
})
);

const mapping = {};
for (const entry of entries) {
if (entry) mapping[entry[0]] = entry[1];
}

return respond(200, { mapping });
}

// ---------- PUT: Save a single mapping ----------
if (method === 'PUT') {
const body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body;
const { apiUrl, teamId, assetId } = body || {};

if (!apiUrl || !teamId || !assetId) {
return respond(400, { error: 'Missing required fields: apiUrl, teamId, assetId' });
}

const urlHash = hashApiUrl(apiUrl);
const key = s3Key(urlHash, teamId);

const payload = {
teamId: String(teamId),
assetId: String(assetId),
updatedAt: new Date().toISOString(),
};

await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: JSON.stringify(payload),
ContentType: 'application/json',
}));

return respond(200, { saved: true, teamId, assetId });
}

return respond(405, { error: `Method ${method} not allowed` });

} catch (err) {
console.error('Lambda error:', err);
return respond(500, { error: 'Internal server error' });
}
};
103 changes: 103 additions & 0 deletions lambda/geocode-v2/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import pkg from "@aws-sdk/client-geo-places";
import { verifyBeaconToken } from "./verifyBeaconToken.mjs";

const { GeoPlacesClient, GeocodeCommand, ReverseGeocodeCommand } = pkg;

const client = new GeoPlacesClient({
region: process.env.AWS_REGION, // required for SigV4 endpoints; Lambda sets AWS_REGION
});

const corsHeaders = {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,OPTIONS",
"access-control-allow-headers": "content-type,authorization",
};

const json = (statusCode, obj) => ({
statusCode,
headers: corsHeaders,
body: JSON.stringify(obj),
});

export const handler = async (event) => {
// CORS preflight
if (event?.httpMethod === "OPTIONS" || event.requestContext?.http?.method === "OPTIONS") {
return { statusCode: 204, headers: corsHeaders, body: "" };
}

let claims;
try {
claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization);
} catch (err) {
return json(401, { error: "Unauthorized", message: err?.message || String(err) });
}
console.log(JSON.stringify({ msg: "beacon_auth", fn: "geocode-v2", userId: claims.sub || claims.client_id || "unknown" }));

const qsp = event?.queryStringParameters || {};

// Forward geocode: ?q=...
const q = typeof qsp.q === "string" ? qsp.q.trim() : "";

// Reverse geocode: ?lat=&lon=
const lat = Number(qsp.lat);
const lon = Number(qsp.lon);

const maxResults = (() => {
const mr = Number(qsp.maxResults);
return Number.isFinite(mr) && mr > 0 ? Math.min(mr, 100) : 10;
})();

try {
if (q) {
// Places v2 Geocode request fields: QueryText, BiasPosition, Filter.IncludeCountries, MaxResults, etc. :contentReference[oaicite:1]{index=1}
const cmd = new GeocodeCommand({
QueryText: q,
MaxResults: maxResults,
BiasPosition: [151.2093, -33.8688], // [lng, lat]
Filter: {
IncludeCountries: ["AUS"],
},
IntendedUse: "SingleUse",
});

const resp = await client.send(cmd);

return json(200, {
input: { q },
results: resp.ResultItems ?? [],
});
}

if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return json(400, { error: "Provide either q=... or lat/lon numbers" });
}

// Places v2 ReverseGeocode request fields: QueryPosition, MaxResults, etc. :contentReference[oaicite:2]{index=2}
const cmd = new ReverseGeocodeCommand({
QueryPosition: [lon, lat], // [lng, lat]
MaxResults: maxResults,
Filter : {
IncludePlaceTypes: [
"PointAddress",
"Street",
"InterpolatedAddress"
]
},
IntendedUse: "SingleUse",
});

const resp = await client.send(cmd);

return json(200, {
input: { lat, lon },
results: resp.ResultItems ?? [],
});
} catch (err) {
const message =
err && typeof err === "object" && "message" in err
? String(err.message)
: "Unknown error";
return json(500, { error: "Places v2 request failed", message });
}
};
60 changes: 60 additions & 0 deletions lambda/map-layers-v2/handlers/addMarkerComment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store');
const { json, badRequest, notFound, forbidden } = require('../lib/response');
const { commentMode, isAuthorized } = require('../lib/permissions');

// POST /map-layers/{id}/features/{markerId}/comments
// body: { apiUrl, actorId, opsLogId }
//
// Appends the id of a client-side-created Operations Log entry to the
// marker's comment thread. Like upsertFeature's opsLogId, this Lambda only
// stores the pointer -- the comment's text/author lives entirely in that
// Ops Log entry and is resolved via BeaconClient.operationslog.get().
module.exports = async function addMarkerComment(event, claims) {
const layerId = event.pathParameters?.id;
const markerId = event.pathParameters?.markerId;
let body;
try {
body = JSON.parse(event.body || '{}');
} catch {
return badRequest('Invalid JSON body');
}

const apiUrl = body.apiUrl;
const actorId = String(body.actorId || '').slice(0, 100);
const opsLogId = Number(body.opsLogId);
const memberId = String(claims?.sub || '');

if (!apiUrl || !layerId || !markerId) {
return badRequest('apiUrl, layer id and marker id are required');
}
if (!Number.isFinite(opsLogId)) return badRequest('opsLogId must be a number');

const layer = await getLayerObject(apiUrl, layerId);
if (!layer) return notFound('Layer not found');

const marker = layer.markers.find((m) => m.id === markerId);
if (!marker) return notFound('Marker not found');

if (!isAuthorized(commentMode(layer), layer, memberId)) {
return forbidden('You do not have permission to comment on this layer');
}

marker.commentOpsLogIds = Array.isArray(marker.commentOpsLogIds) ? marker.commentOpsLogIds : [];
marker.commentOpsLogIds.push(opsLogId);

const now = new Date().toISOString();
marker.updatedBy = actorId || marker.updatedBy;
marker.updatedAt = now;

layer.lastUsedAt = now;
await putLayerObject(apiUrl, layerId, layer);

await updateIndex(apiUrl, (index) => {
const entry = index.layers.find((l) => l.id === layerId);
if (entry) entry.lastUsedAt = now;
});

return json(200, marker);
};
Loading
Loading